Lumi/security-audit-report.md
2026-05-30 20:37:42 +02:00

10 KiB

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)

Authentication entrypoints

Admin endpoints (unauthenticated behavior)

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

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.
  1. Session cookie missing Secure/SameSite
  • Run curl -I https://lumi.ookamikun.tv/.
  • Observe Set-Cookie: connect.sid=...; Path=/; HttpOnly without Secure/SameSite.
  1. Session fixation risk
  • Start a session and capture the connect.sid cookie.
  • Complete OAuth login; the session ID remains the same (no regeneration).
  1. 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.
  1. 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/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

--- 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

--- 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

--- 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

--- 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.