You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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/:
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)
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>.
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.
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.
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.
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.
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.
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.
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)
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.
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.
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.
RFC: CSRF and browser-origin guard for the local Web UI
Background
mm webships an unauthenticated Web UI bound to127.0.0.1by default. CORS is locked tohttps?://(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 checksrequest.client.hostagainst the loopback set.That covers off-machine attackers, but not browser-origin attackers. While
mm webis running, any tab the user has open can issue cross-site requests againsthttp://127.0.0.1:<port>— and CORS is not the same as a write boundary:Content-Type: application/x-www-form-urlencodedPOSTs 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/jsonis preflighted, buttext/plainbody with FastAPI route schemas is not always rejected the way operators assume.<form>submissions.There is currently zero Origin / Referer / token check anywhere in
web/: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)
mm webrunning on:8080and visitsevil.example.comin the same browser. The attacker's page issuesfetch('http://127.0.0.1:8080/api/...', {method: 'POST', ...})or submits a hidden<form>.127.0.0.1so the same browser context can talk to the local server with the attacker's origin in the URL bar.mm web --host 0.0.0.0for a screen-share or proxies through ngrok. CORS is not a deployment / access-control boundary — the allow-list pattern matches the request'sOriginheader, not the server's bind address, so a non-loopback bind does not implicitly tighten access.Out of scope:
Design options
Option A — Session CSRF token + Origin/Referer check
session_csrfat app startup; store in app state.<meta>tag inindex.html, or aGET /api/sessionreturning{csrf, mode, ...}callable only from a same-Origin XHR).X-Memtomem-CSRFto matchsession_csrf.Origin(orRefererifOriginabsent) is present and not loopback.detailshape 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
Originis present and not loopback; reject whenOriginis absent on browser-shaped requests.Pros: zero SPA changes.
Cons: HTML
<form>POSTs from afile://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, since127.0.0.1.evil.example.comis not in the allow list). Weak vs. CORS-simple POSTs that omit Origin.Option C — Bearer token /
--auth-tokenAuthorization: Bearer <tok>on all routes (or just unsafe routes).--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
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.
Origin.index.html(bootstrap-on-load, no extra round-trip) vs. dedicatedGET /api/session(cleaner, one extra request). Inject likely wins.--hostis non-loopback. Auto-enable a stronger mode (require--auth-tokenor print one)? Or just refuse to start without--auth-token? Currently--host 0.0.0.0just works — that is probably wrong default-of-defaults./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.TestClient-based unit tests proving:<form>-submission edge: forms can't issue DELETE, butfetchcan)feedback_staged_default_flip.md— probably a 2-PR sequence: PR1 adds endpoint + middleware in log-only mode + SPA wiring; PR2 flips to enforcement. OptionalMEMTOMEM_WEB__CSRF_ENFORCEenv toggle for rollback.Out of scope for this RFC
mm webto rotate; no need for in-process rotation.Verification anchors
When this is implemented, the audit shape should look like:
web/routes/*runs through the middleware; an AST/registry test enforces this (feedback_ast_architectural_guard_pattern.md)._require_localhostbecomes 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
docs/reports/security-hardening-plan-2026-05-05.mdFinding refactor(stm): decouple surfacing via remote-only MCP #2 (the file is local-only and gitignored).web/routes/system.py:106— only checksrequest.client.host.web/app.py:204.api(...)helper that would gain the header:web/static/app.js(searchfunction 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.