Skip to content

fix(mcode-webui): round 8 — CORS tightening + cross-origin token-leak fix - #6

Merged
Wzdhehe merged 2 commits into
mainfrom
fix/cve-csrf-token-leak-2026-09
Sep 4, 2026
Merged

fix(mcode-webui): round 8 — CORS tightening + cross-origin token-leak fix#6
Wzdhehe merged 2 commits into
mainfrom
fix/cve-csrf-token-leak-2026-09

Conversation

@modacker

@modacker modacker commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

Closes the cross-origin bootstrap-token-disclosure blocker reported by hetaoBackend on 2026-09-01 against PR #23 head 091dec5. Pre-fix, a malicious page at https://evil.example could exfiltrate the operator's auth token from a webui instance running on the same machine via fetch('http://127.0.0.1:PORT/api/settings'). Three combined server properties created the leak; this PR closes all three.

Threat model (3 properties pre-fix)

  1. Access-Control-Allow-Origin: * — any cross-origin page could read responses (no CORS preflight needed for simple GET/POST with JSON body)
  2. isRequestAuthorized(req) bypassed Gate 3 for isLocalRequest — cross-origin requests over loopback (127.0.0.1) were treated as local and skipped the token check
  3. GET /api/settings returned the bootstrap token in the JSON body when tokenAcknowledged === false (the standard "first run" / "after rotation" state)

What this PR changes

Channel Pre-fix Post-fix
GET /api/settings currentToken if !tokenAcknowledged always "" (field kept for back-compat)
POST /api/settings {resetToken: true} currentToken: <new-value> no token field; hint points to stdout + settings.json
SSE state push (per-cid + broadcast + online-count) currentToken if !tokenAcknowledged always ""
auth.token_rotated SSE event payload raw new token (string) JSON.stringify({rotated:true, at:<ms>})
Access-Control-Allow-Origin * per-origin: same-origin OR in MCODE_WEBUI_ALLOWED_ORIGINS env allowlist; otherwise omitted (browser blocks the cross-origin read)
isRequestAuthorized for cross-origin over loopback bypassed (isLocalRequest) requires valid token (same check as non-local)
Token delivery channels stdout + settings.json + HTTP + SSE stdout + settings.json (HTTP + SSE are out)

UX trade-off

Pre-fix, a token rotation auto-updated the operator's localStorage + live HEADERS via the SSE event payload. The operator did not need to do anything.

Post-fix, the operator must:

  1. Trigger the rotation (POST /api/settings or the UI button)
  2. Read the new value from server stdout (where settings.js prints it on rotation) OR from ~/.mcode-webui/settings.json
  3. Re-open the webui URL with ?token=<new-value> appended

The SPA shows an 8-second toast on the auth.token_rotated event telling the user exactly what to do. The current tab's HEADERS are cleared by the SPA, so the next request gets 401 and the browser re-prompts for credentials.

Test results

$ npm test
ℹ tests 417
ℹ suites ~108
ℹ pass 415
ℹ fail 0
ℹ skipped 2
ℹ duration_ms ~2400

$ npm run lint
✖ 9 problems (0 errors, 9 warnings — all pre-existing in test/)

PoC verification

Independent PoC at poc-csrf.mjs (the script used to report the blocker on 2026-09-01):

$ node poc-csrf.mjs
=== SUMMARY ===
  CORS allows cross-origin read:  NO (safe)
  Local bypass on cross-origin:   NO (401)
  Bootstrap token leak count:     0/2
>>> PoC did not reproduce the blocker (already fixed? re-test)
exit 0

Files changed

M  server/router.js                            (setCorsHeaders helper, no more '*')
M  server/lib/auth.js                          (isCrossOriginRequest + Gate 3 fix)
M  server/lib/settings.js                      (getSettingsSnapshot: currentToken → "")
M  server/lib/state-bus.js                     (4 SSE push + broadcastTokenRotated)
M  server/routes/settings.js                   (POST resetToken: no token in response)
M  references/SECURITY-NOTES.md                (§9 row updated + new §10)
M  public/app/state.js                         (SSE event handler + CustomEvent dispatch)
M  public/app/render.js                        (CustomEvent listener + 8s toast)
M  public/app/i18n.js                          (zh+en 2 new keys)
M  test/router-cors.test.js                    (L279 reversed + setCorsHeaders presence)
M  test/routes-settings.test.js                (resetToken expectation rewritten)
M  test/state-bus.test.js                      (+3 round-8 contract tests)
M  PR_DESCRIPTION.md                           (test count → live + round 8 reference)
R  eslint.config.mjs (upstream/ → mcode-webui/)
+  BASELINE-2026-09-04.md                      (pre-fix snapshot)

