Skip to content

RFC: CSRF and browser-origin guard for the local Web UI #787

@memtomem

Description

@memtomem

RFC: CSRF and browser-origin guard for the local Web UI

Background

mm web ships an unauthenticated Web UI bound to 127.0.0.1 by default. CORS is locked to https?://(localhost|127.0.0.1)(:\d+)? (web/app.py:204), and a small set of admin routes use _require_localhost (web/routes/system.py:106) which checks request.client.host against the loopback set.

That covers off-machine attackers, but not browser-origin attackers. While mm web is running, any tab the user has open can issue cross-site requests against http://127.0.0.1:<port> — and CORS is not the same as a write boundary:

  • Content-Type: application/x-www-form-urlencoded POSTs are CORS-simple and hit the route even if the response is unreadable to the attacker (DELETE / PATCH side effects fire regardless).
  • Content-Type: application/json is preflighted, but text/plain body with FastAPI route schemas is not always rejected the way operators assume.
  • Localhost services have a long history of being reached from the browser via DNS rebinding, naive CORS configs, and <form> submissions.

There is currently zero Origin / Referer / token check anywhere in web/:

$ grep -rn 'csrf\|CSRF\|"origin"\|"referer"\|X-Memtomem-CSRF' \
    packages/memtomem/src/memtomem/web | grep -v vendor/swagger
(no matches)

The redaction guard (privacy.enforce_write_guard) catches credential-shaped writes regardless of origin, so the worst-case "exfiltrate secret to LTM" path is mostly closed. But config mutation, memory-dir add/remove, indexing kicks, source delete, reset, and chunk edits all run with whatever the route accepts — and that is the actual attack surface for a malicious tab.

Threat model (what we are defending against)

  1. Drive-by tab on the same machine. User has mm web running on :8080 and visits evil.example.com in the same browser. The attacker's page issues fetch('http://127.0.0.1:8080/api/...', {method: 'POST', ...}) or submits a hidden <form>.
  2. DNS rebinding. Attacker domain initially resolves to a public IP (passes SOP), then re-resolves to 127.0.0.1 so the same browser context can talk to the local server with the attacker's origin in the URL bar.
  3. Misconfigured tunnel / share. User runs mm web --host 0.0.0.0 for a screen-share or proxies through ngrok. CORS is not a deployment / access-control boundary — the allow-list pattern matches the request's Origin header, not the server's bind address, so a non-loopback bind does not implicitly tighten access.

Out of scope:

  • A truly local attacker with code execution on the machine (game over already).
  • Server-side request forgery against the Web UI (different thread; covered separately by the URL-fetch SSRF guard).

Design options

Option A — Session CSRF token + Origin/Referer check

  • Generate a random session_csrf at app startup; store in app state.
  • Expose only via the initial same-origin SPA bootstrap (e.g. inject as a <meta> tag in index.html, or a GET /api/session returning {csrf, mode, ...} callable only from a same-Origin XHR).
  • Middleware: on any non-GET/HEAD/OPTIONS, require X-Memtomem-CSRF to match session_csrf.
  • Secondary: reject when Origin (or Referer if Origin absent) is present and not loopback.
  • 403 on miss with a stable detail shape so SPA can show "session expired, reload to continue".

Pros: defense against (1), (2), and partially (3). No extra UX.
Cons: SPA must thread the token through every api(...) helper. MCP / curl users hitting the Web API directly would need to fetch+forward the token (acceptable — not the documented use case).

Option B — Origin/Referer-only, no token

  • Reject non-GET/HEAD/OPTIONS when Origin is present and not loopback; reject when Origin is absent on browser-shaped requests.

Pros: zero SPA changes.
Cons: HTML <form> POSTs from a file:// page have ambiguous Origin in some browsers; doesn't defend against DNS rebinding (browser sends the rebound hostname as Origin, which would have to be loopback to even reach us — actually this does mostly cover rebinding, since 127.0.0.1.evil.example.com is not in the allow list). Weak vs. CORS-simple POSTs that omit Origin.

