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

287 lines
10 KiB
Markdown

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