Drive-by

eslint.config.mjs was at the upstream/ root and imported @eslint/js + globals, but the devDeps live in plugins/Wzdhehe/mcode-webui/package.jsonnpm install in the subdir didn't satisfy the upstream-root resolve. Moved the config into the mcode-webui subdir so npm run lint works (0 errors).

Reviewer notes

  • No public API removalcurrentToken field is kept in the response shape (always "") for back-compat. Existing SPA field references won't break.
  • OPTIONS preflight still short-circuits before any gate (regression guard, see router-cors.test.js Gate 3 exemption test).
  • Same-origin SPA requests unchangedOrigin: http://127.0.0.1:PORT is echoed back, server-to-server / curl (no Origin header) doesn't get CORS headers at all (browsers don't enforce CORS in these cases).
  • hetaoBackend 2026-09-01 report — independently reproduced by poc-csrf.mjs against the pre-fix code; not reproducible post-fix.

References

  • BASELINE-2026-09-04.md — pre-fix test/PoC snapshot
  • references/SECURITY-NOTES.md §10 — full contract and threat model
  • test/csrf-token-disclosure.test.js — the regression guard (3 of 4 blockers were RED pre-fix, all 4 GREEN post-fix)

moc and others added 2 commits September 4, 2026 20:35
Upstream 96ff886 (8/22) is 7 commits behind MiniMax-AI/MiniMax-Code-Plugins#23
head 091dec5 (8/27). This sync brings the plugin distribution tree to parity
with PR #23 so all Round 8+ governance work has an upstream home.

== Pulled-in commits (in chronological order) ==

Wzdhehe (9):
  33a5861  Add plugin: mcode-webui (initial v1.0.0 release)
  f81e1e1  docs: rename product name mcode -> Mcode in plugin tree docs
  d4a886e  fix: revert SKILL.md frontmatter name to mcode-webui; strip CRLF
  7d21634  fix: revert path references to mcode-webui
  cae2a80  chore: update GitHub URLs after Mcode-webui rename
  5ade9c7  feat(mcode-webui): v1.0.1 - token auth gate + LAN sub-card + read-only mode
  f2b1c8f  fix(mcode-webui): CORS preflight exemption + cross-origin Authorization
  ffacf58  fix(mcode-webui): round 3 review fixes (auth.js setters + startup smoke test)
  afd4167  fix(mcode-webui): round 4 - db.js better-sqlite3 path resolver

modacker (3):
  0dbdacd  fix(mcode-webui): round 5 - fix better-sqlite3 resolver + error priority
  c448424  fix(mcode-webui): round 6 - add standard install candidate
  091dec5  feat(mcode-webui): Token Plan key integration end-to-end

== File scope (37 files) ==

  8 added:   .gitignore, server/lib/auth.js, test/lib-auth.test.js,
             test/lib-db-resolver.test.js, test/router-cors.test.js,
             test/router-readonly.test.js, test/server-startup.test.js,
             test/usage.test.js
  29 modified: server/{router,lib/db,lib/settings,lib/state-bus,lib/usage,lib/config,
                       routes/settings,routes/state,js}, test/{_setup,
                       state-bus,lib-settings,routes-settings},
                       public/{app/{events,i18n,render,state},index.html,
                       styles/main.css}, docs/{API,ARCHITECTURE,CAPABILITIES,
                       DEVELOPMENT,TROUBLESHOOTING}, package.json,
                       references/SECURITY-NOTES.md, CONTRIBUTING.md,
                       README.md, README.zh-CN.md, server.js

== Statistics ==

  37 files changed, +5195 / -281

== Known gaps (out of scope for this commit) ==

  - Upstream root server/public/test/ stays at 96ff886. The plugin tree is
    independently maintained; root is for self-hosting. Future sync would
    require applying Round 5/6/7 deltas to root server/lib/db.js etc.
  - PR #23 (MiniMax-AI) blocker from hetaoBackend 9/1 (CSRF token disclosure
    in server/router.js:280 Access-Control-Allow-Origin: *) is NOT addressed
    here. Planned as Round 8 on a follow-up branch.

== Why one commit (not 12) ==

  modacker/MiniMax-Code-Plugins and Wzdhehe/Mcode-webui have no common
  ancestor (merge-base fails). Cherry-pick is impossible. Wholesale
  tree copy with attribution in commit body is the honest equivalent.

Co-authored-by: Wzdhehe <Wzdhehe@users.noreply.github.com>
Co-authored-by: modacker <2918882+modacker@users.noreply.github.com>
… fix