Option C — Bearer token / --auth-token

  • Generate a token at startup, print to stdout, require Authorization: Bearer <tok> on all routes (or just unsafe routes).
  • Optional flag --auth-token <value> for stable tokens in long-running sessions.

Pros: simplest model; works for non-browser callers too.
Cons: real UX cost — user has to copy the token into the SPA URL or paste into a login screen on every restart. Hostile for the "open mm web, it just works" pitch.

Option D — Status quo + docs warning

  • Document the threat in SECURITY.md, no code change.

Pros: nothing breaks.
Cons: feedback_silent_policy_enforcement_gap.md — docs-only is not enforcement.

Decision points to settle in this RFC

Current lean: A+B, with the token as the primary write guard and Origin/Referer as a secondary browser-origin guard. Open to A-only or a staged B-to-A+B path depending on feedback on the cost/benefit of the SPA-side token plumbing.

  1. A vs. B vs. A+B. Token primary + Origin/Referer secondary covers both CORS-simple POSTs (token gate) and ambient credential leakage scenarios A alone might miss. A-only is acceptable if the SPA wiring cost is judged too high; B-only does not cover CORS-simple POSTs that omit Origin.
  2. Token transport. Inject in index.html (bootstrap-on-load, no extra round-trip) vs. dedicated GET /api/session (cleaner, one extra request). Inject likely wins.
  3. What to do when --host is non-loopback. Auto-enable a stronger mode (require --auth-token or print one)? Or just refuse to start without --auth-token? Currently --host 0.0.0.0 just works — that is probably wrong default-of-defaults.
  4. Endpoint scope. Apply to /api/* only, or also to MCP routes? MCP runs under stdio transport for the typical install, not HTTP, so not relevant unless someone is running the FastMCP HTTP transport. Confirm and document.
  5. Test coverage. TestClient-based unit tests proving:
    • GET still works without token
    • non-GET without token → 403
    • non-GET with wrong token → 403
    • non-GET with right token + non-loopback Origin → 403
    • DELETE without token → 403 (regression for the <form>-submission edge: forms can't issue DELETE, but fetch can)
  6. Migration path. Single PR (gate flips with the SPA wired up), or staged (add the guard in dry-run-warn mode, then flip)? feedback_staged_default_flip.md — probably a 2-PR sequence: PR1 adds endpoint + middleware in log-only mode + SPA wiring; PR2 flips to enforcement. Optional MEMTOMEM_WEB__CSRF_ENFORCE env toggle for rollback.

Out of scope for this RFC

  • Authentication beyond CSRF (multi-user, ACL, audit log subjects). Single-user local tool stays single-user.
  • Token rotation. Restart mm web to rotate; no need for in-process rotation.
  • Cookie-based sessions. We have no cookies today and adding them is a bigger surface than this threat justifies.

Verification anchors

When this is implemented, the audit shape should look like:

  • Any non-GET handler in web/routes/* runs through the middleware; an AST/registry test enforces this (feedback_ast_architectural_guard_pattern.md).
  • A canonical fixture test asserts the failure shape for the 4 negative cases listed above.
  • _require_localhost becomes redundant for routes covered by the token middleware; either remove it (one-axis defense) or keep both with a comment explaining each one's role.

References

  • Today's local audit: docs/reports/security-hardening-plan-2026-05-05.md Finding refactor(stm): decouple surfacing via remote-only MCP #2 (the file is local-only and gitignored).
  • Existing localhost gate: web/routes/system.py:106 — only checks request.client.host.
  • CORS config (does not gate writes): web/app.py:204.
  • SPA api(...) helper that would gain the header: web/static/app.js (search function api().

Discussion welcome on whether this should land as A+B, A-only, or a staged B-to-A+B path. Implementation will be at least 2 PRs (middleware + SPA wiring; enforcement flip) plus a SECURITY.md update.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestsecuritySecurity-related issue or fix

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions