New request
#1909: security: web defense-in-depth — CSRF token, app-wide CSP, Secure cookie heuristic
We'll provision a sandbox, run an agent against the issue, and open a draft PR. You can pull the branch and iterate from there.
Summary
The auth and request-validation layer has solid baseline (PBKDF2 hashing, HMAC session cookies, path-traversal protection, no shell=True, credential redaction). Three defense-in-depth gaps remain that a future incident would lean on:
- CSRF protection is Origin/Referer-only — no token mechanism
- No app-wide Content-Security-Policy (only sandboxed CSP on file-preview responses)
- Secure cookie flag is set on a header heuristic that can be lied to
Each is small individually; together they're the gap between "we did the obvious things" and "a determined attacker spends real effort".
Detail
1. CSRF: Origin/Referer-only check, no token
api/routes.py:774-810:
def _check_csrf(handler) -> bool:
"""Reject cross-origin POST requests. Returns True if OK."""
origin = handler.headers.get("Origin", "")
referer = handler.headers.get("Referer", "")
if not origin and not referer:
return True # non-browser clients (curl, agent) have no Origin
...
Three failure modes today:
- No-Origin bypass — any client that omits Origin and Referer is treated as trusted (necessary for
curland the agent itself, but means a CSRF that scrubs both headers via<form>with redirected referrer policy passes the check). - Reverse proxy trust —
X-Forwarded-HostandX-Real-Hostare accepted from the request without verifying the proxy actually set them. A direct connection to the bind port can spoof either. - No second-factor token — there's no double-submit cookie or header-bound nonce, so any same-origin XSS is also a same-origin CSRF.
Fix: add a _csrf_token generated per session, embedded in the session-bootstrap response, and required as X-CSRF-Token on all state-changing requests. Keep the Origin/Referer check as a first gate.
2. No app-wide Content-Security-Policy
The only CSP in api/routes.py (around line 5140) is for sandboxed file-preview responses (sandbox allow-scripts). The main app HTML and /static/* responses ship no CSP.
csp = "sandbox allow-scripts" if html_inline_ok else None
Without a global CSP:
- A future XSS via
renderMd()or any innerHTML callsite has no last-line-of-defense. - Inline scripts and event handlers run unrestricted.
- No
connect-srcenforcement on outbound fetches.
The reviewer's finding called out "unsafe-inline" but the actual gap is broader: there's no app CSP at all to be lax. Adding one with nonce-based inline-script policy would close the gap properly.
Concrete: add a Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-<random>'; style-src 'self' 'nonce-<random>'; connect-src 'self'; img-src 'self' data: blob:; frame-ancestors 'none' header on the main HTML responses. Inline <script> tags get the per-request nonce. Test against the existing static/ JS to identify the few callsites that need restructuring.
3. Secure cookie flag depends on a spoofable header
api/auth.py:272-275:
# Set Secure flag when connection is HTTPS
if getattr(handler.request, 'getpeercert', None) is not None or handler.headers.get('X-Forwarded-Proto', '') == 'https':
cookie[COOKIE_NAME]['secure'] = True
Two issues:
X-Forwarded-Protofrom the request is trusted unconditionally. A direct connection to the bind port can spoofX-Forwarded-Proto: httpsand get the Secure flag set on a plaintext connection — which is mostly harmless (Secure-flagged cookies aren't sent over plaintext anyway, so the misconfiguration just breaks login over plaintext) but it's the wrong shape.- The inverse: a deployment behind a TLS-terminating proxy that doesn't set
X-Forwarded-Protowill fail to get Secure even though the user's actual connection is HTTPS. The cookie then leaks to plaintext if the proxy ever serves both schemes.
Fix: take the trust boundary from configuration, not from headers. Add HERMES_WEBUI_TRUST_FORWARDED_PROTO=1 env (or similar config) that explicitly opts in to trusting X-Forwarded-Proto. Otherwise default to Secure always when not on 127.0.0.1/localhost, and never trust headers.
Also: cookie is samesite=Lax (api/auth.py:269). Bumping to samesite=Strict for the auth cookie removes the last cross-site GET-with-cookie surface; Lax was historically required for OAuth redirects but the WebUI's auth cookie isn't used in any OAuth callback path so Strict is safe here.
Risk
- M3 — defense in depth, no current exploit
- All three are isolated changes, can land as separate PRs
- CSP rollout needs care: pre-merge audit of all inline
<script>/<style>and event-handler attrs in the served HTML, sinceunsafe-inlinewill not be in the policy
Reporter
Architecture / security review, May 8 2026.
Labels
security, enhancement, M3
Adding CSRF tokens, app-wide CSP, and secure cookie heuristics introduces new security capabilities across the API auth layer and frontend.
- api/routes.py
- api/auth.py
- static/js/login.js
- static/js/app.js
- api/session.py