hetaoBackend reported on 2026-09-01 against PR #23 head 091dec5 that
a malicious page at https://evil.example could exfiltrate the operator's
bootstrap auth token from a webui instance running on the same machine
via a cross-origin fetch('/api/settings'). Three combined properties
created the leak:

  1. router.js:279  Access-Control-Allow-Origin: * (any cross-origin
                    page could read responses — no CORS preflight
                    needed for simple GET/POST with JSON body)
  2. auth.js:113    isRequestAuthorized() bypassed Gate 3 for
                    isLocalRequest — the request arrived over loopback
                    (127.0.0.1), so no token was required regardless
                    of where the request originated in the browser
  3. routes/settings.js + state-bus.js  the bootstrap token was
                    included in GET /api/settings (when
                    !tokenAcknowledged), the POST resetToken response,
                    and the auth.token_rotated SSE event payload

This commit closes the leak by:

  * Removing Access-Control-Allow-Origin: * (router.js: setCorsHeaders
    helper). New behavior: same-origin OR in MCODE_WEBUI_ALLOWED_ORIGINS
    env allowlist → echo Origin + Vary; otherwise omit CORS headers
    (browsers block the cross-origin read). OPTIONS preflight still
    short-circuits before any gate (regression guard).
  * Making isRequestAuthorized require a valid token for cross-origin
    requests even when they arrive over loopback (auth.js: new
    isCrossOriginRequest helper). Same-origin / no-Origin (server-to-
    server, curl, mcode acp subprocess) keeps the isLocalRequest
    fast path.
  * Removing currentToken from every HTTP response and SSE state
    payload (settings.js getSettingsSnapshot, routes/settings.js
    resetToken response, state-bus.js pushStateFor +
    ensureMcodeSessionsFetchedAndPush + pushOnlineCount, plus
    broadcastTokenRotated which now sends JSON {rotated:true, at:ms}
    instead of the raw new token).
  * SPA changes: state.js now clears HEADERS.Authorization +
    localStorage on auth.token_rotated (no auto-update of HEADERS)
    and dispatches a window CustomEvent 'webui:token_rotated'.
    render.js listens for it and shows an 8s toast telling the
    operator to read the new value from server stdout or
    ~/.mcode-webui/settings.json. The LAN-card 'token display'
    placeholder now points to the out-of-band delivery channels.
    i18n.js adds lan_card_token_saved_v2 (zh+en) and
    token_rotated_toast (zh+en).

UX trade-off: a small one-click convenience (auto-HEADERS-update on
rotation) is gone. The operator must re-open the URL with the new
?token=... value. See SECURITY-NOTES §10 for the full contract.

Test plan:
  * Existing csrf-token-disclosure.test.js (added in 091dec5 with 4
    blockers) — 3 of 4 RED pre-commit, all 4 GREEN post-commit. The
    4th (blocker#4: cross-origin DELETE /api/sessions/:id) was already
    GREEN pre-commit (404 fallback) and remains GREEN — the underlying
    Gate 3 tightening now blocks the real cross-origin DELETE too.
  * router-cors.test.js L279 "Allow-Origin remains wildcard" replaced
    with a NEGATIVE assertion (source MUST NOT contain '*') and a
    setCorsHeaders + MCODE_WEBUI_ALLOWED_ORIGINS presence test.
    L280 + L281 unchanged.
  * routes-settings.test.js resetToken:true test updated to assert
    the response does NOT include currentToken and that a 'hint'
    field points to stdout / settings.json.
  * state-bus.test.js new describe 'state-bus — currentToken removal
    (round 8)': per-cid push + __broadcast__ push +
    broadcastTokenRotated all verified to NOT carry the token value.
  * BASELINE-2026-09-04.md captures the pre-fix test counts and PoC
    output for the reviewer to diff against.

Drive-by: eslint.config.mjs was at the upstream/ root and imported
@eslint/js + globals, but the devDeps live in plugins/Wzdhehe/mcode-
webui/package.json — npm install in the subdir didn't satisfy the
upstream-root resolve. Moved the config into the mcode-webui subdir
so npm run lint works (0 errors, 9 pre-existing 'imported but never
used' warnings in test/ that are not new in this commit).

Post-commit status:
  * npm test  → 417 / 415 / 0 / 2 (no regressions, +3 new tests)
  * npm run lint → 0 errors, 9 warnings (none new in this commit)
  * poc-csrf.mjs → exit 0, 'PoC did not reproduce the blocker'
  * Same-origin SPA requests (curl with Origin: http://127.0.0.1:PORT)
    still 200 + ACAO echo + currentToken: '' (field kept for back-compat)
@Wzdhehe
Wzdhehe merged commit 072220c into main Sep 4, 2026
0 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants