287 lines
10 KiB
Markdown
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`.
|
|
|