github →

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.

Issue
enhancementauth

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:

  1. CSRF protection is Origin/Referer-only — no token mechanism
  2. No app-wide Content-Security-Policy (only sandboxed CSP on file-preview responses)
  3. 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 curl and the agent itself, but means a CSRF that scrubs both headers via <form> with redirected referrer policy passes the check).
  • Reverse proxy trustX-Forwarded-Host and X-Real-Host are 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-src enforcement 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-Proto from the request is trusted unconditionally. A direct connection to the bind port can spoof X-Forwarded-Proto: https and 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-Proto will 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, since unsafe-inline will not be in the policy

Reporter

Architecture / security review, May 8 2026.

Labels

security, enhancement, M3

Assessmentadvisory
feature●● medium85% confidence

Adding CSRF tokens, app-wide CSP, and secure cookie heuristics introduces new security capabilities across the API auth layer and frontend.

Likely files
  • api/routes.py
  • api/auth.py
  • static/js/login.js
  • static/js/app.js
  • api/session.py
Create the request

This opens a fresh agent run and a draft PR for issue #1909.

Cancel