# Lumi Bot Public Surface Security Audit Date: 2026-01-22 Target: https://lumi.ookamikun.tv (unauthenticated/public surface) Scope: Public pages, unauthenticated endpoints, and code review of the local repo. ## 0) Guardrails - No destructive actions performed. - No authentication performed. - Only safe, read-only checks and code review. ## 1) Public Attack Surface Inventory ### Discovered public URLs (unauthenticated) - https://lumi.ookamikun.tv/ - https://lumi.ookamikun.tv/commands - https://lumi.ookamikun.tv/leaderboards - https://lumi.ookamikun.tv/plugins/expression-interaction - https://lumi.ookamikun.tv/plugins/sample-plugin ### Authentication entrypoints - https://lumi.ookamikun.tv/auth/discord (302 to Discord OAuth) - https://lumi.ookamikun.tv/auth/twitch/login (302 to Twitch OAuth) ### Admin endpoints (unauthenticated behavior) - https://lumi.ookamikun.tv/admin (302 to /auth/discord) - https://lumi.ookamikun.tv/admin/commands (302 to /auth/discord) - https://lumi.ookamikun.tv/admin/settings (302 to /auth/discord) ### Hidden endpoint probes (all 404) - /api, /api/, /api/v1 - /oauth, /callback, /webhook, /webhook/discord - /health, /metrics, /status, /debug, /logs - /swagger, /openapi, /graphql ### Third-party integrations observed - Discord OAuth (authorize, token, user fetch) via /auth/discord - Twitch OAuth (authorize, token, user fetch) via /auth/twitch/login ### JS/CSS assets - https://lumi.ookamikun.tv/app.js - https://lumi.ookamikun.tv/styles.css ### Source maps - /app.js.map and /styles.css.map return 404 (not exposed) ## 2) Passive Misconfiguration Checks ### TLS - HTTP -> HTTPS redirect observed for http://lumi.ookamikun.tv/ - HSTS present: `Strict-Transport-Security: max-age=63072000; preload` ### Mixed Content - No `http://` links detected in homepage HTML. ### Security Headers (public HTML endpoints) Observed on `/`, `/commands`, `/leaderboards`: - Present: `Strict-Transport-Security` - Present: `X-Powered-By: Express` - Missing: `Content-Security-Policy`, `X-Frame-Options`/`frame-ancestors`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy` ### Cookies - `connect.sid` set for public pages with `HttpOnly` only. - Missing: `Secure`, `SameSite` ### CORS - No CORS headers observed on public endpoints. ### Caching - HTML responses do not set explicit `Cache-Control`. - `/app.js` uses `Cache-Control: public, max-age=0`. ### Error handling - 404 returns default Express `Cannot GET /path` page (no stack trace or env leaks). ## 3) OAuth Flow Hardening (Discord/Twitch) Checklist: - State present and random: Yes (`crypto.randomBytes(16)`) - State validated on callback: Yes - Rejects missing/invalid state: Yes - Session binding: Uses session-stored state - Session fixation protection: Missing (no session regeneration after login) - Redirect handling: No open redirect parameter observed ## 4) Access Control Public pages returning data: - `/commands`: command list and counts (expected public?) - `/leaderboards`: leaderboards and expression summary - `/plugins/expression-interaction`: public plugin page with global stats - `/plugins/sample-plugin`: public content (despite role labeled `admin` in plugin) Admin endpoints: - All `/admin/*` routes protected by `requireRole("admin")`. ## 5) CSRF and Cross-site Request Risks - No CSRF middleware detected for POST routes (logout, profile updates, admin actions). - Session cookies do not set `SameSite`, increasing CSRF risk. - High-impact admin actions include plugin install/update, updates, and restart. ## 6) Injection Testing (safe) - No reflected parameters observed on public pages. - User-controlled content in HTML uses EJS escaped output (`<%= ... %>`). - Custom pages use raw HTML (`<%- page.content %>`), but creation is admin-gated. ## 7) Rate Limiting and Enumeration - No rate limiting middleware observed (e.g. `express-rate-limit`). - Public endpoints (`/commands`, `/leaderboards`) are enumerable without throttling. ## 8) Static Asset and Source Map Leakage - No `.map` files exposed. - No hardcoded secrets found in `src/web/public/app.js`. ## 9) Node/Express Pitfalls Review - No `helmet` usage for security headers. - `express-session` cookie flags missing. - No CSRF protection. - No session ID rotation after OAuth login. - `X-Powered-By` header enabled. - `web.mount` does not enforce `navItem.role` for plugin routes. - Dependency audit: `npm` not available in this environment, audit not executed. ## Findings | Severity | Title | Affected URL/endpoint | Evidence | Impact | Fix | | --- | --- | --- | --- | --- | --- | | High | Missing CSRF protection on state-changing routes | Multiple POST endpoints (e.g. `/admin/*`, `/profile/*`, `/auth/logout`) | No CSRF middleware in `src/web/server.js` and no Origin/Referer checks | Logged-in admins can be forced to install plugins, change settings, or trigger updates | Add CSRF tokens and/or Origin checks; set `SameSite=Lax` cookies | | Medium | Session cookie missing Secure and SameSite attributes | `/` (and other public pages) | `Set-Cookie: connect.sid=...; Path=/; HttpOnly` (no `Secure`/`SameSite`) | Session cookie can be sent over HTTP or cross-site requests; increases CSRF/session hijack risk | Configure `express-session` cookie flags; enable `trust proxy` when behind TLS terminator | | Medium | Session fixation risk after OAuth login | `/auth/discord/callback`, `/auth/twitch/callback` | Session is populated without regeneration (`req.session.user = ...`) | Attacker may fixate session ID before login and reuse it after victim authenticates | Call `req.session.regenerate()` on successful OAuth login | | Medium | Plugin route role not enforced | `/plugins/sample-plugin` (and any plugin using `web.mount`) | `web.mount` uses `app.use` without role guard; sample plugin labeled `admin` is public | Plugin pages intended for admins can be publicly accessible | Enforce `navItem.role` in `web.mount` with `requireRole` | | Low | Missing baseline security headers | `/`, `/commands`, `/leaderboards` | No CSP/XFO/XCTO/Referrer-Policy/Permissions-Policy; `X-Powered-By: Express` | Increases exposure to clickjacking/XSS/mime sniffing and framework fingerprinting | Use `helmet` and disable `x-powered-by` | ## Reproduction Steps 1) Missing CSRF protection - Log in as admin in a normal browser session. - Host a page that auto-submits a POST form to `https://lumi.ookamikun.tv/admin/plugins/install` with a malicious repo URL. - Visit the page while logged in; request succeeds without CSRF token. 2) Session cookie missing Secure/SameSite - Run `curl -I https://lumi.ookamikun.tv/`. - Observe `Set-Cookie: connect.sid=...; Path=/; HttpOnly` without `Secure`/`SameSite`. 3) Session fixation risk - Start a session and capture the `connect.sid` cookie. - Complete OAuth login; the session ID remains the same (no regeneration). 4) Plugin route role not enforced - Visit `https://lumi.ookamikun.tv/plugins/sample-plugin` while unauthenticated. - The page loads (200) despite being labeled `role: admin` in the plugin. 5) Missing security headers - Run `curl -I https://lumi.ookamikun.tv/`. - Confirm absence of CSP/XFO/XCTO/Referrer-Policy/Permissions-Policy headers. ## Suggested Patches / Config Snippets ### A) Session cookie hardening ```diff --- a/src/web/server.js +++ b/src/web/server.js @@ const app = express(); + app.set("trust proxy", 1); + app.disable("x-powered-by"); @@ app.use( session({ secret: ensureSessionSecret(), resave: false, saveUninitialized: false, - store: sessionStore + store: sessionStore, + cookie: { + httpOnly: true, + secure: true, + sameSite: "lax" + } }) ); ``` ### B) Add helmet with baseline headers ```diff --- a/src/web/server.js +++ b/src/web/server.js @@ -const express = require("express"); +const express = require("express"); +const helmet = require("helmet"); @@ const app = express(); + + app.use( + helmet({ + contentSecurityPolicy: { + useDefaults: true, + directives: { + "script-src": ["'self'"], + "style-src": ["'self'", "'unsafe-inline'"] + } + }, + referrerPolicy: { policy: "strict-origin-when-cross-origin" } + }) + ); ``` ### C) Rotate session ID after OAuth login ```diff --- a/src/web/server.js +++ b/src/web/server.js @@ - req.session.user = { + req.session.regenerate(() => { + req.session.user = { id: profile.id, username: profile.internal_username, avatar: user.avatar, roles, ...flags - }; - req.session.discordToken = token; - setFlash(req, "success", "Logged in."); - res.redirect("/"); + }; + req.session.discordToken = token; + setFlash(req, "success", "Logged in."); + res.redirect("/"); + }); ``` ### D) Enforce role protection for plugin mounts ```diff --- a/src/web/server.js +++ b/src/web/server.js @@ - mount: (mountPath, router, navItem) => { - app.use(mountPath, router); + mount: (mountPath, router, navItem) => { + const role = navItem?.role || "public"; + const middleware = role && role !== "public" ? requireRole(role) : null; + if (middleware) { + app.use(mountPath, middleware, router); + } else { + app.use(mountPath, router); + } if (navItem) { navItems.push({ ...navItem, path: mountPath }); } }, ``` ### E) CSRF protection for state-changing routes ```diff --- a/src/web/server.js +++ b/src/web/server.js @@ +const csrf = require("csurf"); @@ const app = express(); + const csrfProtection = csrf(); @@ - app.post("/auth/logout", (req, res) => { + app.post("/auth/logout", csrfProtection, (req, res) => { req.session.destroy(() => { res.redirect("/"); }); }); ``` Add hidden `_csrf` fields in forms and/or apply CSRF middleware globally after session initialization. ## Top 5 Fixes This Week 1) Add CSRF protections for all state-changing routes. 2) Enforce Secure/SameSite cookie flags for `connect.sid`. 3) Rotate session IDs after OAuth login to prevent fixation. 4) Add helmet (CSP/XFO/XCTO/Referrer-Policy/Permissions-Policy). 5) Enforce `navItem.role` for plugin routes. ## Verification Checklist (Post-fix) - `curl -I https://lumi.ookamikun.tv/` shows CSP, XFO/frame-ancestors, XCTO, Referrer-Policy, Permissions-Policy. - `Set-Cookie` includes `Secure` and `SameSite=Lax`. - OAuth login changes the session ID. - CSRF token required for POST to `/admin/*` endpoints. - `/plugins/sample-plugin` is no longer accessible without auth when role is `admin`.