Skip to content

feat(web): enforce localhost-only binding; expand loopback origin allow-list#1796

Merged
cliffhall merged 32 commits into
v2/mainfrom
v2/enforce-loopback-bind
Jul 27, 2026
Merged

feat(web): enforce localhost-only binding; expand loopback origin allow-list#1796
cliffhall merged 32 commits into
v2/mainfrom
v2/enforce-loopback-bind

Conversation

@cliffhall

@cliffhall cliffhall commented Jul 26, 2026

Copy link
Copy Markdown
Member

Closes #1795

What & why

The web server bound whatever HOST env you gave it, unvalidated. HOST=0.0.0.0 (or ::, or empty) binds every network interface, exposing a backend that can spawn local processes and reach MCP servers to the whole network — the surface DNS-rebinding attacks target. The "never 0.0.0.0" rule existed only as a note in specification/v2_scope.md; nothing enforced it.

This PR enshrines it in code.

Host-binding guard (the escape hatch)

  • Shared bind-host guard: policy (clients/web/server/resolve-bind-host.ts) + detection (core/node/hostUrl.ts isAllInterfacesHost), used by both bind points (web-server-config.ts prod backend and vite.config.ts dev server) and, via strictPort, the launcher's in-process dev server too.
  • Refuses an all-interfaces host by default, throwing an actionable error.
  • Allows it only when DANGEROUSLY_BIND_ALL_INTERFACES=true (explicit true/1, so =false reads as off) — same DANGEROUSLY_* pattern as DANGEROUSLY_OMIT_AUTH.
  • The Dockerfile sets the flag (a container must bind 0.0.0.0 to be reachable via -p); that's the one sanctioned exception.

Loopback origin fix (the bug that started this)

Connecting to a plain stdio server (everything-server) failed with Remote connect failed (403): Invalid origin … DNS rebinding. Root cause: the dev server binds IPv6 loopback [::1], the browser lands on http://[::1]:6274, but the default allowedOrigins was the single literal http://localhost:6274, so the origin guard 403'd the connect. (The server list still rendered because same-origin GETs carry no Origin header — only the connect POST does.)

The default loopback origin list now expands to all three interchangeable forms — http://localhost:PORT, http://127.0.0.1:PORT, http://[::1]:PORT — keeping the DNS-rebinding guard effective (still loopback-only, that exact port) while not rejecting the IPv6 alias of the same machine.

Security holes closed (during review)

This PR went through an extended adversarial review. The load-bearing principle that emerged: the guard reasons about the address the socket binds, not the raw string — one shared, canonicalized value drives detection, listen(), the origin allow-list, the banner, the sandbox URL, and the CLI deep-link. Notable holes closed:

  • Wildcard-bind bypasses — every spelling the OS binds as 0.0.0.0/::. The naïve guard only caught the literal 0.0.0.0. Now refused (each verified against a real http.listen()):
    • Legacy inet_aton forms: 0, 0x0, 0.0, 0.0.0, and octal/hex (00000000000, 0x00000000, 0x0.0.0.0, 000.000.000.000).
    • IPv6 wildcard spellings: ::0, 0::0, ::0.0.0.0, fully-expanded zeros; the IPv4-mapped wildcard ::ffff:0.0.0.0 / ::ffff:0:0; the zone-scoped ::%eth0; bracketed [::]; empty/whitespace.
    • IDNA / fullwidth-Unicode spellingsHOST="0" (U+FF10), "00.0.0", "0.0.0.0" (fullwidth dots). Node's resolver maps these to 0.0.0.0 before binding, so a raw-string check missed them and the server bound every interface silently. The guard now canonicalizes through new URL() (which applies the same IDNA mapping) first. Verified with dns.lookup + http.listen() on both Linux and macOS.
  • ALLOWED_ORIGINS fail-open. A blank / whitespace / ,-only value parsed to [], which the origin middleware treats as allow-all — silently disabling the DNS-rebinding guard. Now falls back to the default (fail closed); there is no env knob to turn origin validation off.
  • Opaque / wildcard ALLOWED_ORIGINS entries. A scheme-less entry (localhost:6274) parses to the literal origin "null" — which is a real Origin: null header browsers send from opaque origins (sandboxed iframes, data: docs), so allow-listing it eroded the guard; now dropped with a warning. A *.example.com entry worked for the sandbox CSP but silently 403'd every connect (the most confusing split); now dropped.
  • Zone-index guard crash. HOST=fe80::1%eth0 (a valid link-local) threw an opaque TypeError out of the guard; net.isIPv6 accepts a zone id but new URL() rejects it. The zone is now stripped for detection and kept for listen().
  • CSP frame-ancestors grounded in a real browser, not the grammar. A bracketed IPv6 literal is not a valid CSP host-source — verified in headless Chromium that a lone http://[::1]:* collapses the directive to 'none' and blocks the sandbox iframe. It's excluded from the derived frame-ancestors, and the MCP Apps IPv6-literal limitation is documented.
  • The CLI/TUI OAuth callback listener bound the wildcard unguarded. --callback-url http://0.0.0.0:6276/oauth/callback (or MCP_OAUTH_CALLBACK_URL) put the listener that receives the OAuth authorization code on every interface — a credential-interception risk. It now goes through the same isAllInterfacesHost guard and refuses a non-loopback callback host.

Alongside these, the review tightened correctness: banner ⊆ allowedOrigins holds by construction (verified as a property), CLIENT_PORT/MCP_SANDBOX_PORT reject non-integer/out-of-range values, strictPort holds on all three bind points, and origin canonicalization matches what browsers actually send (trailing slash, uppercase host, default :80, IPv4-mapped, IDNA).

Tests

  • hostUrl.test.ts (host normalization + the isAllInterfacesHost wildcard sweep — decimal/octal/hex/IPv6/mapped/zone/IDNA spellings, positive and negative), resolve-bind-host.test.ts (the guard end-to-end: refuse-without-opt-in incl. fullwidth, allow-with-opt-in), web-server-config.test.ts (origin allow-list, banner, ALLOWED_ORIGINS canonicalization + fail-closed, CLIENT_PORT validation), sandbox-controller.test.ts (derived CSP + IPv6-drop, port validation), and CLI handoff tests (deep-link canonicalization + wildcard→localhost).
  • npm run coverage (web and CLI) passes — every touched file clears the ≥90 per-file gate; core/node/hostUrl.ts is at 100%.

Docs

Root README (core/node/ entry, Docker section), clients/web/README.md ("Host binding & the origin allow-list" + "Hosting on a network" + MCP Apps caveats), AGENTS.md (structure entries for resolve-bind-host.ts and core/node/hostUrl.ts), docs/mcp-app-review.md (loopback/Apps guidance), and the Dockerfile comment.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

…ow-list (#1795)

Refuse binding an all-interfaces host (0.0.0.0 / :: / empty) by default —
it exposes the process-spawning backend to the local network, the surface
DNS-rebinding attacks target — unless DANGEROUSLY_BIND_ALL_INTERFACES=true
is set. A new shared guard (server/resolve-bind-host.ts) is used by both
bind points (web-server-config.ts and vite.config.ts). The Docker image,
which must bind 0.0.0.0 to be reachable via -p, sets the opt-in flag.

Also fixes the original bug: connecting to a stdio server 403'd with
"Invalid origin ... DNS rebinding" because the dev server binds IPv6
loopback [::1] while the default allowedOrigins was the single literal
"http://localhost:PORT". The default loopback origin list now expands to
all three interchangeable forms (localhost / 127.0.0.1 / [::1]).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
@cliffhall cliffhall added the v2 Issues and PRs for v2 label Jul 26, 2026
…ED_ORIGINS guidance (#1795)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
@cliffhall

Copy link
Copy Markdown
Member Author

@claude review

@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Claude finished @cliffhall's task in 3m 51s —— View job


Code review — PR #1796

  • Gather context (diff + touched files + surrounding code)
  • Review host-binding guard
  • Review loopback origin allow-list change
  • Review tests / docs
  • Post review

Solid, well-scoped change: one shared helper for both bind points, an explicit true/1 opt-in (so =false reads as off, unlike DANGEROUSLY_OMIT_AUTH's !!), the Dockerfile opting in at the one sanctioned place, and the origin expansion stays loopback-scoped to the exact port so the DNS-rebinding guard keeps its teeth. Doc comments explain why rather than what. Below are findings, most significant first.

1. The [::1] fix is incomplete — the sandbox proxy CSP still excludes it (medium)

clients/web/server/sandbox-controller.ts:60:

const SANDBOX_PROXY_CSP =
  "frame-ancestors http://127.0.0.1:* http://localhost:*";

This is the same root cause the PR fixes, one layer over: when the browser legitimately lands on http://[::1]:6274, that embedder origin matches neither frame-ancestors source, so the browser blocks the sandbox iframe and the MCP Apps tab breaks — for exactly the user whose connect 403 this PR just fixed. Adding http://[::1]:* keeps the list loopback-only and closes the gap. (Same reasoning as defaultAllowedOrigins: localhost may resolve to either family.)

Fix this →

2. Guard bypass: HOST=0 binds the wildcard and isn't caught (medium/low)

ALL_INTERFACES_HOSTS is a literal set of 0.0.0.0 / :: / "". I checked what Node actually binds for near-miss spellings:

HOST Node listen() result
0 bound 0.0.0.0 (wildcard) — not caught
0x0.0.0.0 bound 0.0.0.0 — not caught
::ffff:0.0.0.0 bound ::ffff:0.0.0.0 (v4-mapped wildcard) — not caught
[::] ENOTFOUND (fails loudly — fine)
0.0.0.0. ENOTFOUND (fine)

HOST=0 is the one that matters in practice — it's a common shorthand in Node/PaaS tooling, and it silently defeats the guard the PR exists to add. Since the helper's job is fail-fast, consider normalizing instead of literal-matching: e.g. keep the set, then additionally treat the value as all-interfaces when net.isIP()/BigInt-parses to the unspecified address (or, minimally, add "0" and "::ffff:0.0.0.0"). Cheap hardening and easy to unit-test alongside the existing it.each.

Fix this →

3. The actionable message is printed as a raw stack trace (low)

buildWebServerConfig() is called at clients/web/server/run-web.ts:235, outside the try/catch at 253–268. So the throw propagates to the launcher's top-level handler (clients/launcher/src/index.ts:60):

Error running MCP Inspector: Error: Refusing to bind HOST="0.0.0.0": …
    at resolveBindHostname (…)
    …

The carefully-worded guidance ends up buried under a stack dump. Twenty lines up, run-web.ts:200-206 already has the house pattern (console.error("Error:", message); process.exit(1)) — wrapping the buildWebServerConfig call the same way makes the error read the way the PR body describes it. Same consideration for the dev path, where the throw surfaces as a Vite config-load error.

4. resolveBindHostname returns the untrimmed value (low)

isAllInterfacesHost trims for detection, but line 56 returns the raw host. So HOST=" 127.0.0.1 " passes the guard and then fails at listen() with ENOTFOUND — and HOST=" 0.0.0.0 " + opt-in returns a padded string that can't bind either. Returning the trimmed value makes the detection and the return value agree.

5. defaultAllowedOrigins non-loopback branch: unbracketed IPv6, uncased host (low)

return [`http://${hostname}:${port}`];
  • HOST=fe80::1http://fe80::1:6274, not a valid origin — never matches, silent 403.
  • HOST=Example.comhttp://Example.com:PORT; browsers send the host lowercased, so this never matches either (the loopback branch lowercases, this one doesn't).

Same bracket omission exists in printServerBanner (web-server-config.ts:167) and sandboxUrl (sandbox-controller.ts:135) — pre-existing, but ::1 is now an explicitly documented input via LOOPBACK_HOSTNAMES, so HOST=::1 yields a malformed banner URL and a sandboxUrl that new URL() rejects on the restart path. A small formatHostForUrl(host) helper used by all three would settle it.

6. The guard runs for vite build and for the vitest projects too (low)

vite.config.ts:161 calls resolveBindHostname() inside the config factory's server block, which is evaluated for every command — vite build, vitest, not just serve. With HOST=0.0.0.0 exported in a shell or CI env, npm run build / npm run test now fail at config load with a message about binding, which is confusing when nothing is being bound. The factory already receives command; gating on command === "serve" (or resolving lazily) keeps the guard where it applies. Not a problem for the Dockerfile as written (ENV lands in the runtime stage and sets the opt-in, so the on-demand ensureWebBuild build is fine).

7. ALLOWED_ORIGINS entries aren't trimmed (nit)

process.env.ALLOWED_ORIGINS?.split(",").filter(Boolean)ALLOWED_ORIGINS="http://a:1, http://b:2" yields " http://b:2", which can never match an Origin header, producing a 403 with no clue why. The new README section actively instructs users to write comma-separated lists, so this gets more reachable. .map((s) => s.trim()).filter(Boolean).

8. Test nit

resolve-bind-host.test.ts:9it.each(["0.0.0.0", "::", "", " 0.0.0.0 ", "::"]) repeats "::"; presumably " :: " was intended (the whitespace variant is only covered for the IPv4 form).

9. Docs: "a resolvable hostname works directly" may not hold in --dev (verify)

The new Hosting on a network section says HOST=<hostname> works with no extra config. That's true for the prod backend, but the dev server also has Vite's own server.allowedHosts Host-header check, whose default accepts loopback + IP literals and blocks other hostnames unless listed — and vite.config.ts doesn't set it. Worth confirming against the pinned Vite version; if it applies, either add the caveat ("in --dev, set server.allowedHosts") or scope that bullet to IP addresses.


Not blocking, and correct where it counts: the origin expansion doesn't weaken the rebinding guard (an attacker-controlled Origin, rebound or not, still never matches a loopback origin), and the true/1-only parsing is the right call. Items 1 and 2 are the ones I'd fix before merge — 1 because it leaves the reported symptom half-fixed, 2 because a bypass makes the guard advisory.

Note: I reviewed by reading the code; clients/web/node_modules isn't installed in this checkout, so I did not run the test suite locally (the HOST=0 / [::] / ::ffff:0.0.0.0 bind behavior in item 2 was verified by running Node directly). CI on this PR was still in progress at review time.
· branch v2/enforce-loopback-bind

…SP/URLs (#1795)

Address the @claude review of #1796:

- sandbox CSP: add http://[::1]:* to frame-ancestors so the MCP Apps iframe
  isn't blocked when the browser is on the IPv6 loopback (the same case the
  origin fix targets).
- resolve-bind-host: catch the wildcard bypasses HOST=0 / 0x0.0.0.0 /
  ::ffff:0.0.0.0 / dotted-all-zero (inet_aton spellings Node still binds as
  0.0.0.0); trim the returned host so detection and the bind value agree.
- add shared formatHostForUrl() (brackets IPv6) and use it in the origin
  allow-list, the startup banner, and the sandbox URL; lowercase a
  non-loopback origin host so it matches the browser's Origin.
- run-web: wrap buildWebServerConfig so the guard's message prints as a clean
  "Error: …" line instead of a raw stack trace.
- vite.config: only enforce the guard for `command === "serve"`, so an ambient
  HOST=0.0.0.0 doesn't fail `vite build` / vitest at config load.
- ALLOWED_ORIGINS: trim entries so a spaced comma list still matches.
- docs: note that `--dev` additionally enforces Vite's server.allowedHosts, so
  a named host needs configuring there (or use the prod server / a specific IP).
- tests: cover all of the above.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
@cliffhall

Copy link
Copy Markdown
Member Author

Thanks — thorough review. All nine addressed in 5995c620. Rundown:

1. Sandbox CSP excluded [::1] (medium) — Fixed. Added http://[::1]:* to frame-ancestors in sandbox-controller.ts, with a comment tying it to the same localhost-resolves-to-either-family reasoning as defaultAllowedOrigins. Good catch — this was the reported symptom half-fixed.

2. HOST=0 (and friends) bypassed the guard (medium/low) — Fixed. isAllInterfacesHost now normalizes and also flags all-zero IPv4 in the legacy inet_aton spellings: bare 0, 0x0, 0x0.0.0.0, 000.000.000.000, plus the explicit set now includes ::ffff:0.0.0.0 / ::ffff:0:0 / 0:0:0:0:0:0:0:0. New it.each cases cover each. ([::]/0.0.0.0. still fail loudly at listen() as you noted, which is fine.)

3. Raw stack trace instead of the actionable message (low) — Fixed. Wrapped the buildWebServerConfig call in run-web.ts with the house console.error("Error: …"); process.exit(1) pattern, so the guard's guidance reads cleanly.

4. Returned untrimmed value (low) — Fixed. resolveBindHostname now trims once up front and returns the trimmed value, so detection and the listen() value agree. Test added (HOST=" 127.0.0.1 "127.0.0.1).

5. Unbracketed IPv6 / uncased host in the non-loopback origin, banner, sandboxUrl (low) — Fixed with the shared formatHostForUrl(host) helper (brackets IPv6, passes everything else through), now used by defaultAllowedOrigins, printServerBanner, and the sandbox URL. The non-loopback origin branch also lowercases the host to match the browser's Origin. Tests: fe80::1http://[fe80::1]:PORT, Example.COMhttp://example.com:80, and a banner IPv6 case.

6. Guard ran for vite build / vitest (low) — Fixed. vite.config.ts now only calls resolveBindHostname() when command === "serve"; build/test fall back to the raw value so an ambient HOST=0.0.0.0 can't fail them at config load.

7. ALLOWED_ORIGINS entries not trimmed (nit) — Fixed. Now .split(",").map((o) => o.trim()).filter(Boolean); test updated to a spaced list.

8. Duplicate "::" in the it.each (nit) — Fixed to " :: " (the whitespace-IPv6 variant that was the apparent intent).

9. --dev hostname needs Vite's server.allowedHosts (verify) — Confirmed against the pinned Vite 8.0.0: its dev server does enforce a Host-header allow-list that rejects named hosts by default. Reworded the README bullet — a specific IP works in both modes; a bare named HOST works for the prod server but under --dev needs server.allowedHosts (recommend the prod server or a specific IP for network hosting).

npm run format / web validate / the test:coverage gate (per-file ≥90) and a full web build all pass locally.

@cliffhall

Copy link
Copy Markdown
Member Author

@claude review

@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Claude finished @cliffhall's task in 3m 38s —— View job


Code review — PR #1796 (re-review after 5995c620)

  • Gather context (full diff of all 3 commits)
  • Verify the nine prior findings actually landed
  • Fresh pass over the new code (guard normalization, formatHostForUrl, CSP, vite gating)
  • Review tests / docs
  • Post review

Eight of the nine landed cleanly, and several landed better than suggested — formatHostForUrl as a single shared formatter used by the origin list, banner, and sandbox URL is the right shape, and the inet_aton normalization in isAllZeroIpv4 correctly catches the whole legacy family (I traced 0, 0x0, 0.0, 000.000.000.000, 0x0.0.0.0 → all flagged; 1.0.0.0 / 0.0.0.1 / ::1 correctly not). Bracket-stripping in isAllInterfacesHost also means HOST=[::] now fails with the guard's message instead of a bare ENOTFOUND, which is a bonus.

# Prior finding Status
1 Sandbox CSP excluded [::1] ✅ fixed — but untested (see A2)
2 HOST=0 bypass ✅ fixed, thoroughly
3 Raw stack trace ✅ fixed (prod path)
4 Untrimmed return ✅ fixed (see A3 for the bracket twin)
5 Unbracketed IPv6 / uncased host ✅ fixed via formatHostForUrl (two call sites remain — A6)
6 Guard runs for vite build / vitest ⚠️ not actually fixed — see A1
7 ALLOWED_ORIGINS trimming ✅ fixed
8 Duplicate "::" in it.each ✅ fixed
9 --dev + allowedHosts ✅ verified & documented correctly

New findings below, most significant first.

A1. The item-6 fix is cosmetic — vite build / vitest still throw at config load (medium-low)

clients/web/vite.config.ts:164-168 now gates server.host on command === "serve", but twenty lines up the plugins array is built unconditionally:

// vite.config.ts:117
honoMiddlewarePlugin(buildWebServerConfigFromEnv()),

buildWebServerConfigFromEnv()buildWebServerConfig()resolveBindHostname() (web-server-config.ts:250). That argument is evaluated eagerly for every command, so with an ambient HOST=0.0.0.0 exported in the shell/CI, vite build and every vitest project still fail at config load with the bind message — exactly the scenario the new comment says "must not fail them." The server.host gate only removes the second throw site, not the first (and the first is hit earlier, at array construction).

The plugin already declares apply: "serve" (vite-hono-plugin.ts:48), so skipping it outright for non-serve commands is behavior-neutral and avoids the wasted config build (vite preview also runs with command === "serve", so dev and preview are unaffected):

plugins: [
  react(),
  ...(command === "serve" ? [honoMiddlewarePlugin(buildWebServerConfigFromEnv())] : []),
  browserExternalizedBuiltinGate(),
],

Fix this →

A2. The CSP fix (item 1) has no test — the existing assertion passes either way (low-medium)

clients/web/src/test/integration/server/sandbox-controller.test.ts:88:

expect(csp).toContain("frame-ancestors http://127.0.0.1:*");

That's a prefix match on the old value, so it's green with or without http://[::1]:*. The bug this PR set out to fix (IPv6 embedder → CSP-blocked sandbox iframe) is therefore unguarded: delete the new source and CI stays green. Assert the full directive, or add expect(csp).toContain("http://[::1]:*").

Fix this →

A3. resolveBindHostname returns a bracketed host that can't be bound (low)

Same class as the trim fix, one spelling over. isAllInterfacesHost strips brackets for detection (resolve-bind-host.ts:55-58), but line 108 returns the host as-given:

  • HOST=[::1] → passes the guard → serve({ hostname: "[::1]" }) / server.listen(…, "[::1]")ENOTFOUND (Node wants the bare ::1).
  • HOST=[::] + opt-in → returns "[::]" → same failure.

Notably LOOPBACK_HOSTNAMES (web-server-config.ts:206) explicitly lists "[::1]", so the code treats a bracketed HOST as a legitimate input for origin purposes while the bind path can't consume it. Stripping the brackets in resolveBindHostname (before returning) makes detection, listen(), and the origin list all agree — formatHostForUrl re-adds them wherever a URL is built.

A4. Dead branch in isAllZeroIpv4 (nit)

resolve-bind-host.ts:47if (value === "") return false; is unreachable: the only caller short-circuits on "" via ALL_INTERFACES_LITERALS.has("") before the ||, and any input that normalizes to "" (" ", "[]") hits the same literal. Even if reached it's redundant — "".split(".")[""]NaNfalse. It's an uncoverable branch under the ≥90 gate; drop it (or annotate it) rather than leave it looking load-bearing.

A5. let webConfig; is an implicit-any evolving let (nit)

run-web.ts:235. It typechecks (the catch ends in process.exit(1), which is never, so flow analysis narrows the reads), but AGENTS.md's no-any rule reads better with the declared type spelled out: let webConfig: WebServerConfig;. Also, the house pattern two blocks up is console.error("Error:", message) (run-web.ts:203) whereas the new code uses console.error(\Error: ${message}`)` — identical output modulo the separator, but worth matching.

A6. formatHostForUrl sweep is one-and-a-half sites short (nit / follow-up)

Two remaining places build http://${host}:${port} unbracketed:

  • clients/web/server/server.ts:134 — the EADDRINUSE message. Cosmetic (message only), trivial to include.
  • clients/cli/src/cli.ts:727buildHandoff()'s deep-link URL, from the same process.env.HOST. HOST=::1 yields a malformed handoff link that the browser can't open. The CLI can't import clients/web/server/, so fixing it means the helper belongs in core/node/. Reasonable as a follow-up issue rather than scope creep here.

Verdict: A1 is the one I'd fix before merge — not because the failure is severe, but because the code now carries a comment asserting a property it doesn't have, which is the kind of thing that survives for years. A2 is cheap and makes the headline fix regression-proof. A3–A6 are polish. The security substance of the PR is sound: the guard's normalization closes the bypass class properly, and the loopback origin expansion still can't be satisfied by an attacker-controlled Origin.

Note: node_modules isn't installed in this checkout (root and clients/web both absent), so I did not run the test suite, validate, or a real vite build — A1 is established by reading the evaluation order (eager argument at vite.config.ts:117buildWebServerConfigFromEnvresolveBindHostname), not by executing it.
· branch v2/enforce-loopback-bind

…, test CSP (#1795)

Address the re-review of #1796:

- A1: gate honoMiddlewarePlugin on isDevServer, not just its `apply`/the
  server.host ternary. Its config arg buildWebServerConfigFromEnv() (→
  resolveBindHostname) was evaluated eagerly, so an ambient HOST=0.0.0.0 still
  threw at config load for `vite build` and vitest. Comment corrected.
- A2: assert the full sandbox frame-ancestors directive (incl. http://[::1]:*)
  so the IPv6-loopback CSP fix is regression-guarded, not prefix-matched.
- A3: resolveBindHostname de-brackets its return (HOST=[::1] → ::1) so
  detection, listen(), and the origin list agree; formatHostForUrl re-adds.
- A4: drop the dead `value === ""` branch in isAllZeroIpv4.
- A5: `let webConfig: WebServerConfig` + match console.error("Error:", msg).
- A6: move formatHostForUrl to core/node/hostUrl.ts (shared) and apply it at
  the two remaining unbracketed sites — server.ts EADDRINUSE message and the
  CLI deep-link (cli.ts buildHandoff, which reads HOST). resolve-bind-host
  re-exports it; new core test covers it (100%).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
@cliffhall

Copy link
Copy Markdown
Member Author

Round-2 addressed in 944aeedc — all six, including the CLI site.

A1 — item-6 fix was cosmetic (medium-low) — Correct, good catch. The server.host ternary only removed the second throw site; the plugin's config argument at vite.config.ts:117 was evaluated eagerly. Now the whole honoMiddlewarePlugin(buildWebServerConfigFromEnv()) is spread conditionally on isDevServer — I used isDevServer rather than the suggested command === "serve" precisely because vitest also runs with command === "serve", so command === "serve" alone would still throw under vitest; isDevServer is command === "serve" && !VITEST. Also aligned the server.host gate to isDevServer for the same reason, and rewrote the stale comment that claimed the plugin stays inert under vitest.

A2 — CSP fix untested (low-medium) — Fixed. The assertion now matches the full frame-ancestors http://127.0.0.1:* http://localhost:* http://[::1]:* directive, so deleting the new source fails CI.

A3 — bracketed return can't bind (low) — Fixed. Added stripIpv6Brackets; resolveBindHostname now returns the bare form (HOST=[::1]::1), so detection, listen(), and the origin list agree, and formatHostForUrl re-adds brackets at URL-build time. Test added.

A4 — dead branch (nit) — Removed the unreachable value === "" guard in isAllZeroIpv4 (behavior-identical — "".split(".")[""]NaNfalse).

A5 — evolving let + separator (nit) — Fixed. let webConfig: WebServerConfig; and switched to console.error("Error:", message) to match the house pattern.

A6 — formatter sweep (nit/follow-up) — Handled here rather than deferred (this PR is meant to close the class). Moved formatHostForUrl to core/node/hostUrl.ts and applied it at both remaining sites: server.ts's EADDRINUSE message and — the one that actually matters — cli.ts's buildHandoff deep-link, which reads process.env.HOST, so HOST=::1 no longer yields a malformed handoff URL. resolve-bind-host.ts re-exports it for the web bind points; a new core test covers it (100%).

Green locally: web validate + test:coverage (per-file ≥90; hostUrl.ts 100%), validate:core, CLI lint + typecheck + build (formatHostForUrl bundles), and a full web build.

@cliffhall

Copy link
Copy Markdown
Member Author

@claude review

@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Claude finished @cliffhall's task in 3m 58s —— View job


Code review — PR #1796 (round 3, after 944aeedc)

  • Gather context (full diff of all 4 commits)
  • Verify the six round-2 findings landed
  • Fresh pass over the new code (core/node/hostUrl.ts, isDevServer gating, bracket stripping)
  • Review tests / docs
  • Post review

All six round-2 items landed, and A1 landed better than suggested — isDevServer (command === "serve" && !process.env.VITEST) is the correct predicate, since vitest also resolves config with command === "serve", so the command === "serve" I proposed would have left the vitest projects throwing. Good catch on the catch.

# Round-2 finding Status
A1 Plugin config arg evaluated eagerly ✅ fixed via isDevServer, comment rewritten
A2 CSP fix untested ✅ fixed — full-directive assertion, now regression-proof
A3 Bracketed return can't bind ✅ fixed (stripIpv6Brackets; [::] also now hits the guard message)
A4 Dead value === "" branch ✅ removed
A5 Evolving let + separator let webConfig: WebServerConfig + console.error("Error:", message)
A6 Formatter sweep ✅ moved to core/node/hostUrl.ts, applied at server.ts + cli.ts (see C3 for two more)

New findings, most significant first. Nothing here is a blocker; C1 is the one I'd still fix.

C1. The IPv6 half of the wildcard-bypass class is still open — HOST=::0 binds all interfaces (low-medium)

Round 2 normalized the IPv4 spellings properly (isAllZeroIpv4 + inet_aton parsing), but the IPv6 side is still a literal set (resolve-bind-host.ts:24-31), so the same near-miss class survives one family over. Verified by actually binding:

::0                                     -> {"address":"::","family":"IPv6"}   // NOT flagged
0::0                                    -> {"address":"::","family":"IPv6"}   // NOT flagged
::0.0.0.0     (WHATWG-normalizes to ::) -> wildcard                           // NOT flagged
0:0::0                                  -> wildcard                           // NOT flagged
0000:0000:0000:0000:0000:0000:0000:0000 -> wildcard                           // NOT flagged

isAllZeroIpv4("::0")parseAddressPart("::0")NaNfalse, and "::0" isn't in the literal set — so HOST=::0 sails past a guard whose entire purpose is to refuse exactly that bind. It's the mirror image of the HOST=0 hole, and ::0 is a more plausible hand-typed value than 0:0:0:0:0:0:0:0 (which is in the set).

Cleanest fix is to canonicalize IPv6 through the WHATWG serializer before the set lookup — it compresses zero runs, so every spelling above collapses to ::, and ::ffff:0.0.0.0::ffff:0:0, which the existing set already contains (both verified):

function canonicalizeIpv6(value: string): string {
  if (!isIPv6(value)) return value; // node:net
  return new URL(`http://[${value}]`).hostname.slice(1, -1);
}
// isAllInterfacesHost: ALL_INTERFACES_LITERALS.has(canonicalizeIpv6(normalized)) || isAllZeroIpv4(normalized)

That also lets the literal set shrink to "" | "0.0.0.0" | "::" | "::ffff:0:0". Non-wildcard v6 hosts are unaffected (::1::1, ::ffff:0::ffff:0, both verified as not flagged).

Fix this →

C2. defaultAllowedOrigins can never match on port 80/443 — and a new test enshrines the broken form (low)

web-server-config.ts:233 always appends :${port}, but browsers omit the default port from Origin. The check is an exact string match (core/mcp/remote/node/server.ts:202,225allowedOrigins.includes(origin)), so:

  • CLIENT_PORT=80 HOST=192.168.1.50 → allow-list http://192.168.1.50:80, browser sends Origin: http://192.168.1.50403, "Invalid origin … DNS rebinding" — the same confusing failure mode this PR set out to eliminate, in the deployment shape the new README section now actively documents.
  • The new test at web-server-config.test.ts asserts exactly this: defaultAllowedOrigins("Example.COM", 80)["http://example.com:80"], freezing the value that can't match.

Emitting both forms when the port is the scheme default (or just http://host for 80) fixes it and stays exact-match. Worth folding in since the loopback branch is already a multi-form list.

Fix this →

C3. Two pre-existing copies of formatHostForUrl in core/auth/node/ weren't swapped (low)

A6's premise was "this PR is meant to close the class," and the helper now lives in core/node/hostUrl.ts where core/auth can import it — but the two inline duplicates remain, byte-for-byte the same logic:

// core/auth/node/oauth-callback-server.ts:223-225
const needsBrackets = host.includes(":") && !host.startsWith("[");
const formattedHost = needsBrackets ? `[${host}]` : host;

// core/auth/node/runner-oauth-callback.ts:91-94  (same, on config.hostname)

Swapping both for formatHostForUrl is a two-line change that makes the new module the single definition rather than a third copy.

C4. Sandbox frame-ancestors is loopback-only, so MCP Apps is CSP-blocked in the case this PR sanctions (low, pre-existing)

sandbox-controller.ts:65 is now frame-ancestors http://127.0.0.1:* http://localhost:* http://[::1]:*. Correct for the [::1] bug, but under the newly-documented network-hosting path (DANGEROUSLY_BIND_ALL_INTERFACES=true, or the Docker image, browsed from another machine at http://192.168.1.50:6274) the embedder origin matches none of the three and the browser blocks the sandbox iframe — MCP Apps silently doesn't render. createSandboxController only receives { port, host }, so the principled fix (derive the directive from config.allowedOrigins) is a small signature change; the cheap alternative is a caveat in the new Hosting on a network section noting the MCP Apps tab is loopback-only today. Either way, better acknowledged than discovered.

C5. Nits

  • isAllZeroIpv4's parts.length > 4 early return is never exercised (resolve-bind-host.ts:52). Ironic follow-on to removing the A4 dead branch — no test input has 5 parts. "0.0.0.0.0" in the negative it.each covers the branch and documents that inet_aton rejects it.
  • Import path for formatHostForUrl is inconsistent, and the re-export comment is inaccurate. server.ts:18 imports from core/node/hostUrl.ts; web-server-config.ts:19 and sandbox-controller.ts:10 go through the re-export at resolve-bind-host.ts:12 — which drags the bind-guard module (and its error copy) into the sandbox controller purely for a pass-through. The comment there says the re-export exists "so the two web bind points can keep importing the URL formatter from this module," but neither importer is a bind point (vite.config.ts imports only resolveBindHostname). Pick one direction — dropping the re-export and importing core/node/hostUrl.ts at all three sites is the simpler shape.
  • Zone-index IPv6. formatHostForUrl("fe80::1%eth0")[fe80::1%eth0], which new URL() rejects (a zone id must be %25-encoded). Genuinely exotic for a bind host; noting it only so it isn't a surprise if it ever shows up.

Verdict: the security substance holds up — the guard's IPv4 normalization is thorough, the trim/de-bracket round trip makes detection and listen() agree, the origin expansion still can't be satisfied by an attacker-controlled Origin, and the CSP assertion is now a real regression guard. C1 is worth closing because a guard with a known one-character bypass reads as complete when it isn't; C2 is the kind of 403 that costs someone an afternoon.

Note: node_modules isn't installed in this checkout, so I did not run the suite, validate, smoke, or test:storybook. The bind behavior in C1 and the WHATWG normalization it suggests were verified by running Node directly. One thing worth confirming locally: the reported local run covered validate + test:coverage + build, but not npm run ci — and the A1 fix changes plugin resolution for every vitest project including the Storybook browser one (VITEST set ⇒ the Hono middleware no longer attaches, where it previously did via apply: "serve"). Stories are presentational so I'd expect no impact, but npm run ci (which includes test:storybook) is the mandated pre-push gate for exactly this reason.
· branch v2/enforce-loopback-bind

cliffhall and others added 2 commits July 26, 2026 16:57
…in, dedupe formatter (#1795)

Address the round-3 review of #1796:

- C1: canonicalize IPv6 via the WHATWG serializer before the wildcard-set
  lookup, so HOST=::0 / 0::0 / ::0.0.0.0 / 0:0::0 / fully-expanded zeros are all
  refused (the IPv6 mirror of the HOST=0 hole). Literal set shrinks to
  "" | 0.0.0.0 | :: | ::ffff:0:0.
- C2: defaultAllowedOrigins omits the port when it's the http default (80) —
  browsers drop :80 from Origin and the guard is an exact match, so :80 could
  never match.
- C3: swap the two pre-existing formatHostForUrl copies in core/auth/node
  (oauth-callback-server, runner-oauth-callback) for the shared core/node one.
- C4: derive the sandbox frame-ancestors from allowedOrigins (new
  sandboxFrameAncestors) so the MCP Apps iframe isn't CSP-blocked under
  network hosting; loopback-wildcard fallback when the list is empty.
- C5: add the parts.length>4 negative case (0.0.0.0.0); drop the
  resolve-bind-host re-export and import formatHostForUrl from core/node
  directly at all sites.
- tests for all of the above.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
…k-bind

# Conflicts:
#	clients/cli/src/cli.ts
@cliffhall

Copy link
Copy Markdown
Member Author

Round-3 addressed in d8dbf93a (plus a v2/main merge, 71b21c8e). All five, none deferred.

C1 — IPv6 wildcard bypass (low-medium) — Fixed, and thanks for catching the mirror-image hole. Added canonicalizeIpv6 (guards on net.isIPv6, then new URL('http://[${v}]').hostname.slice(1,-1)), applied before the set lookup. Verified with real Node runs that ::0, 0::0, ::0.0.0.0, 0:0::0, and the fully-expanded 0000:…:0000 all collapse to ::, and ::ffff:0.0.0.0::ffff:0:0 — all now flagged; ::1, ::ffff:0, fe80::1 stay distinct and are not flagged. The literal set shrank to "" | "0.0.0.0" | "::" | "::ffff:0:0" as you suggested. New it.each cases (positive + negative) cover them.

C2 — port-80 origin can never match (low) — Fixed. defaultAllowedOrigins now omits the port when it's the http default (80) via a small httpOrigin helper, applied to both the loopback and non-loopback branches. The frozen test is corrected (Example.COM, 6274 for the lowercasing case; a dedicated port-80 test asserts the port-less forms).

C3 — two pre-existing copies in core/auth/node/ (low) — Fixed. buildRedirectUrl (oauth-callback-server) and formatRunnerOAuthRedirectUrl (runner-oauth-callback) both call the shared core/node/hostUrl.ts now; the inline needsBrackets logic is gone. core/node/hostUrl.ts is the single definition.

C4 — MCP Apps CSP-blocked under network hosting (low) — Did the principled fix rather than the caveat. New sandboxFrameAncestors(allowedOrigins) derives the directive from the backend's allow-list (the embedder origins), threaded through SandboxControllerOptions.allowedOrigins from both callers (server.ts, vite-hono-plugin.ts). Falls back to the loopback family when the list is empty (the origin-check-disabled case). Unit tests for both the derived and fallback paths; the existing full-directive CSP assertion still guards the loopback default.

C5 nits — All done: added the parts.length > 4 negative case (0.0.0.0.0); dropped the resolve-bind-host re-export and import formatHostForUrl from core/node/hostUrl.ts at all three web sites (comment removed too). The zone-index %eth0 case I left as-is — genuinely exotic for a bind host, and noted for the record.

On your closing note about npm run ci: ran the pieces that matter for the A1 plugin-gating change — web test:coverage (per-file ≥90; hostUrl.ts 100%), test:storybook (108 files / 460 tests green, so the Storybook browser project is unaffected by the isDevServer gating), CLI suite (298), and validate:core. Also merged the latest v2/main (#1782, which reworked cli.ts) — the only conflict was the cli.ts import block, resolved by keeping the formatHostForUrl import and dropping the now-unused extractAppInfo/AppInfo imports #1782 relocated; CLI re-lints/typechecks/tests clean post-merge.

@cliffhall

Copy link
Copy Markdown
Member Author

@claude review

@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Claude finished @cliffhall's task in 4m 51s —— View job


Code review — PR #1796 (round 4, after d8dbf93a + merge 71b21c8e)

  • Gather context (full diff, 6 commits)
  • Verify the five round-3 findings landed
  • Fresh pass over the new code (canonicalizeIpv6, httpOrigin, sandboxFrameAncestors, formatter dedupe)
  • Review tests / docs
  • Post review

All five round-3 items landed, and C1/C4 landed the way I'd hoped. I re-verified the IPv6 canonicalization claim by running Node: ::0, 0::0, ::0.0.0.0, 0:0::0, 0000:…:0000 all serialize to ::; ::ffff:0.0.0.0::ffff:0:0; and ::1 / ::ffff:0 / fe80::1 stay distinct. The four-entry literal set plus isAllZeroIpv4 is now a genuinely closed cover of the wildcard family — with one spelling left, below.

# Round-3 finding Status
C1 IPv6 wildcard bypass ✅ fixed via canonicalizeIpv6; set shrank to 4 entries (see E1 for the zone-index remainder)
C2 Port-80 origin can never match ✅ fixed via httpOrigin, both branches; frozen test corrected
C3 Two duplicate formatters in core/auth/node/ ✅ both swapped; core/node/hostUrl.ts is the single definition
C4 MCP Apps CSP-blocked off loopback ✅ principled fix — sandboxFrameAncestors(allowedOrigins), threaded from both callers, with a loopback fallback + tests (see E3 for what remains)
C5 Nits (parts.length > 4 case, re-export) ✅ all done; the resolve-bind-host re-export is gone, all three sites import core/node/hostUrl.ts

Findings below, most significant first.

E1. A zone index makes the guard throw TypeError: Invalid URL — and HOST=::%eth0 is the last wildcard spelling (medium-low)

canonicalizeIpv6 (resolve-bind-host.ts:33-36) guards on net.isIPv6 and then hands the value to new URL(). Those two disagree about zone indices, which I verified directly:

net.isIPv6("fe80::1%eth0")        -> true      // Node accepts the zone id
net.isIPv6("::%eth0")             -> true
new URL("http://[fe80::1%eth0]")  -> TypeError: Invalid URL   // WHATWG wants %25
new URL("http://[::%eth0]")       -> TypeError: Invalid URL

So the if (!isIPv6(value)) return value early-out does not protect the new URL() call, and any zone-scoped HOST throws a bare TypeError: Invalid URL out of resolveBindHostname. Two consequences:

  1. A legitimate link-local bind is now broken. HOST=fe80::1%eth0 is the canonical use of a zone id. Before this PR it passed straight through to listen(); now it dies at config load with Error: Invalid URL (via the new run-web.ts handler) — no mention of hosts or binding, and the value is perfectly valid (listen(0, "fe80::1%eth0") gives EADDRNOTAVAIL here only because this box has no eth0).
  2. HOST=::%eth0 really is a wildcard bind — verified: server.listen(0, "::%eth0"){"address":"::","family":"IPv6"}. It currently fails closed, but with the wrong error, so the guard's whole purpose (an actionable message) is lost on the one input class that's both a real wildcard and crashes.

Stripping the zone before canonicalizing fixes both at once and needs no try/catch — it's strictly better for detection, because the zone is irrelevant to which address it is:

function canonicalizeIpv6(value: string): string {
  if (!isIPv6(value)) return value;
  const [address] = value.split("%"); // net.isIPv6 accepts a zone id; new URL() rejects it
  return new URL(`http://[${address}]`).hostname.slice(1, -1);
}

::%eth0:: → flagged (correct); fe80::1%eth0fe80::1 → not flagged, and resolveBindHostname still returns the value with its zone intact so listen() can consume it. That also retires the round-3 C5 zone-index note as a real bug rather than an exotic formatting quirk (formatHostForUrl("fe80::1%eth0")[fe80::1%eth0], which new URL() rejects — reachable via sandbox-controller.ts:100 on the restart path).

Fix this →

E2. User-controlled ALLOWED_ORIGINS now flows unvalidated into a response header (low)

C4's fix is right, but it turns ALLOWED_ORIGINS into CSP header content (sandbox-controller.ts:84res.writeHead). Two failure modes that didn't exist when the directive was a constant:

  • A newline kills the sandbox page. I confirmed res.writeHead(200, {"Content-Security-Policy": "frame-ancestors http://a:1\nX-Evil: 1"}) throws ERR_INVALID_CHAR, the handler rejects, and the socket is destroyed (UND_ERR_SOCKET client-side) — so the MCP Apps iframe fails to load with no diagnostic pointing at the env var. Reachable from a multi-line -e ALLOWED_ORIGINS=… or a shell heredoc.
  • A ; injects extra CSP directives into the proxy page (ALLOWED_ORIGINS="http://a:1; sandbox"), quietly changing the defense-in-depth header the long comment above it is careful about.

Both are self-inflicted, so this is hardening rather than a vulnerability — but the fix is one line in sandboxFrameAncestors: drop entries that aren't a bare scheme+host(+port) source (e.g. /^[a-z][a-z0-9+.-]*:\/\/[^\s;,'"]+$/i), so a malformed allow-list degrades to the loopback fallback instead of corrupting the header.

Fix this →

E3. C4 unblocks the CSP, but MCP Apps still can't work off loopback — the sandbox URL is built from the bind host (low)

The CSP was one of two blockers. The other: sandboxUrl is http://${formatHostForUrl(host)}:${actualPort}/sandbox where host is config.sandboxHost = the bind host (web-server-config.ts:296), and that string is handed to the browser via GET /api/config (server.ts:61, consumed at App.tsx:735). Under the two paths this PR newly documents:

  • Docker / HOST=0.0.0.0sandboxUrl = http://0.0.0.0:<dynamic>/sandbox, which is not a reachable address from the client machine — and the dynamic sandbox port isn't EXPOSEd (Dockerfile exposes only 6274, and MCP_SANDBOX_PORT is unset ⇒ port 0).
  • HOST=192.168.1.50 → the URL is right, but the sandbox port is still dynamic and separate, so it must be reachable independently.

Nothing here regresses (it was equally broken before), but the new Hosting on a network section now reads as though network hosting is fully supported. Either add one caveat sentence there (MCP Apps needs the sandbox port reachable and a browser-reachable sandbox host — set MCP_SANDBOX_PORT and expose it), or file it as a follow-up. Given C4 was fixed principled-not-caveated, a follow-up issue is the consistent choice.

E4. Nits

  • Banner vs. origin list disagree on port 80. httpOrigin drops :80 but printServerBanner (web-server-config.ts:168) always prints it, so CLIENT_PORT=80 advertises http://localhost:80 while the allow-list holds http://localhost. Harmless (browsers normalize the request), just an inconsistency now that one side is port-aware.
  • resolve-bind-host.ts:57parts.length > 4 is guarded, but parts.length < 4 isn't. Deliberate and correct (inet_aton binds 0.0 as the wildcard, so flagging it is right), but the doc comment lists only 4-part and bare-integer spellings; worth naming 0.0 / 0.0.0 so a future reader doesn't "tighten" it to === 4.
  • Web README wording. "refuses an all-interfaces host (0.0.0.0, ::, or empty)" now undersells the guard — it also refuses every legacy/canonical spelling. A trailing "…or any spelling of them (0, 0x0, ::0, ::ffff:0.0.0.0, …)" makes the coverage discoverable to someone deciding whether to trust it.

Verdict: the security substance is in good shape. The wildcard cover is now closed on both families, hostUrl.ts is a single definition consumed by all five call sites including the CLI deep-link, the origin expansion still can't be satisfied by an attacker-controlled Origin, and the CSP assertion is a real regression guard. E1 is the only one I'd fix before merge — not for the wildcard (that fails closed) but because the guard now crashes with an unrelated error on a valid host spelling it previously accepted, which is a regression the tests can't see. E2–E4 are polish, and E3 is a fair follow-up issue.

Note: node_modules isn't installed in this checkout, so I did not run the suite, validate, coverage, or a build. Everything asserted about Node behavior in E1 and E2 (net.isIPv6 vs. new URL on zone ids, the real listen("::%eth0"):: bind, writeHead ERR_INVALID_CHAR) was verified by running Node directly. CI run 30220104780 was still in progress at review time.
· branch v2/enforce-loopback-bind

… docs (#1795)

Address the round-4 review of #1796:

- E1: strip the IPv6 zone index (%eth0) before canonicalizing. net.isIPv6
  accepts a zone id but new URL() rejects it (even %25-encoded), so a
  zone-scoped HOST previously threw an opaque TypeError out of the guard —
  breaking a legitimate link-local bind (fe80::1%eth0) and losing the
  actionable message on ::%eth0 (a real wildcard). Now ::%eth0 → :: (flagged),
  fe80::1%eth0 → fe80::1 (not flagged) and the zone is kept on the returned
  value for listen().
- E2: filter allowedOrigins to well-formed scheme://host[:port] sources before
  interpolating them into the sandbox CSP header, so a newline (ERR_INVALID_CHAR
  → dead sandbox page) or ';' (injected CSP directives) in ALLOWED_ORIGINS can't
  corrupt the header; falls back to loopback when nothing valid remains.
- E4a: printServerBanner uses httpOrigin too, so banner and origin list agree
  on the default-port (:80) form.
- E4b/E4c/E3: doc clarifications — isAllZeroIpv4's >4 guard rationale + short
  inet_aton forms; README notes the wildcard covers every spelling, and an MCP
  Apps caveat (sandbox runs on a separate port that must be reachable off
  loopback).
- tests for the zone-index and CSP-filtering paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
@cliffhall

Copy link
Copy Markdown
Member Author

Round-4 addressed in ed1f9930. All handled in this PR (E3 folded in as a doc caveat rather than a follow-up, per this PR's "close the class" intent).

E1 — zone index crashes the guard (medium-low) — Fixed, and thanks — this was a real regression (a valid host spelling that previously passed now threw). I verified your Node findings: net.isIPv6("fe80::1%eth0") is true but new URL("http://[fe80::1%eth0]") throws — and notably it throws even %25-encoded ([fe80::1%25eth0]ERR_INVALID_URL), so a URL host genuinely can't carry a zone id. canonicalizeIpv6 now strips the zone (value.split("%")[0]) before serializing: ::%eth0:: (flagged), fe80::1%eth0 / ::1%lo0 → not flagged and no throw, and resolveBindHostname returns the value with its zone intact for listen(). Positive + negative it.each cases added, plus a resolveBindHostname test asserting the zone survives.

E2 — ALLOWED_ORIGINS flows unvalidated into the CSP header (low) — Fixed. sandboxFrameAncestors now filters entries to well-formed scheme://host[:port] sources (/^[a-z][a-z0-9+.-]*:\/\/[^\s;,'"]+$/i) before joining, so a newline (ERR_INVALID_CHAR → dead sandbox page) or ; (injected directives) is dropped; if nothing valid survives it degrades to the loopback fallback. Tests cover both the drop-malformed and all-malformed-→-fallback paths.

E3 — sandbox URL is built from the bind host (low) — Since C4 was fixed principled-not-caveated, I kept it consistent by... adding the caveat here rather than a follow-up (the review process for this PR isn't creating follow-ups). The new Hosting on a network section now notes the MCP Apps sandbox runs on a separate MCP_SANDBOX_PORT that must be independently reachable off loopback (and that under 0.0.0.0 the sandbox URL is unreachable — bind a specific address for Apps). Making the served sandboxUrl derive from a browser-reachable host rather than the bind host is a larger change I'm happy to do as a separate PR if you'd prefer it in code — but it's out of the bind-guard scope and the caveat is accurate today.

E4 nits — Done: printServerBanner uses httpOrigin so it and the origin list agree on dropping :80; the isAllZeroIpv4 doc comment now names the short 0.0 / 0.0.0 forms and explains why it's > 4 not === 4 (+ positive test cases); the README guard description now lists the equivalent spellings. The zone-index %eth0 note from round 3 is retired as the real E1 fix.

Green locally: web test:coverage (per-file ≥90; hostUrl.ts 100%), the targeted unit/integration suites (135), format, and eslint.

@cliffhall

Copy link
Copy Markdown
Member Author

@claude review

@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Claude finished @cliffhall's task in 5m 18s —— View job


Code review — PR #1796 (round 5, after ed1f9930)

  • Gather context (full diff, 7 commits incl. the v2/main merge)
  • Verify the round-4 findings (E1–E4) landed
  • Fresh pass over the new code (zone stripping, CSP source filter, derived directive, docs)
  • Review tests
  • Post review

All four round-4 items landed, and E1 landed exactly right. I re-verified the Node behavior it turns on: net.isIPv6("fe80::1%eth0") is true, and new URL("http://[fe80::1%eth0]…") throws ERR_INVALID_URL even %25-encoded — so stripping the zone before canonicalizing (rather than try/catching) is the correct shape, and ::%eth0:: is flagged while fe80::1%eth0 / ::1%lo0 are not.

# Round-4 finding Status
E1 Zone index crashed the guard ✅ fixed — zone stripped for detection, kept on the returned value; +/− it.each cases and a resolveBindHostname zone test
E2 ALLOWED_ORIGINS → CSP header unvalidated ✅ fixed — CSP_HOST_SOURCE filter + fallback, both paths tested
E3 Sandbox URL built from the bind host ✅ documented (MCP Apps caveat in the web README)
E4 Nits (banner/origin port-80 agreement, > 4 doc, README spellings) ✅ all three

The bind guard itself I now consider closed: ALL_INTERFACES_LITERALS + canonicalizeIpv6 + isAllZeroIpv4 is a complete cover of the wildcard family across both address families and the legacy inet_aton spellings, detection/listen()/origin-list all consume the same trimmed, de-bracketed, zone-preserving value, and it's well tested. The remaining findings are all on the origin allow-list / CSP side.


F1. The 0.0.0.0 default allow-list can never match — so the Docker image (this PR's one sanctioned exception) 403s every connect (medium)

defaultAllowedOrigins returns a single exact origin for a non-loopback host, so under the opt-in path it yields http://0.0.0.0:6274. The origin check is an exact string match (core/mcp/remote/node/server.ts:202,225), and a browser never sends 0.0.0.0 as its Origin — it sends whatever it navigated to (http://localhost:6274 for docker run -p 6274:6274). Result, in the shipped image with default env:

  • GET / and the SPA load fine (same-origin GETs carry no Origin).
  • The first POST to /api/* (every connect, and createRemoteFetch/remoteClientTransport generally) carries Origin: http://localhost:6274403 "Invalid origin … DNS rebinding" — the exact failure mode this PR set out to eliminate, one config over.

The PR knows this: clients/web/README.md:174 says that under the wildcard you must also set ALLOWED_ORIGINS. But the Dockerfile — the place this PR grants the opt-in — sets HOST, DANGEROUSLY_BIND_ALL_INTERFACES, CLIENT_PORT, MCP_AUTO_OPEN_ENABLED and not ALLOWED_ORIGINS, and the root README's Docker section doesn't mention it either. So the documented requirement and the shipped artifact disagree.

The pre-existing part is the value (main also returned [baseUrl]); what's new is (a) the PR now documents the requirement without satisfying it at the one place it applies, (b) web-server-config.test.ts now asserts the unusable value (expect(cfg.allowedOrigins).toEqual(["http://0.0.0.0:8123"])), freezing it, and (c) since round 3, the sandbox CSP is derived from this same list — so frame-ancestors becomes http://0.0.0.0:6274, where it used to be the loopback wildcard family that did admit a http://localhost:6274 embedder. That last one is a small regression from the C4 fix, in the same configuration.

The principled fix is in defaultAllowedOrigins: an all-interfaces bind does serve loopback, so the loopback origins are legitimately reachable and adding them weakens nothing (still exact-match, still loopback-or-the-bound-host only) — e.g. return the loopback trio plus http://<host>:port when isAllInterfacesHost(hostname). That fixes the connect 403, the derived CSP, and the Docker default in one place. (Belt-and-braces: ENV ALLOWED_ORIGINS=… in the Dockerfile for the -p case.)

Fix this →

F2. Worth verifying in a browser: a bracketed IPv6 host-source isn't in the CSP grammar (low-medium, verify)

The http://[::1]:* entry added in round 1 (and the http://[::1]:6274 the derived list now emits) rests on browsers accepting a bracketed IPv6 literal as a CSP host-source. The CSP3 grammar doesn't cover it:

host-source = [ scheme-part "://" ] host-part [ port-part ] [ path-part ]
host-part   = "*" / [ "*." ] 1*host-char *( "." 1*host-char )
host-char   = ALPHA / DIGIT / "-"

[, :, ] aren't host-char, and browsers are required to ignore an invalid source expression (with a console warning). Chromium's CSP parser does appear to special-case brackets for IPv6, but I can't confirm that here (no browser in this checkout), and I don't know Firefox/WebKit's behavior. If a browser drops the source, the headline symptom — IPv6-loopback embedder ⇒ sandbox iframe CSP-blocked — is still present despite the fix, and nothing in the suite would show it (the tests assert the header string, not the browser's parse). Cheapest confirmation: load the app at http://[::1]:6274 with an MCP App open and check for a CSP console warning / blocked frame. If it doesn't hold, the note belongs in the code comment next to LOOPBACK_FRAME_ANCESTORS so the entry isn't mistaken for coverage it doesn't provide.

F3. E1 fixed the guard, but the URL builders downstream are still zone-unaware (low)

Now that a zone-scoped HOST legitimately passes the guard and is returned with its zone, that value flows into formatHostForUrl, which brackets anything containing ::

  • sandbox-controller.ts:175sandboxUrl = "http://[fe80::1%eth0]:PORT/sandbox", and start()'s already-started branch (:116) does new URL(sandboxUrl)TypeError (verified: throws even as %25eth0). Also handed to the browser via /api/configApp.tsx:735.
  • printServerBanner → the same string, then open(url).
  • defaultAllowedOrigins → an origin that can never match.

Practically unreachable (a zone-scoped bind host is exotic, and start() is called once per controller), and strictly better than the pre-E1 crash — but "the guard accepts it" now implies "the rest of the pipeline handles it," and it doesn't. Either normalize in formatHostForUrl (drop the zone — a URL host can't carry one at all) or say so in its doc comment, so the boundary is explicit rather than latent.

F4. Nits

  • The CSP integration test only exercises the fallback. sandbox-controller.test.ts:127 creates the controller without allowedOrigins, so it asserts frame-ancestors http://127.0.0.1:* http://localhost:* http://[::1]:* — but both real callers (server.ts:43, vite-hono-plugin.ts:65) always pass the list, so the shipped default directive is the exact-port trio (http://localhost:6274 …), which no end-to-end test covers. sandboxFrameAncestors is unit-tested, so this is coverage shape rather than a gap; worth one controller case constructed with a list so the header the product actually serves is guarded.
  • formatHostForUrl treats any : as IPv6. A mistyped HOST=localhost:6274 becomes [localhost:6274] in the banner/origin/sandbox URL rather than failing legibly. A net.isIP-based check would be exact now that the helper is shared by five call sites including the CLI deep-link.
  • resolveBindHostname preserves case (HOST=LOCALHOST"LOCALHOST"), which is fine only because every consumer lowercases (defaultAllowedOrigins) or is case-insensitive (listen). Since the function already normalizes trim + brackets, folding case in would make "the returned value is the normalized one" true without exception.
  • clients/cli/src/cli.ts:727 reads raw process.env.HOST for the handoff deep-link, so a wildcard/zoned HOST still yields an unusable link. The formatter swap fixed the bracketing half; the value half is out of scope here.

Verdict: the bind guard is done — closed cover, good normalization, real tests, and the actionable message now actually reaches the user. F1 is the one I'd fix before merge, because it's the same defect class as the bug in the PR title (a legitimate browser origin that the allow-list can't match), it now has a test freezing the broken value, and it lands on the exact path the PR sanctions and documents. F2 is a five-minute browser check that decides whether the headline [::1] fix works at all. F3/F4 are polish.

Note: node_modules isn't installed in this checkout (root and clients/web both absent), so I did not run the suite, validate, coverage, or a build. The Node behavior asserted above (net.isIPv6 vs. new URL on zone ids, including the %25-encoded form) was verified by running Node directly; F1's 403 is established by reading the exact-match middleware (core/mcp/remote/node/server.ts:202,225) against the derived list, plus the Origin-on-POST behavior the PR body itself documents.
· branch v2/enforce-loopback-bind

… builder (#1795)

Address the round-5 review of #1796:

- F1: defaultAllowedOrigins returns the loopback trio for an all-interfaces
  bind (0.0.0.0 / ::) — a wildcard bind serves loopback, so `docker run -p`
  browsed at http://localhost:PORT now connects out of the box instead of
  403ing against the unmatchable http://0.0.0.0:PORT. README/Docker bullet
  reconciled (non-loopback access still needs ALLOWED_ORIGINS).
- F2: VERIFIED in headless Chromium that a bracketed IPv6 literal is NOT a
  valid CSP host-source — `frame-ancestors http://[::1]:*` degrades to 'none'
  and blocks the frame. So the round-1 [::1] CSP entry never worked: dropped it
  from the loopback fallback and from the derived directive (CSP_HOST_SOURCE now
  rejects brackets), and documented that MCP Apps requires browsing at a
  name/IPv4, not a bare [::1].
- F3/F4b: formatHostForUrl brackets only real IPv6 (net.isIPv6, not "has a
  colon") and drops the zone id (a URL host can't carry one), so a mistyped
  host:port and a zone-scoped bind host no longer produce invalid URLs.
- F4a: integration test asserts the CSP the product actually serves (controller
  constructed WITH allowedOrigins), not just the fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
@cliffhall

Copy link
Copy Markdown
Member Author

Round-5 addressed in e571afee. F2 I verified empirically rather than assuming — and it changed the fix.

F1 — Docker/wildcard allow-list can never match (medium) — Fixed. defaultAllowedOrigins now returns the loopback trio for an all-interfaces bind (0.0.0.0 / :: / any spelling, via isAllInterfacesHost), because a wildcard bind also serves loopback: docker run -p 6274:6274 browsed at http://localhost:6274 sends that as its Origin and now matches. I omitted a http://0.0.0.0:PORT entry (provably never sent as an Origin). This fixes the connect 403 and the derived sandbox CSP for the Docker default in one place. The README/Docker bullet is reconciled: local access works out of the box, non-loopback access still needs ALLOWED_ORIGINS (those origins can't be enumerated from the wildcard, so a hardcoded ENV ALLOWED_ORIGINS wouldn't be right). The frozen test now asserts the trio.

F2 — bracketed IPv6 as a CSP host-source (verify) — Verified in headless Chromium, and your grammar reading is right: it does not work. I served frame-ancestors http://[::1]:* (and the exact-port form) on a [::1] origin and embedded it from [::1]; both were blocked — Chromium reports frame-ancestors 'none', i.e. it drops the invalid source and the directive collapses. A 'self' control on the same harness rendered, so it's the bracket, not the setup. Consequences applied:

  • Dropped http://[::1]:* from the loopback fallback (it never worked; http://127.0.0.1:* + http://localhost:* cover name/IPv4 loopback, which is all a browser can actually match).
  • CSP_HOST_SOURCE now also rejects brackets, so a bracketed-IPv6 origin in allowedOrigins is dropped from the derived directive instead of poisoning it (a lone IPv6 source would otherwise → 'none').
  • Documented in code + the README MCP Apps caveat: the Apps sandbox requires browsing at a name/IPv4 (localhost), not a bare http://[::1]:… — CSP can't express an IPv6-literal embedder. (The headline connect fix is unaffected — that's an exact Origin string match, not CSP, and http://[::1]:6274 matches there fine.)

F3 + F4b — zone-unaware / colon-based URL building (low) — Fixed together. formatHostForUrl now brackets only a real IPv6 literal (net.isIPv6, not "contains a colon") and strips the zone id, so a mistyped HOST=localhost:6274 isn't wrapped as [localhost:6274] and a zone-scoped bind host yields http://[fe80::1]:… (a valid URL) instead of a new URL() crash on the sandbox restart path. Tests updated.

F4a — CSP integration test only hit the fallback (nit) — Added a controller case constructed with allowedOrigins, asserting the exact-origin directive the product actually serves (and that the [::1] entry is dropped).

F4c / F4d — deferred with reason. F4c (folding case in resolveBindHostname): left as-is deliberately — every consumer already lowercases or is case-insensitive, and lowercasing would have to spare the zone id (interface names can be case-sensitive), which isn't worth the special-case for a nit. F4d (the CLI handoff reads raw HOST): as you noted, the value half is out of the bind-guard scope — a wildcard/zoned HOST for the CLI deep-link is a CLI concern, and the formatter swap already fixed the bracketing half.

Green locally: web test:coverage (per-file ≥90; hostUrl.ts 100%), the targeted suites (142), CLI typecheck, validate:core, format, eslint.

@cliffhall

Copy link
Copy Markdown
Member Author

@claude review

@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Claude finished @cliffhall's task in 4m 13s —— View job


Code review — PR #1796 (round 6, after e571afee)

  • Gather context (full diff, 8 commits)
  • Verify the round-5 findings (F1–F4) landed
  • Fresh pass over the new code (wildcard allow-list, CSP source filter, formatHostForUrl)
  • Review tests / docs
  • Post review

All of F1–F4 landed, and F2 is the standout: you didn't take my grammar reading on faith, you put it in a browser and it changed the fix. Dropping http://[::1]:* and excluding brackets from CSP_HOST_SOURCE is the right conclusion — a lone invalid source collapsing the directive to 'none' is exactly the failure mode worth avoiding.

# Round-5 finding Status
F1 Wildcard allow-list unmatchable ✅ loopback trio for a wildcard bind (but see H2 — the omitted entry isn't dead weight)
F2 Bracketed IPv6 as a CSP host-source ✅ verified empirically, fix inverted accordingly, documented in code + README
F3/F4b Zone-unaware / colon-based URL building formatHostForUrl now keys off net.isIPv6 and strips the zone
F4a CSP test only hit the fallback ✅ derived-directive controller case added
F4c/F4d Case folding, CLI raw HOST ✅ deferred with stated reasons — both reasonable

The bind guard remains closed and well-tested. The two findings below are both on the origin allow-list.

H1. ALLOWED_ORIGINS that parses to an empty list silently disables the origin guard (medium)

web-server-config.ts:307-310:

allowedOrigins:
  process.env.ALLOWED_ORIGINS?.split(",").map((o) => o.trim()).filter(Boolean) ??
  defaultAllowedOrigins(hostname, port),

?? only fires on undefined, and [] is not undefined — so ALLOWED_ORIGINS="", " ", or "," yields []. The middleware treats that as allow everything (core/mcp/remote/node/server.ts:192-196):

// If no allowedOrigins configured, skip validation (allow all)
if (!allowedOrigins || allowedOrigins.length === 0) { await next(); return; }

So -e ALLOWED_ORIGINS= (a bare -e with no value, or an empty line in a compose .env) turns off the DNS-rebinding protection this PR exists to strengthen, with no log line saying so. The "" spelling is pre-existing, but the new .map(trim) widens it: ALLOWED_ORIGINS=" " previously produced [" "] — a non-empty list that matched nothing, i.e. fail closed — and now produces [], i.e. fail open. Small surface, wrong direction.

Worth noting the two consumers of the same field now disagree about what empty means: sandboxFrameAncestors treats it as "no information, be strict" and falls back to loopback; the origin middleware treats it as "allow all". The fix keeps them consistent — fall back to the default when nothing survives parsing:

const configured = process.env.ALLOWED_ORIGINS?.split(",").map((o) => o.trim()).filter(Boolean);
allowedOrigins: configured?.length ? configured : defaultAllowedOrigins(hostname, port),

Fix this →

H2. My round-5 premise was wrong: http://0.0.0.0:PORT is a real browser origin — and the banner advertises it (medium-low)

web-server-config.ts:228-232 now states, on my say-so, that "the bind host itself (0.0.0.0) is never sent as an Origin, so a http://0.0.0.0:PORT entry would only ever be dead weight." That's not right, and it matters here. 0.0.0.0 is locally connectable — I verified a client connect to 0.0.0.0 on a listening port returns 200 — and browsers on Linux/macOS navigate to http://0.0.0.0:6274 fine, sending that exact string as Origin on the connect POST.

Which is a problem, because the Docker banner prints that URL: printServerBanner builds it from config.hostname, so the container logs read

MCP Inspector Web is up and running at:
   http://0.0.0.0:6274?MCP_INSPECTOR_API_TOKEN=…

and that log line is the user's only source for the token — pasting it is the natural move. Under main that origin was the one entry in the allow-list; after F1 it's the one loopback-equivalent form not in it, so following the banner now 403s on connect. Net effect of F1 in the Docker default: localhost fixed, 0.0.0.0 broken. Both should work.

Adding it back weakens nothing — a rebinding attacker's page carries Origin: http://attacker.com, and no attacker-controlled document can claim http://0.0.0.0:6274 without the user having navigated there. Two independent fixes, ideally both:

  1. Include httpOrigin(formatHostForUrl(h), port) alongside the trio in the isAllInterfacesHost branch (and correct that comment).
  2. Have printServerBanner advertise localhost when the bind host is the wildcard, so the log points at a URL that's reachable and allow-listed.

Fix this →

H3. Docker with a remapped port still 403s, and no doc covers it (low)

docker run -p 8080:6274 (any remap — common when 6274 is taken) puts the browser at http://localhost:8080, while CLIENT_PORT=6274 makes the allow-list the trio at 6274. Exact-match ⇒ 403 on connect, and the derived frame-ancestors blocks the sandbox iframe too. Not a regression (equally broken before), and unavoidable with port-scoped origins — but the new Hosting on a network section only tells users they need ALLOWED_ORIGINS for non-loopback access, so a loopback-with-a-different-port user has no signal. One sentence in the root README's Docker paragraph ("if you remap the published port, set -e ALLOWED_ORIGINS=http://localhost:<hostPort>") closes it. Alternatively -p 8080:6274 users can just set -e CLIENT_PORT=8080 -p 8080:8080.

H4. Nits

  • The new run-web.ts failure path has no test. The round-1 item-3 fix (actionable message instead of a stack trace) is unguarded — delete the try/catch and CI stays green, same class as A2. It's a ~6-line addition mirroring run-web.test.ts:153: process.exit is already spied to throw, so process.env.HOST = "0.0.0.0" + await expect(runWeb([...])).rejects.toThrow("process.exit:1") + assert errorLines mentions DANGEROUSLY_BIND_ALL_INTERFACES (remember to snapshot/restore HOST, which isn't in that file's env-cleanup list). Also worth checking run-web.ts still clears the ≥90 branch gate with the new branches — you report test:coverage green, so presumably yes.
  • An explicitly narrow ALLOWED_ORIGINS can silently widen the CSP. ALLOWED_ORIGINS=http://[::1]:6274 (only IPv6 entries) → every entry filtered → fallback http://127.0.0.1:* http://localhost:*, i.e. any-port loopback, broader than what the user asked for. Defense-in-depth only, and the alternative ('none') is worse; just noting the direction of the degradation isn't obviously the safe one.
  • formatHostForUrl still passes a bracketed-with-zone host straight through. formatHostForUrl("[fe80::1%eth0]") → unchanged (the startsWith("[") early return precedes the zone strip), so new URL() still rejects it. Unreachable from resolveBindHostname (which de-brackets), reachable from cli.ts's raw process.env.HOST. Moving the strip ahead of the early return would make the helper total.
  • The vitest bypass leaves browser-mode vitest binding raw HOST. isDevServer excludes VITEST, which is correct for server.host in the unit projects — but the Storybook browser project does bind a real listener, so an ambient HOST=0.0.0.0 binds it wide. Test-only, serves stories not the backend, ephemeral — mentioning for completeness, not asking for a change.

Verdict: the bind guard is done and I have nothing left on it. H1 is the one I'd fix before merge: it's a fail-open in the same subsystem the PR hardens, one character of env value away, and the fix is three lines. H2 is worth taking because the PR's own banner points users at the origin it just removed — and because the comment now asserts something untrue on my recommendation.

Note: node_modules isn't installed in this checkout, so I did not run the suite, validate, coverage, or a build. H2's connectability claim was verified by running Node directly (listen('0.0.0.0') + client connect to 0.0.0.0 → 200); H1 is established by reading the ?? semantics against createOriginMiddleware's length === 0 short-circuit.
· branch v2/enforce-loopback-bind

… real origin (#1795)

Address the round-6 review of #1796:

- H1: ALLOWED_ORIGINS that parses to an empty list ("", " ", ",") now falls
  back to the default allow-list instead of [] — the origin middleware treats
  [] as allow-all, so a blank value silently disabled the DNS-rebinding guard
  (fail open). Now fails closed. Tests added.
- H2: 0.0.0.0 IS a real, locally-connectable browser origin (my round-5 premise
  was wrong), and the banner prints http://0.0.0.0:PORT — the user's only
  source for the token in Docker. So (a) defaultAllowedOrigins includes the
  bound-host origin alongside the loopback trio for a wildcard bind, and (b)
  printServerBanner advertises localhost for a wildcard bind, so the printed URL
  is both reachable and allow-listed. Comment corrected.
- H3: root README notes a remapped Docker port needs ALLOWED_ORIGINS.
- H4a: test the run-web bind-guard failure path (actionable message, not a
  stack trace). H4c: formatHostForUrl is now total — a bracketed-with-zone host
  yields a valid URL authority.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
@cliffhall

Copy link
Copy Markdown
Member Author

Round-6 addressed in 43a9b47f.

H1 — empty ALLOWED_ORIGINS silently disabled the guard (medium) — Fixed, and you're right this was a fail-open in exactly the wrong direction. ALLOWED_ORIGINS="" / " " / "," now falls back to defaultAllowedOrigins (fail closed) rather than reaching the middleware as [] (which it reads as allow-all). Tests assert the default trio for all three empty spellings. This also reconciles the two consumers — the origin middleware and sandboxFrameAncestors now agree that "empty" means "be strict."

H2 — 0.0.0.0 IS a real origin, and the banner prints it (medium-low) — Fixed, and thanks for correcting my round-5 premise — I verified you're right (http://0.0.0.0:6274 is locally connectable and browsers send it as Origin). Two fixes, both applied:

  1. defaultAllowedOrigins for a wildcard bind now returns the loopback trio plus the bound-host origin (http://0.0.0.0:PORT), so pasting the banner URL connects. Corrected the comment that wrongly called it dead weight.
  2. printServerBanner now advertises localhost for a wildcard bind, so the Docker log points at a URL that's both reachable and allow-listed.

Together: in the Docker default, both localhost and 0.0.0.0 connect, and the banner steers users to the cleaner one.

H3 — remapped Docker port (low) — Added a sentence to the root README Docker paragraph: -p 8080:6274 needs -e ALLOWED_ORIGINS=http://localhost:8080 (or -e CLIENT_PORT=8080 -p 8080:8080).

H4a — run-web failure path untested (nit) — Added a run-web.test.ts case: HOST=0.0.0.0runWeb rejects with process.exit:1, errorLines contains DANGEROUSLY_BIND_ALL_INTERFACES, and neither server starts. Snapshot/restore HOST added to the suite's env cleanup.

H4c — formatHostForUrl bracketed-with-zone passthrough (nit) — Fixed. The strip now runs before the bracket check, so formatHostForUrl("[fe80::1%eth0]")[fe80::1] (a valid URL host) — the helper is total now. Test added.

H4b / H4d — noted, no change — H4b (a lone-IPv6 ALLOWED_ORIGINS widening the CSP to loopback-any-port): defense-in-depth only, and the alternative ('none') is strictly worse, so the fallback direction is the safe one. H4d (the Storybook browser vitest project binds raw HOST): test-only, serves stories not the backend, ephemeral — agreed it's not worth gating.

Green locally: web test:coverage (per-file ≥90; hostUrl.ts 100%), the targeted suites (175), tsc -b, CLI typecheck, validate:core, format, eslint.

@cliffhall

Copy link
Copy Markdown
Member Author

@claude review

…red (#1795)

Address the round-24 review of #1796 (all on the newest guard's error-reporting):

- AJ1: the OAuth callback guard's message contains "OAuth", so the CLI exit-code
  heuristic classified it as AUTH_REQUIRED (exit 3) — telling an automated caller
  to re-auth on a config error. And parseRunnerOAuthCallbackUrl runs for EVERY
  --cli invocation, so an exported MCP_OAUTH_CALLBACK_URL=http://0.0.0.0/… made
  every call exit 3. Wrapped the call in cli.ts and rethrow as
  CliExitCodeError(USAGE) → exit 1, code "error". Test asserts exit 1 / not
  auth_required.
- AJ2: the TUI bin (clients/tui/index.ts) and the launcher bin
  (clients/launcher/src/index.ts) printed a config-error Error as a raw stack
  trace; now print err.message like run-web.ts's house pattern, so the guard's
  actionable message isn't buried.
- AJ3: AGENTS.md + root README name isLoopbackHost in the core/node/hostUrl entry.
- AJ4a: CSP_HOST_SOURCE now excludes `*` too, matching its "can't corrupt/widen"
  invariant. AJ4b: pin isLoopbackHost's canonical ::ffff:7f00:1 serialization.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
@cliffhall

Copy link
Copy Markdown
Member Author

Round-24 addressed in ea7625f7.

AJ1 — OAuth guard error misclassified as auth_required (low-medium) — Fixed, and good catch that it's not flag-only: parseRunnerOAuthCallbackUrl runs unconditionally for every --cli invocation, so an exported MCP_OAUTH_CALLBACK_URL=http://0.0.0.0:… would have made even a plain stdio tools/list exit 3 auth_required. Since core/ can't import CliExitCodeError, I wrapped the call site in cli.ts and rethrow the config error as CliExitCodeError(EXIT_CODES.USAGE, message). classifyError checks instanceof CliExitCodeError before the OAuth text heuristic, so it now exits 1 with envelope code "error", not auth_required. This covers the sibling Invalid OAuth callback URL / must use http scheme throws on the same path too. Test asserts exit 1, code error (not auth_required), and the loopback message.

AJ2 — TUI/launcher printed the guard message as a stack trace (low) — Fixed, the round-1 item-3 pattern one client over. clients/tui/index.ts and clients/launcher/src/index.ts now print err.message for Error instances (falling back to the value) before process.exit(1), matching run-web.ts. (The CLI path was already fine — handleError emits the message inside the JSON envelope.)

AJ3 — isLoopbackHost doc drift (nit) — Added it to the core/node/hostUrl.ts entry in AGENTS.md and the root README (the OAuth-callback gate).

AJ4a — CSP_HOST_SOURCE didn't exclude * (nit) — Fixed. The char class now excludes *, so a http://*.example.com entry (which the origin allow-list already rejects, but a future non-canonicalizing caller might not) can't widen the frame-ancestors set — matching the "can't corrupt" invariant the doc claims. Test drops it.

AJ4b — test pinned ::ffff:127.0.0.1 but not its serialization (nit) — Added ::ffff:7f00:1 (what new URL().hostname actually yields for a bracketed mapped callback URL) to the isLoopbackHost positives.

AJ4c — resolveSandboxPort warn vs CLIENT_PORT hard-exit — Left as-is per your note: deliberate (best-effort sandbox degradation vs. load-bearing web port), documented.

Green locally: web + CLI + launcher test:coverage (all per-file ≥90; hostUrl.ts 100%; the new cli try/catch branch covered), the targeted core/web suites (119), CLI handoff/exit-code suite (35), tsc -b, cli/tui typecheck, validate:core, format, eslint. (tui/index.ts + tui.tsx sit outside the src/** coverage include, so the bin edits aren't gated.)

@cliffhall

Copy link
Copy Markdown
Member Author

@claude review

@claude

claude Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Claude finished @cliffhall's task in 5m 5s —— View job


Code review — PR #1796 (round 25, after ea7625f7)

  • Gather context (full diff, 27 commits)
  • Verify the round-24 findings (AJ1–AJ4) landed
  • Fresh pass over the code (independent isLoopbackHost sweep; traced the CLI/TUI/launcher error paths end to end)
  • Review tests / docs
  • Post review

All four round-24 items landed. AJ1's CliExitCodeError(EXIT_CODES.USAGE, …) is placed correctly — classifyError checks instanceof CliExitCodeError before the OAuth text heuristic (error-handler.ts:110), and codeForExit(1)"error", so the test's assertions are the real behavior. AJ4a's * exclusion is safe: sandboxFrameAncestors filters only allowedOrigins, not the fallback constant, so tightening the char class can't collapse the loopback default.

# Round-24 finding Status
AJ1 OAuth guard error classified auth_required ✅ pinned to USAGE; exit 1 / code error asserted — but see AK1
AJ2 TUI/launcher printed a raw stack ✅ both print err.message — see AK2 for the cost
AJ3 isLoopbackHost doc drift ✅ AGENTS.md + root README
AJ4a CSP_HOST_SOURCE admitted * ✅ excluded + tested — see AK3
AJ4b ::ffff:7f00:1 unpinned ✅ added to the positives

I re-derived isLoopbackHost independently rather than inheriting round 23's sweep: 36 spellings through the current hostUrl.ts logic compared against a real http.listen(). No false accepts (nothing accepted binds a non-loopback address) and no false rejects among bindable loopback forms except the two already declined with reason (lvh.me, a hosts/DNS alias that does resolve to 127.0.0.1; localhost.., which fails ENOTFOUND anyway). I also confirmed AH2's trailing-dot fix through the real call path rather than the predicate alone — new URL("http://127.0.0.1.:6276/…").hostname is 127.0.0.1 (WHATWG strips it for IP literals) while localhost. is preserved and binds ::1, so the .replace(/\.$/,"") is load-bearing for exactly the name form.

One substantive finding, then nits. No defect in the host-normalization, allow-list, or CSP logic.


AK1. The CLI's exit-code map and JSON envelope are unreachable through mcp-inspector --cli — the documented invocation (medium-low, pre-existing)

AJ1's premise is that "an automated caller (CI, an agent) can branch on the failure class". That holds for node clients/cli/build/index.js …, whose bin does runCli(process.argv).then(…).catch(handleError) (clients/cli/src/index.ts:16-18). It does not hold for the way every doc tells users to run it.

mcp-inspector --cli goes through the launcher, which imports runCli as a module and owns the rejection itself:

// clients/launcher/src/index.ts:52-53, 60-68
const { runCli } = await import(clientEntry("cli"));
await runCli(forwardedArgv);

run().catch((err: unknown) => {
  console.error("Error running MCP Inspector:", err instanceof Error ? err.message : err);
  process.exit(1);                                  // ← every failure class → 1
});

The CLI bin's isMain guard is false under the launcher (process.argv[1] is the launcher's path), so handleError — the only caller of formatErrorOutput — never runs. Consequences for npx @modelcontextprotocol/inspector --cli:

  • All five non-zero codes collapse to 1. NO_APP (2), AUTH_REQUIRED (3), UNREACHABLE (4), TOOL_ERROR (5) are indistinguishable.
  • The JSON envelope is never emitted — stderr is a human string, not {"error":{…}}.

Both are documented contracts, in the launcher's own voice: clients/cli/README.md:3 says the CLI is "Invoked as mcp-inspector --cli", :212 promises "it exits 3 (auth_required, envelope refresh_failed)", :248 shows the envelope; and docs/mcp-app-review.md — an agent-facing recipe whose every command is npx @modelcontextprotocol/inspector --cli (lines 16, 47, 140, 195) — tells the reader at :150 that a revoked token "exits 3 (auth_required)". Under those exact commands it exits 1 with no envelope, so an agent following the doc silently loses the branch.

This is entirely pre-existing — the launcher's catch-all predates the PR, and this commit only changed errerr.message inside it. I'm raising it here because round 24 hardened the classification on a premise the shipped entry point doesn't honor, and AJ2 just edited the precise handler that flattens it. The fix is small and local to the launcher: for the CLI mode, route the rejection through the CLI's own sink (the client build already exports it alongside runCli) instead of the generic handler.

Fix this →

AK2. AJ2 dropped the stack at a catch-all, not at a config boundary (nit)

run-web.ts's house pattern is a narrow try/catch around one call known to throw actionable config errors; everything else still surfaces normally. AJ2 applied the same shape to clients/tui/index.ts:15 and clients/launcher/src/index.ts:60, which are the top-level sinks for all work in their modes — an ESM resolution failure, an Ink render crash, a TypeError deep inside InspectorClient. Those now print one line with no frames (verified: console.error(err) prints err.stack, console.error(err.message) does not), so a user's bug report carries nothing to locate the fault. The actionable-message goal and the debuggability goal aren't in conflict — print the message always and append err.stack when process.env.DEBUG/MCP_DEBUG is set, or keep the stack for errors that aren't a known config class.

AK3. AJ4a made LOOPBACK_FRAME_ANCESTORS fail its own CSP_HOST_SOURCE (nit)

sandbox-controller.ts:40 is ["http://127.0.0.1:*", "http://localhost:*"], and the newly-tightened regex (:57) excludes * — so the module's own fallback no longer matches the predicate that guards the same header, and the doc's closing sentence ("Only values matching this shape survive into sandboxFrameAncestors") is now literally false for the fallback path. Benign today because the filter is applied to allowedOrigins only, but it's the kind of thing a later tidy-up ("filter sources, not valid — belt and braces") turns into frame-ancestors with no sources. One clause noting the fallback is a trusted constant that deliberately uses the :* port-wildcard would keep the two from reading as contradictory.

AK4. Nits

  • isLoopbackHost accepts a host-with-port. isLoopbackHost("localhost:6276")true (verified): formatHostForUrl passes it through, then new URL("http://localhost:6276") parses and .hostname drops the port. Unreachable from parseRunnerOAuthCallbackUrl, which feeds it url.hostname — but exactly the AH1 situation one input over, and its sibling isAllInterfacesHost is called with a raw env value. Same resolution as AH1: bound it, or say in the doc comment that it expects a bare host.
  • Two error prefixes in one launcher file. parse-launcher-argv's catch (index.ts:31) prints Error: <message> while the new top-level catch prints Error running MCP Inspector: <message>. Cosmetic, but the two are now three lines of the same shape.
  • docs/mcp-app-review.md:150's exit-3 claim is the concrete casualty of AK1 — whichever way AK1 lands (fix the launcher, or narrow the doc), that line and the clients/cli/README.md:212/:248 envelope example should end up agreeing with the shipped behavior.

Verdict: converged; nothing in this PR's own surface blocks. Twenty-five rounds on, the host-address work is closed and I have no change to ask for in it. Re-derived this round rather than inherited: the wildcard refusal covers both address families, the legacy inet_aton family (decimal/octal/hex, 1–4 parts), bracketed, trailing-dot, zone-scoped, IPv4-mapped, and IDNA/fullwidth spellings, with every escape failing loudly at listen(); the predicate reasons about the address the socket binds rather than the string typed; detection, listen(), the origin allow-list, the banner, the sandbox URL, the CLI deep-link, and both OAuth redirect builders consume one normalized value from one shared module in core/node/, with banner ⊆ allowedOrigins asserted as a property; the allow-list canonicalizes derived and typed inputs, fails closed on empty with no env knob to disable it, and warns-and-drops opaque, wildcard, and unparseable entries; frame-ancestors derives from the real embedder list with an IPv6 exclusion settled in a real browser; all three web bind points enforce the same host/port invariants with strictPort on each; and the OAuth callback is loopback-only with message, help text, docs, and tests in agreement. AK1 is a pre-existing launcher gap that round 24's fix walked into — a defensible follow-up issue, though the fix is small enough to fold in.

Note: node_modules isn't installed in this checkout (root and all four clients absent), so I did not run the suite, validate, coverage, smoke, or a build — taking your reported green at face value. Everything asserted above was verified by running Node directly against logic copied verbatim from the current core/node/hostUrl.ts (the 36-spelling isLoopbackHost-vs-real-listen() sweep, the new URL().hostname results for each callback-URL form, and the localhost:6276 port-swallow), or by reading the actual call chain (launcher/src/index.tsrunCli, cli/src/index.ts's isMain guard, and handleError's single call site).
· branch v2/enforce-loopback-bind

…her (#1795)

Address the round-25 review of #1796:

- AK1 (medium-low, pre-existing but AJ1 walked into it): `mcp-inspector --cli`
  goes through the launcher, which imports runCli as a module — so the CLI bin's
  own .catch(handleError) never fires (isMain is false), and the launcher's
  top-level catch flattened EVERY failure to exit 1 with a plain-text message,
  losing the whole EXIT_CODES map and the JSON {"error":…} envelope the CLI
  README documents. Exported handleError from the CLI entry and routed the
  launcher's --cli branch through it, so exit codes (incl. AJ1's usage/exit-1 and
  auth_required/exit-3) and the envelope survive the documented invocation.
  New smoke:cli assertion drives the built launcher with a bad --callback-url and
  checks the JSON envelope + exit 1 (verified green).
- AK2: the TUI + launcher top-level sinks are catch-alls, not config boundaries —
  print err.message for readability but append err.stack under DEBUG / MCP_DEBUG
  so a real fault is still debuggable.
- AK3: note LOOPBACK_FRAME_ANCESTORS is a trusted constant that deliberately uses
  :* and is emitted as-is (doesn't pass CSP_HOST_SOURCE, by design).
- AK4a: doc isLoopbackHost / isAllInterfacesHost expect a bare host (no port).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
@cliffhall

Copy link
Copy Markdown
Member Author

Round-25 addressed in 4ff578c9.

AK1 — mcp-inspector --cli flattened all exit codes to 1 and dropped the JSON envelope (medium-low) — Fixed, and thanks — this was the real one: my AJ1 exit-code pin was invisible through the documented invocation, because the launcher imports runCli as a module (so the CLI bin's .catch(handleError) never fires) and its top-level catch flattened everything to exit 1 with a plain-text message. Exported handleError from the CLI entry and routed the launcher's --cli branch through it, so the EXIT_CODES map (AJ1's usage/exit-1, auth_required/exit-3, NO_APP/2, UNREACHABLE/4, TOOL_ERROR/5) and the {"error":…} envelope survive npx @modelcontextprotocol/inspector --cli. Web/tui stay on the generic sink. Verified end-to-end: added a smoke:cli assertion that drives the built launcher with a bad --callback-url and asserts the JSON envelope + exit 1 — it passes. So the clients/cli/README.md / docs/mcp-app-review.md exit-3/envelope contracts now hold under the launcher (AK4c resolved by fixing the launcher, not narrowing the docs).

AK2 — stack dropped at a catch-all, not a config boundary (nit) — Fixed. The TUI (clients/tui/index.ts) and launcher (clients/launcher/src/index.ts) top-level sinks still print err.message for readability, but now append err.stack when DEBUG or MCP_DEBUG is set — so an ESM-resolution failure or a deep TypeError is still locatable in a bug report, while a config error reads clean by default.

AK3 — LOOPBACK_FRAME_ANCESTORS fails its own CSP_HOST_SOURCE (nit) — Fixed the doc: it now states the filter applies to caller-supplied allowedOrigins only, and the fallback is a trusted constant that deliberately uses the :* port-wildcard and is emitted as-is (it does not pass the regex, by design) — so the two no longer read as contradictory.

AK4a — isLoopbackHost accepts a host-with-port (nit) — Documented (same resolution as AH1): both isLoopbackHost and isAllInterfacesHost now note they expect a bare host (no port), since every caller passes url.hostname or an env HOST. Bounding it would fight canonicalUrlHost's legitimate host:port parse.

AK4b — two launcher error prefixes (nit) — Left as-is: parse-launcher-argv's Error: (arg parse) vs. the top-level Error running MCP Inspector: are different phases; cosmetic.

Green locally: web + CLI + launcher test:coverage (all per-file ≥90; launcher gated files 100%), smoke:cli (the new launcher-envelope assertion), the sandbox suite (29), cli/launcher/tui typecheck, validate:core, format, eslint.

@cliffhall

Copy link
Copy Markdown
Member Author

@claude review

@claude

claude Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Claude finished @cliffhall's task in 5m 27s —— View job


Code review — PR #1796 (round 26, after 4ff578c9)

  • Gather context (full diff, 28 commits)
  • Verify the round-25 findings (AK1–AK4) landed
  • Fresh pass over the new code (launcher error routing, smoke assertion)
  • Review tests / docs
  • Post review

All four round-25 items landed, and AK1 landed correctly at the level that matters — I traced the whole chain rather than reading the commit message. clients/cli/src/index.ts:12 exports handleError; clients/launcher/src/index.ts:57-62 destructures it from the same dynamic import as runCli and calls it in the --cli catch; handleErrorformatErrorOutputclassifyErrorprocess.exit(exitCode) (error-handler.ts:212-216), so it terminates before the outer run().catch can flatten it. And every CliExitCodeError in cli.ts (:308/315/338/376/914/1051) is thrown rather than process.exited, so the full map — NO_APP 2, AUTH_REQUIRED 3, UNREACHABLE 4, TOOL_ERROR 5 — genuinely reaches the new sink. The short-circuit modes are unaffected: if (parsed.shortCircuit) return sits ~20 lines above the OAuth guard, so --print-handoff / --list-stored-auth never touch it.

The new smoke assertion is better than it first looks — it pins both of the last two rounds' fixes, and I checked each direction:

regression what fails
launcher routing removed (back to the generic catch-all) JSON.parse(stderr) throws → fail (status would still be 1, so the envelope check is the load-bearing one)
AJ1's CliExitCodeError(USAGE) removed "OAuth" re-matches the auth heuristic → exit 3 → status !== 1fail

docs/mcp-app-review.md:150's exit-3 claim and clients/cli/README.md:212/:244 are now true of npx @modelcontextprotocol/inspector --cli, which resolves AK4c by fixing the code rather than narrowing the docs. I also re-checked that the earlier smoke steps (3–6) still pass under the new JSON stderr: they're all regex.test(stderr) against messages with no characters JSON-escaping would alter.

# Round-25 finding Status
AK1 mcp-inspector --cli flattened exit codes / dropped the envelope ✅ routed through the CLI's own sink; smoke pins the envelope + AJ1's code
AK2 Stack dropped at a catch-all err.message by default, err.stack under DEBUG/MCP_DEBUG
AK3 LOOPBACK_FRAME_ANCESTORS fails its own CSP_HOST_SOURCE ✅ doc states it's a trusted constant emitted as-is
AK4a isLoopbackHost / isAllInterfacesHost accept a host-with-port ✅ documented as expecting a bare host
AK4b Two launcher error prefixes ✅ declined — different phases, agreed

No defects in the security surface. I re-read core/node/hostUrl.ts, resolve-bind-host.ts, web-server-config.ts's defaultAllowedOrigins, sandbox-controller.ts, and runner-oauth-callback.ts end to end; nothing changed there this round beyond comments, and the invariants hold as of round 25. Four nits below, all on the newest (error-reporting) surface.


AL1. A stale clients/cli/build makes the launcher replace the real CLI error with handleError is not a function (nit)

clients/launcher/src/index.ts:57 destructures handleError out of await import(clientEntry("cli")), which resolves to the sibling build directory (join(launcherDir, "..", "..", "cli", "build", "index.js")) — untyped, so nothing checks it exists. If that build predates 4ff578c9 (a dev running npm run build:launcher alone, or an interrupted npm run build), handleError is undefined, and calling it throws a TypeError inside the catch. That propagates to the outer run().catch, which prints:

Error running MCP Inspector: handleError is not a function

— the original CLI error is gone entirely. It's dev-only (the tarball ships both builds from one commit), but the failure mode is precisely the one AK1 set out to remove: an internal error standing in for the user's actual one. One line makes it degrade instead of mask:

if (typeof handleError !== "function") throw err; // stale cli build → generic sink
handleError(err);

Fix this →

AL2. The success half of the CLI contract still diverges from the direct bin (nit)

AK1 restored the failure contract; the success one is still asymmetric. clients/cli/src/index.ts:20-22 is runCli(...).then(() => process.exit(0)).catch(handleError) — an explicit exit. The launcher's --cli branch just awaits and lets run() resolve, so termination depends on the event loop draining. Any lingering handle (a keep-alive socket from an HTTP transport, an unclosed stdio child) hangs mcp-inspector --cli where node clients/cli/build/index.js would have exited.

Nothing lingers on the covered paths today — smoke step 1 (stdio) and step 8 (HTTP) both terminate — so this is an observation, not a bug. Worth being deliberate about rather than incidental, because the fix isn't free either: adding process.exit(0) inherits the direct bin's truncation risk (Node's process.stdout is asynchronous on pipes on macOS, so an immediate process.exit can cut a large tools/list payload). Either choice is defensible; a one-line comment saying which was chosen and why would stop the next reader from "fixing" it in the wrong direction.

AL3. Nothing in the gate exercises a non-1 exit code through the launcher (nit)

All four round-25 edits land in files outside the coverage gate — clients/launcher/src/index.ts and clients/cli/src/index.ts are documented exclude entries, and clients/tui/index.ts is outside the src/** include — so smoke:cli step 9 is their only guard. It's a good one (it pins the envelope and AJ1's classification, per the table above), but it exercises exit 1, which is also what the old catch-all produced. The claim AK1 actually makes — that EXIT_CODES survives the launcher — has no case behind it.

A second smoke case would close it cheaply, using fixtures already in the file: --use-stored-auth --server-url http://example.invalid/mcp with HOME pointed at the existing fakeHome throws CliExitCodeError(AUTH_REQUIRED, …, { code: "no_stored_token" }) (cli.ts:914) before any connect, so it's fast and offline — assert status === 3 and envelope.error.code === "no_stored_token".

Fix this →

AL4. Nits

  • DEBUG is truthy for DEBUG=0 (verified — any non-empty string), and it's the debug package's namespace filter, so a developer with DEBUG=someotherlib:* exported gets Inspector stacks on every launcher/TUI failure. Harmless and arguably intended for a debug flag; MCP_DEBUG alone would be unambiguous, and an isEnabled-style parse (the one resolve-bind-host.ts:24-27 already defines for DANGEROUSLY_*) would make DEBUG=0 read as off.
  • Two launcher error shapes now coexist, which is a step past AK4b's "different phases": parse-launcher-argv's catch prints Error: <msg>, the top-level prints Error running MCP Inspector: <msg>, and the --cli path prints a JSON envelope. All three are correct for their audience; the CLI one is worth a sentence in clients/launcher/README.md, whose flag table doesn't mention that --cli failures are machine-readable while --web/--tui failures are prose.
  • handleError in the launcher is implicitly any (dynamic-import destructure). Pre-existing for runWeb/runCli/runTui and not caught by no-explicit-any, so no action — noting only because AL1's guard is what a type would otherwise have given you.

Verdict: converged. Ship it. Twenty-six rounds on, I have no change to ask for in the PR's own subject, and the newest surface holds up too. The final state: the wildcard refusal covers both address families, the legacy inet_aton family (decimal/octal/hex, 1–4 parts), bracketed, trailing-dot, zone-scoped, IPv4-mapped, and IDNA/fullwidth spellings, with every escape failing loudly at listen(); the predicate reasons about the address the socket binds rather than the string typed; detection, listen(), the origin allow-list, the banner, the sandbox URL, the CLI deep-link, and both OAuth redirect builders consume one normalized value from one shared module in core/node/, with banner ⊆ allowedOrigins asserted as a property; the allow-list canonicalizes derived and typed inputs, fails closed on empty with no env knob to disable it, and warns-and-drops opaque, wildcard, and unparseable entries; frame-ancestors derives from the real embedder list with an IPv6 exclusion settled in a real browser; all three web bind points enforce the same host/port invariants with strictPort on each; the OAuth callback is loopback-only with message, help text, docs and tests in agreement; and its failure now reaches an automated caller as a usage error with the documented envelope through the documented invocation. AL1–AL4 are all a line or two each and none of them blocks.

Note: node_modules isn't installed in this checkout (root and all four clients absent), so I did not run the suite, validate, coverage, smoke, or a build — taking your reported green at face value. This round's conclusions come from tracing the actual call chain (launcher/src/index.tscli/build/index.jshandleErrorclassifyErrorprocess.exit, the shortCircuit return ordering in cli.ts, and the two smoke regression directions) plus a direct Node check of the DEBUG=0 truthiness and console.error(err.message) behavior in AL4/AK2.
· branch v2/enforce-loopback-bind

… docs (#1795)

Address the round-26 review of #1796 (converged; all nits):

- AL1: guard the launcher --cli catch against a stale cli/build where handleError
  is undefined — rethrow to the generic sink instead of masking the real error
  with "handleError is not a function".
- AL3: smoke:cli step 10 exercises a NON-1 exit code through the launcher
  (--use-stored-auth with no token → exit 3 / envelope code no_stored_token,
  offline), pinning that the whole EXIT_CODES map — not just exit 1 — survives
  the launcher. Verified green.
- AL2: comment on the launcher --cli success path (let the loop drain rather than
  process.exit(0), which risks truncating a large piped stdout on macOS).
- AL4a: DEBUG=0 / "false" / empty now read as off for the stack-on-debug gate
  (launcher + tui), via a small parse — DEBUG stays useful for the debug package.
- AL4b: launcher README documents that --cli failures are machine-readable JSON
  (exit-code map + envelope) while --web/--tui are prose.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
@cliffhall

Copy link
Copy Markdown
Member Author

Round-26 addressed in c4392c73.

AL1 — stale cli/build would mask the real error (nit) — Fixed. The launcher's --cli catch now if (typeof handleError !== "function") throw err; before calling it, so a cli/build predating the handleError export falls through to the generic sink with the user's real message instead of throwing handleError is not a function over it. Dev-only, but it was exactly the mask AK1 removed.

AL3 — smoke only pinned exit 1 (nit) — Fixed, and you're right it was the load-bearing gap: exit 1 is also what the old catch-all produced, so nothing proved the map survives. Added smoke:cli step 10 — --use-stored-auth against http://example.invalid/mcp with HOME at the existing fakeHome throws AUTH_REQUIRED before any connect (offline, fast) — asserting exit 3 and envelope code no_stored_token. Verified green. So the smoke now pins a non-1 code and its envelope code through the launcher.

AL2 — success-path asymmetry (nit) — Documented the choice inline: the launcher awaits and lets the loop drain rather than process.exit(0), because the CLI closes its own transports and an eager exit risks truncating a large piped stdout (async on macOS) — the direct bin's known trade-off, chosen deliberately here.

AL4a — DEBUG=0 truthy (nit) — Fixed. Both the launcher and TUI sinks now gate the stack on a small parse where "0" / "false" / empty read as off (MCP_DEBUG or DEBUG), so a stray DEBUG=0 doesn't force a stack, while DEBUG stays a meaningful toggle.

AL4b — launcher README (nit) — Added an "Error reporting" note: --cli failures preserve the exit-code map + JSON {"error":…} envelope; --web/--tui print Error: <message> and exit 1 (MCP_DEBUG=1 for the stack).

AL4c — handleError implicitly any — Left as-is per your note: pre-existing for the dynamic-import destructures, and AL1's runtime guard is what a type would have bought.

Green locally: validate:core, launcher test:coverage (index.ts is the documented exclusion; gated files 100%), smoke:cli (steps 9 + 10 — usage→envelope+exit 1, auth→envelope+exit 3), launcher/tui typecheck + eslint, format. (Web/CLI gated files are unchanged from round 25, so their gates stand.)

@cliffhall

Copy link
Copy Markdown
Member Author

@claude review

@claude

claude Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Claude finished @cliffhall's task in 4m 38s —— View job


Code review — PR #1796 (round 27, after c4392c73)

  • Gather context (full diff, 29 commits)
  • Verify the round-26 findings (AL1–AL4) landed
  • Fresh pass over the code (traced the OAuth callback bind path to every listen(); re-read the guard, allow-list, CSP)
  • Review tests / docs
  • Post review

All four round-26 items landed. AL1's guard is placed correctly — the destructure const { runCli, handleError } = await import(…) leaves handleError undefined on a stale build, and typeof handleError !== "function" → throw err sends the real message to the generic sink instead of masking it. AL3's step 10 is the right shape: --use-stored-auth throws CliExitCodeError(AUTH_REQUIRED, …, { code: "no_stored_token" }) at cli.ts:914, which is inside parseArgs and therefore before loadServerEntries — genuinely offline — and oauthStatePath comes from getStateFilePath() called at parse time, so the HOME: fakeHome override actually takes effect in the child. AL4a's parse reads correctly (" " → off, "FALSE" → off, "0" → off).

# Round-26 finding Status
AL1 Stale cli/build masked the real error typeof guard + comment
AL2 Success-path asymmetry ✅ documented as a deliberate choice (see AM3)
AL3 Smoke only pinned exit 1 ✅ step 10 pins exit 3 + envelope code no_stored_token
AL4a DEBUG=0 truthy ✅ launcher + tui (see AM2)
AL4b Launcher README ✅ names the --cli vs --web/--tui split
AL4c handleError implicitly any ✅ declined — AL1's runtime guard covers it

No defects. I re-derived one thing rather than inheriting it: the OAuth callback listener has exactly one hostname source. OAuthCallbackServer.start({ hostname }) (oauth-callback-server.ts:98) is reached only from runner-interactive-oauth.ts:64-67 via options.callbackListen, whose only producer is parseRunnerOAuthCallbackUrl (cli.ts:1049, tui.tsx:104) — so the round-22 loopback guard covers the credential listener with no second door. I also confirmed the TUI's call sits before Ink renders (line 104, above the process.stdout.write patch and the render()), so a refusal exits cleanly instead of leaving the terminal in raw mode.

Four nits, all outside the security surface. Nothing blocks.


AM1. smoke-cli.mjs's docblock enumerates checks 1–8; the two that guard the newest contract aren't listed (nit)

The file header (scripts/smoke-cli.mjs:11-25) is a numbered list of what the smoke asserts, and it stops at 8 — steps 9 (usage error → envelope + exit 1) and 10 (auth error → envelope + exit 3) aren't there. That matters more than usual here: clients/launcher/src/index.ts and clients/cli/src/index.ts are both coverage-excluded, so those two steps are the only automated guard on the AK1/AL3 contract, and this docblock is where a reader learns they exist. Two lines, matching the existing style.

Fix this →

AM2. Three env-boolean parsers now, two truth tables (nit)

AL4a produced a fourth-and-fifth copy of "is this env var on":

location accepts rejects
resolve-bind-host.ts:24 isEnabled true, 1 only everything else (incl. yes, on)
launcher/src/index.ts:22 wantsDebugStack any non-empty except 0 / false
tui/index.ts:23 debugOn (verbatim copy of the above)

The two semantics are each right for their job (a DANGEROUSLY_* opt-in should be strict; a DEBUG flag should be permissive because the debug package treats any non-empty value as on), so this isn't a bug. What's worth a line is that debugOn is a byte-for-byte duplicate of wantsDebugStack across two clients that can't import each other — and the launcher's version already carries the explanatory comment the TUI's lacks. If a third client ever grows a top-level sink, this becomes the shape that drifts.

AM3. AL2's new comment and handleError reason oppositely about the same hazard (nit, non-actionable)

The comment added at launcher/src/index.ts:73-75 says the success path deliberately avoids process.exit(0) because "an eager exit risks truncating a large stdout payload on a pipe (async on macOS)". Eight lines down, the failure path calls handleError, which is exactly process.stderr.write(stderr); process.exit(exitCode) (error-handler.ts:213-215) — the same write-then-exit shape on the same class of stream. It's fine in practice (the envelope is a few hundred bytes, well inside the 64 KB pipe buffer) and it's the direct bin's long-standing behavior, so I'd change nothing — but the two comments now sit in the same function asserting different things about the same risk, which is the kind of thing that reads as an oversight later. Half a clause on the handleError call ("small payload, fits the pipe buffer") would settle it.

AM4. smoke-cli.mjs inherits ambient env, and the round-24 guard made one of those vars fatal for every step (nit)

runCli in the smoke spreads ...process.env with no scrubbing. Since parseRunnerOAuthCallbackUrl runs unconditionally in runCli (cli.ts:1049, the "not flag-only" point from AJ1), a developer with MCP_OAUTH_CALLBACK_URL=http://0.0.0.0:6276/oauth/callback exported now fails steps 1–8 — none of which are about OAuth — with a loopback-guard message. The sibling harness already solves this: scripts/lib/prod-web-server.mjs:37-42 pins HOST / CLIENT_PORT explicitly over the inherited env, which is why smoke:web is immune to an ambient HOST=0.0.0.0. Adding MCP_OAUTH_CALLBACK_URL: "" to runCli/runCliAsync's env (empty reads as unset in the parser) gives smoke:cli the same immunity in one line.

Fix this →

AM5. Nit

  • clients/cli/__tests__/stored-auth.test.ts — the new AJ1 case asserts expect(envelope.error.code).toBe("error") and then expect(envelope.error.code).not.toBe("auth_required"). The second is implied by the first; it reads as documentation of intent, which is fine, but a comment would say it more cheaply than a redundant assertion.

Verdict: converged. Ship it. Twenty-seven rounds on, I have no code change to ask for, and the two things I chose to re-derive from scratch this round both held. The final state:

  • The wildcard refusal covers both address families, the full legacy inet_aton family (decimal/octal/hex, 1–4 parts), bracketed, trailing-dot, zone-scoped, IPv4-mapped, and IDNA/fullwidth spellings — with every escape failing loudly at listen() — and the predicate reasons about the address the socket binds, not the string typed.
  • Detection, listen(), the origin allow-list, the banner, the sandbox URL, the CLI deep-link, and both OAuth redirect builders consume one normalized value from one shared module in core/node/, with banner ⊆ allowedOrigins asserted as a property rather than a string.
  • The allow-list canonicalizes derived and typed inputs the way a browser does, fails closed on empty with no env knob to disable it, and warns-and-drops opaque, wildcard, and unparseable entries.
  • frame-ancestors derives from the real embedder list, with an IPv6 exclusion settled in a real browser rather than read off the CSP grammar.
  • All three web bind points enforce the same host and port invariants from one validated config object, with strictPort on each.
  • The OAuth callback — the credential listener — is loopback-only through its single hostname source, with message, --help text, READMEs, and tests in agreement; and its failure reaches an automated caller as a usage error with the documented exit code and JSON envelope through the documented mcp-inspector --cli invocation.

AM1–AM5 are all a line or two each and none of them touches behavior that matters.

Note: node_modules isn't installed in this checkout (root and all four clients absent), so I did not run the suite, validate, coverage, smoke, or a build — taking your reported green at face value. This round's conclusions come from reading the actual call chains: OAuthCallbackServer.startrunner-interactive-oauthRunnerOAuthCallbackConfigparseRunnerOAuthCallbackUrl (the only producer, both clients); parseArgs's --use-stored-auth throw at cli.ts:914 preceding loadServerEntries at :935; getStateFilePath() being call-time not load-time; the tui.tsx:104 parse preceding the Ink render; and prod-web-server.mjs's explicit HOST/CLIENT_PORT pinning versus smoke-cli.mjs's bare ...process.env.
· branch v2/enforce-loopback-bind

Address the round-27 review of #1796 (converged; all nits):

- AM4: pin MCP_OAUTH_CALLBACK_URL="" in smoke-cli's runCli/runCliAsync base env,
  so an ambient non-loopback value can't fail steps 1–8 (parseRunnerOAuthCallbackUrl
  runs on every --cli invocation). Mirrors prod-web-server.mjs's HOST pin. Verified
  smoke:cli still passes with a hostile MCP_OAUTH_CALLBACK_URL=http://0.0.0.0 exported.
- AM1: smoke-cli docblock now lists steps 9 + 10 (the only guard on the AK1/AL3
  launcher exit-code/envelope contract, since the bins are coverage-excluded).
- AM3: note on the handleError call that its small envelope fits the pipe buffer,
  so AL2's large-stdout truncation caveat doesn't apply to it.
- AM2: note on the tui debugOn copy that it mirrors the launcher's wantsDebugStack
  (the two bins can't import each other).
- AM5: replaced the redundant `.not.toBe("auth_required")` assertion with a comment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
@cliffhall

Copy link
Copy Markdown
Member Author

Round-27 addressed in b52e3763 — all five nits.

AM4 — smoke inherited a now-fatal ambient var (nit) — Fixed, good catch. parseRunnerOAuthCallbackUrl runs on every --cli invocation, so an exported non-loopback MCP_OAUTH_CALLBACK_URL would have failed the OAuth-unrelated steps 1–8. Pinned MCP_OAUTH_CALLBACK_URL: "" in runCli/runCliAsync's base env (empty reads as unset → default 127.0.0.1:6276), with per-call extraEnv still spread after so step 9 passes --callback-url explicitly. Same shape as prod-web-server.mjs's HOST pin. Verified: MCP_OAUTH_CALLBACK_URL=http://0.0.0.0:6276/oauth/callback npm run smoke:cli now passes.

AM1 — docblock stopped at step 8 (nit) — Fixed. Added steps 9 and 10 to the header list, and noted explicitly that they're the only guard on the AK1/AL3 contract since clients/{launcher,cli}/src/index.ts are coverage-excluded — so a reader knows where that contract is tested.

AM3 — AL2's comment vs handleError reasoned oppositely (nit) — Settled with a clause on the handleError call: it's write-then-exit like the success path deliberately avoids, but the envelope is a few hundred bytes (well inside the pipe buffer), so the large-stdout truncation risk doesn't apply here.

AM2 — debugOn is a copy of wantsDebugStack (nit) — Added a note on the TUI copy that it mirrors the launcher's helper and to keep them in sync (the two bins can't import each other; I left both semantics as-is since they're each right — strict for DANGEROUSLY_*, permissive for DEBUG).

AM5 — redundant assertion (nit) — Replaced expect(...).not.toBe("auth_required") with a comment; the toBe("error") already covers it, and the comment states the intent (pinning the exit-code class) more cheaply.

Green locally: smoke:cli (10 steps, and re-verified immune to a hostile ambient MCP_OAUTH_CALLBACK_URL), the CLI handoff/exit-code suite (35), cli/tui typecheck, validate:core, format, eslint. (All round-27 edits are in non-gated files — smoke, comments, the two coverage-excluded bins, and a test assertion→comment — so the web/CLI/launcher gates from the prior rounds stand.)

@cliffhall

Copy link
Copy Markdown
Member Author

@claude review

@claude

claude Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Claude finished @cliffhall's task in 7m 3s —— View job


Code review — PR #1796 (round 28, after b52e3763)

  • Gather context (full diff, 30 commits)
  • Verify the round-27 nits (AM1–AM5) landed
  • Fresh pass over the code (independent flag-vs-listen() audit; dead-code probe of the wildcard cover)
  • Review tests / docs
  • Post review

All five round-27 items landed. AM4's SMOKE_BASE_ENV pin is placed correctly — the base spread precedes ...extraEnv, so step 9 can still pass --callback-url explicitly while steps 1–8 are immune to an ambient MCP_OAUTH_CALLBACK_URL. AM1's docblock now names steps 9–10 and says why they matter (the two bins are coverage-excluded), which is the part a future reader needs.

# Round-27 finding Status
AM4 Smoke inherited a now-fatal ambient var MCP_OAUTH_CALLBACK_URL: "" pinned in both helpers
AM1 Docblock stopped at step 8 ✅ steps 9–10 + the coverage-exclusion note
AM3 AL2 comment vs. handleError reasoned oppositely ✅ clause added on the handleError call
AM2 debugOn duplicates wantsDebugStack ✅ cross-referenced with a keep-in-sync note
AM5 Redundant assertion ✅ replaced with a comment

I re-ran the security audit from scratch rather than inheriting it. 44 host spellings through the current core/node/hostUrl.ts logic, each compared against a real http.listen(): zero mismatches in either direction — everything that binds 0.0.0.0/:: is flagged wildcard, everything that binds loopback is flagged loopback, and every unflagged spelling either binds a genuinely distinct address or fails loudly (ENOTFOUND / EADDRNOTAVAIL). Two spellings I hadn't seen tried in earlier rounds also came out right: HOST=𝟎 (U+1D7CE, mathematical bold digit zero) and HOST=0。0。0。0 (U+3002 ideographic full stop) both bind 0.0.0.0 and are flagged — so AD1's canonicalize-first fix generalizes past the fullwidth forms it was written for, rather than enumerating them. I also confirmed new URL().hostname percent-decodes (http://%31%32%37.0.0.1:6276/…127.0.0.1), so the OAuth guard can't be fed an encoded host it accepts but can't bind. Also traced parsed.shortCircuit (cli.ts:1028) preceding the OAuth parse (:1049), and --use-stored-auth's throw (:914, inside parseArgs) preceding any connect — so smoke step 10 really is offline.

Two findings, both non-blocking; neither is a security defect.


AP1. Three pieces of the wildcard cover became dead when AD1 canonicalized inside the predicate — and their comments still claim they're load-bearing (low)

Round 19 (AD1) changed isAllInterfacesHost to run canonicalUrlHost(host) first. That was the right fix, but canonicalUrlHost does everything the downstream helpers were written to do, so three of them no longer fire. I measured this rather than reasoned it — instrumented the real pipeline and counted which sub-check produced the verdict, over 27 wildcard spellings plus 125 generated IPv6 forms:

element claimed job times it changed the verdict
canonicalizeIpv6 (hostUrl.ts:96) compress ::0 / 0::0 / 0000:…:: 0 — always identity (new URL() in canonicalUrlHost already compressed)
"::ffff:0:0" in ALL_INTERFACES_LITERALS (:82) catch the IPv4-mapped wildcard 0canonicalUrlHost unmaps it to 0.0.0.0 first, so ::ffff:0:0 can never be the normalized value
isAllZeroIpv4 (:125) "guards the near-miss bypasses of the literal set" 0 for any bindable input0, 0x0, 0.0, 0.0.0, 000.000.000.000, 00000000000, 0x00000000, all canonicalize to exactly 0.0.0.0

isAllZeroIpv4 isn't fully dead — it's the only thing that flags HOST="[0.0.0]" (bracketed non-IPv6 → canonicalUrlHost throws → catch → 0.0.0), but that value fails ENOTFOUND at listen() anyway. So all three are belt-and-braces now, not the cover.

Nothing is broken and I'd keep the defense-in-depth. What's worth fixing is the comments, which read as a specification of how the guard works: ALL_INTERFACES_LITERALS's doc says the two IPv6 entries "cover the whole IPv6 family (see canonicalizeIpv6)", and isAllZeroIpv4's says it "guards the near-miss bypasses" — a reader (or a future refactor) will conclude that deleting canonicalUrlHost from line 145 is safe because those still catch the legacy spellings. They don't; that call is now the entire cover. This is the same class as U3a (round 12's dead "::1" in LOOPBACK_HOSTNAMES), which was resolved by deleting the entry — here I'd keep the code and correct the prose, since one of the three still has a live (if unbindable) input.

Fix this →

AP2. ALLOWED_ORIGINS replaces the default list, so the network-hosting recipe the new docs give silently breaks local access (low)

web-server-config.ts:398 is configuredOrigins?.length ? configuredOrigins : defaultAllowedOrigins(...) — an explicit list wins outright, it isn't merged. That's a defensible design, but it interacts badly with the two places this PR newly tells users to set the var:

  • clients/web/README.md, wildcard bullet: "local access works out of the box … Reaching it at a non-loopback address still needs ALLOWED_ORIGINS: ALLOWED_ORIGINS=http://192.168.1.50:PORT,https://inspector.example.com." Following that exact line removes the loopback trio, so http://localhost:6274 — the access the same paragraph just promised works out of the box, and the URL the Docker banner prints (H2's fix) — starts 403-ing. And since round 3's C4, the derived frame-ancestors follows the same list, so MCP Apps breaks on loopback too.
  • Root README.md, Docker port-remap: -e ALLOWED_ORIGINS=http://localhost:8080 is fine as written (the browser is at localhost:8080), but it inherits the same trap the moment anyone adds a second origin.

The failure is silent and self-inflicted-looking: the user added an origin and lost a different one. Either merge the defaults into a configured list (adding loopback origins weakens nothing — no attacker document can claim them), or add one clause to the README: ALLOWED_ORIGINS replaces the default list rather than adding to it, so include every origin you'll browse from, including the loopback forms.

Fix this →

AP3. Nits

  • isLoopbackHost's negative it.each has no empty-string case. isLoopbackHost("") is false (correct), and the OAuth path guards !hostname separately, but "" is the one value the sibling predicate treats as the wildcard — pinning it here would make the two suites say the same thing about the same input.
  • MCP_DEBUG=1 prints no stack on the --cli path. handleError exits before the launcher's top-level catch can consult wantsDebugStack(), so the debug affordance covers --web/--tui only. That matches the direct bin and the launcher README scopes the claim correctly (--web/--tui … append MCP_DEBUG=1), so nothing is wrong — noting it because "the stack is still available under DEBUG / MCP_DEBUG for real faults" (launcher/src/index.ts:96) sits ten lines below a branch where it isn't.
  • formatHostForUrl preserves case while canonicalUrlHost lowercases — a known, doc-noted boundary (round 15 F4), unchanged. The only remaining formatHostForUrl-not-canonicalUrlHost call sites are server.ts:136's EADDRINUSE message (deliberate, AA3) and the two OAuth redirect builders (correct — a redirect URI must echo what was registered, not a re-canonicalized form).

Verdict: converged. Ship it. Twenty-eight rounds on, I have no code change to ask for, and the two properties I chose to re-derive independently this round both held — the wildcard cover survived a fresh 44-spelling flag-vs-real-listen() audit including two Unicode forms nobody had tried, and the guard's single hostname source into the OAuth listener is unchanged. The final state:

  • The wildcard refusal covers both address families, the full legacy inet_aton family (decimal/octal/hex, 1–4 parts), bracketed, trailing-dot, zone-scoped, IPv4-mapped, and IDNA/Unicode spellings — with every escape failing loudly at listen() — because the predicate reasons about the address the socket binds, not the string typed.
  • Detection, listen(), the origin allow-list, the banner, the sandbox URL, the CLI deep-link, and both OAuth redirect builders consume one normalized value from one shared module in core/node/, with banner ⊆ allowedOrigins asserted as a property rather than a string.
  • The allow-list canonicalizes derived and typed inputs the way a browser does, fails closed on empty with no env knob to disable it, and warns-and-drops opaque, wildcard, and unparseable entries.
  • frame-ancestors derives from the real embedder list, with an IPv6 exclusion settled in a real browser rather than read off the CSP grammar.
  • All three web bind points enforce the same host and port invariants from one validated config object, with strictPort on each.
  • The OAuth callback — the credential listener — is loopback-only through its single hostname source, with message, --help text, READMEs, and tests in agreement; and its failure reaches an automated caller as a usage error with the documented exit code and JSON envelope through the documented mcp-inspector --cli invocation, now pinned by two smoke steps covering both an exit-1 and a non-1 code.

AP1 is comment-only; AP2 is a doc clause (or a small merge). Neither blocks.

Note: node_modules isn't installed in this checkout (root and all four clients absent), so I did not run the suite, validate, coverage, smoke, or a build — taking your reported green at face value. Everything asserted above was verified by running Node directly against logic copied verbatim from the current core/node/hostUrl.ts: the 44-spelling flag-vs-listen() audit, the instrumented sub-check counts behind AP1 (27 wildcard spellings + 125 generated IPv6 forms, 0 hits for all three elements), and the new URL().hostname percent-decode / trailing-dot results. AP2 is established by reading web-server-config.ts:398 against createOriginMiddleware's exact-match check and sandboxFrameAncestors's input.
· branch v2/enforce-loopback-bind

…ces doc (#1795)

Address the round-28 review of #1796 (converged; both non-blocking):

- AP1 (comment-only): since AD1 (round 19) made isAllInterfacesHost run
  canonicalUrlHost FIRST, that call is the actual wildcard cover — canonicalizeIpv6,
  the ::ffff:0:0 literal, and isAllZeroIpv4 became a redundant second layer that
  (measured) changes no verdict for any bindable input. Corrected the comments so
  they no longer read as the cover, with an explicit "do NOT drop the
  canonicalUrlHost call" note so a future refactor doesn't regress. Kept the code
  as defense-in-depth (it does cover canonicalUrlHost's non-URL catch path).
- AP2: ALLOWED_ORIGINS REPLACES the default list (exact-match, no merge), so the
  network-hosting recipe that set only a LAN origin silently dropped the loopback
  trio — breaking http://localhost (which the same paragraph/Docker banner
  promises) and the derived sandbox CSP. Documented "replaces, not merges — list
  every origin incl. loopback" and fixed the wildcard-bullet example to keep the
  loopback forms.
- AP3a: isLoopbackHost negative test now pins "" (the wildcard's job, not loopback).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
@cliffhall

Copy link
Copy Markdown
Member Author

Round-28 addressed in 83a2dfe9.

AP1 — dead-cover comments (low, comment-only) — Fixed. You're right that AD1 (round 19) made canonicalUrlHost the actual wildcard cover, leaving canonicalizeIpv6, the ::ffff:0:0 literal, and isAllZeroIpv4 as a redundant second layer. I kept the code (defense-in-depth, and it does still cover canonicalUrlHost's non-URL catch fallback), but corrected the prose so it no longer reads as the cover: a layering note above ALL_INTERFACES_LITERALS states canonicalUrlHost is now the cover and explicitly says not to drop that call on the assumption these still catch the legacy spellings — the U3a class you flagged, resolved by fixing the comment since one element still has a live (unbindable) input.

AP2 — ALLOWED_ORIGINS replaces rather than merges (low) — This was a real trap in my own docs — following the network-hosting recipe (ALLOWED_ORIGINS=http://192.168.1.50:PORT,…) drops the loopback trio, so http://localhost:6274 (which the same paragraph and the Docker banner promise) starts 403ing, and the derived frame-ancestors breaks MCP Apps on loopback too. I chose the doc fix over merging (replace-semantics lets a hardened deployment legitimately exclude loopback, which a forced merge would take away): the allow-list paragraph now says ALLOWED_ORIGINS replaces the default (list every origin incl. the loopback forms or local access stops working), and the wildcard-bullet example now keeps http://localhost:PORT,http://127.0.0.1:PORT in the list.

AP3a — empty-string isLoopbackHost case (nit) — Added "" to the negatives, so both sibling predicates' suites pin the same input (isAllInterfacesHost flags it, isLoopbackHost doesn't).

AP3b/AP3c — MCP_DEBUG on --cli, formatHostForUrl case (noted) — No change: both are correct as-is and scoped correctly in the docs (the --cli path exits through handleError before the debug affordance, and the launcher README scopes the MCP_DEBUG=1 claim to --web/--tui; formatHostForUrl's case-preservation is the round-15 F4 boundary, and its only non-canonical call sites — the EADDRINUSE message and the OAuth redirect builders — are deliberate).

Green locally: web test:coverage (per-file ≥90; hostUrl.ts 100% — AP1 is comment-only, AP3a adds a negative case), the hostUrl suite (91), tsc -b, validate:core, format, eslint. (AP2 is README-only.)

@cliffhall

Copy link
Copy Markdown
Member Author

@claude review

@claude

claude Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Claude finished @cliffhall's task in 6m 1s —— View job


Code review — PR #1796 (round 29, after 83a2dfe9)

  • Gather context (full diff, 31 commits)
  • Verify the round-28 findings (AP1–AP3) landed
  • Fresh pass over the code (independent 31-spelling flag-vs-listen() audit; 19-URL OAuth-guard accept-vs-bind audit)
  • Review tests / docs
  • Post review

All three round-28 items landed. AP1's layering note is the right resolution — keeping the redundant layer as defense-in-depth while stating plainly that canonicalUrlHost is the cover, with an explicit "do NOT remove this call" warning, is exactly what stops a future tidy-up from re-opening the bypass class. AP2's "replaces, not merges" clause closes the trap in both web-README bullets.

# Round-28 finding Status
AP1 Dead-cover comments claimed load-bearing ✅ layering note + all three doc comments corrected
AP2 ALLOWED_ORIGINS replaces the default ✅ documented; wildcard-bullet example now keeps the loopback forms (see AQ2 for the third recipe)
AP3a isLoopbackHost empty-string negative ✅ pinned

The audits I ran this round (not inherited)

I copied the current core/node/hostUrl.ts logic verbatim into a standalone module and compared its verdicts against a real http.listen(), deliberately choosing spellings none of the prior 28 rounds tried:

Wildcard cover — 31 exotic spellings, zero mismatches. The interesting positives, all of which genuinely bind 0.0.0.0 and are all flagged:

HOST codepoint binds flagged
𝟎 / 𝟢 U+1D7CE / U+1D7E2 (mathematical digit zero) 0.0.0.0
0 0 + U+200B zero-width space 0.0.0.0
0 0 + U+FEFF BOM 0.0.0.0
0。0。0。0 U+3002 ideographic full stop 0.0.0.0
0x0000000000000000000 / 0000000000000000000000 overlong hex/decimal 0.0.0.0
0:0:0:0:0:0:0.0.0.0 IPv4-embedded IPv6 wildcard ::

And the correct negatives: Arabic-Indic ٠ / Extended-Arabic ۰ are not IDNA-mapped to ASCII digits (they punycode to xn--8hb / xn--dmb and don't bind), 4294967295 binds 255.255.255.255, ::ffff:0:0:0 is EADDRNOTAVAIL — none flagged. This is the payoff of AD1's canonicalize-first shape: it generalizes to Unicode forms nobody enumerated, rather than covering the three fullwidth spellings that motivated it.

OAuth loopback guard — 19 callback URLs through the real path (new URL(...).hostnameisLoopbackHoststripBracketslisten()): no false accepts. 127.1, 0177.0.0.1, 2130706433, 017777777777, [0:0:0:0:0:0:0:1], [::ffff:127.0.0.1], localhost., 127.0.0.1., and percent-encoded %31%32%37.0.0.1 all accept and bind loopback; xn--localhost-. correctly rejects.

I also re-verified: handleErrorformatErrorOutputclassifyErrorprocess.exit(exitCode) terminates before the launcher's catch-all; smoke step 10's no_stored_token throw (cli.ts:920) precedes loadServerEntries (:935), so it really is offline; fakeHome is in scope at smoke-cli.mjs:469; and the launcher README's exit-code enumeration (1/2/3/4/5) matches EXIT_CODES exactly.

No code defects. Two nits, both one-liners, both outside the security logic.


AQ1. smoke:tui didn't get AM4's env pin — the same ambient var breaks it (nit)

Round 27 fixed smoke-cli.mjs by pinning MCP_OAUTH_CALLBACK_URL: "" in SMOKE_BASE_ENV, because parseRunnerOAuthCallbackUrl runs unconditionally on every --cli invocation. The TUI has the same shape and didn't get the same treatment:

  • clients/tui/tui.tsx:104parseRunnerOAuthCallbackUrl(options.callbackUrl) runs after loadTuiServers and before the Ink render().
  • scripts/smoke-tui.mjs:93env: { ...process.env, HOME: work, USERPROFILE: work }, no scrub.

So a developer with MCP_OAUTH_CALLBACK_URL=http://0.0.0.0:6276/oauth/callback exported fails smoke:tui with a crash-before-render, from a guard that has nothing to do with what the smoke asserts. Strictly a developer paper cut — smoke:tui self-skips under CI (needs a TTY) — but it's the one remaining spawn site of the class AM4 named, and the fix is the same line.

Fix this →

AQ2. AP2's "replaces, not merges" clause didn't reach the third ALLOWED_ORIGINS recipe (nit)

Round 28 fixed the two clients/web/README.md bullets that hand out an ALLOWED_ORIGINS value. The root README.md Docker paragraph is the third, and still reads:

If you remap the published port (-p 8080:6274) … set -e ALLOWED_ORIGINS=http://localhost:8080 … or connects will 403.

Under the replace semantics that value is the whole allow-list, so http://127.0.0.1:8080 — which the web README two files over calls interchangeable with localhost, and which is the form docs/mcp-app-review.md tells reviewers to use — now 403s, and the derived frame-ancestors becomes http://localhost:8080 alone, blocking the Apps iframe for a 127.0.0.1 embedder. Same trap AP2 just closed, one recipe over. Listing both forms (-e ALLOWED_ORIGINS=http://localhost:8080,http://127.0.0.1:8080) fixes it in place.

Fix this →

AQ3. Observations, no action

  • A specific-IPv6 bind makes the CSP and the connect guard disagree in direction. HOST=2001:db8::1allowedOrigins is the single http://[2001:db8::1]:6274, which CSP_HOST_SOURCE drops (bracketed IPv6), so frame-ancestors falls back to the any-port loopback family — broader than the allow-list, and it doesn't admit the origin the app is actually served on, so Apps is blocked there. Both halves are correct-by-design (the fallback direction is H4b's deliberate choice; the IPv6-literal limit is F2's browser-verified finding and is documented in the MCP Apps caveat). Naming the combination only so it isn't rediscovered as a bug.
  • resolveBindHostname returns the typed spelling, not the canonical address (HOST=0 + opt-in → "0"). Correct — listen() resolves it, and every downstream consumer canonicalizes independently — but the docstring's "detection, listen(), and the origin list all consume the same value" means the same string, with canonicalization happening per-consumer, not that the returned value is canonical. Only reachable behind the opt-in.
  • smoke:cli step 9/10's fail()-inside-catch around JSON.parse is the good shape (fail exits), and step 9 pins both AK1's routing and AJ1's classification — deleting either regresses it. Nice.

Verdict: converged. Ship it. Twenty-nine rounds on, I have no code change to ask for, and the two properties I chose to re-derive from scratch — the wildcard cover and the OAuth loopback guard — both survived spellings that hadn't been tried before, which is the strongest signal available that the cover is structural rather than enumerated. The final state:

  • The wildcard refusal covers both address families, the full legacy inet_aton family (decimal/octal/hex, 1–4 parts), bracketed, trailing-dot, zone-scoped, IPv4-mapped, and arbitrary IDNA/Unicode spellings — with every escape failing loudly at listen() — because the predicate reasons about the address the socket binds, not the string typed.
  • Detection, listen(), the origin allow-list, the banner, the sandbox URL, the CLI deep-link, and both OAuth redirect builders consume one normalized value from one shared module in core/node/, with banner ⊆ allowedOrigins asserted as a property rather than a string.
  • The allow-list canonicalizes derived and typed inputs the way a browser does, fails closed on empty with no env knob to disable it, and warns-and-drops opaque, wildcard, and unparseable entries.
  • frame-ancestors derives from the real embedder list, with an IPv6 exclusion settled in a real browser rather than read off the CSP grammar.
  • All three web bind points enforce the same host and port invariants from one validated config object, with strictPort on each.
  • The OAuth callback — the credential listener — is loopback-only through its single hostname source, with message, --help text, READMEs, and tests in agreement; and its failure reaches an automated caller as a usage error with the documented exit code and JSON envelope through the documented mcp-inspector --cli invocation, pinned by two smoke steps covering both an exit-1 and a non-1 code.

AQ1 and AQ2 are each a single line and neither blocks.

Note: node_modules isn't installed in this checkout (root and all four clients absent), so I did not run the suite, validate, coverage, smoke, or a build — taking your reported green at face value. Everything asserted above was verified by running Node directly against logic copied verbatim from the current core/node/hostUrl.ts (the 31-spelling flag-vs-listen() audit and the 19-URL OAuth accept-vs-bind audit), or by reading the actual call chains (tui.tsx:104 preceding render(); smoke-tui.mjs:93's spawn env; cli.ts:920 preceding :935; EXIT_CODES vs. the launcher README).
· branch v2/enforce-loopback-bind

…cipe (#1795)

Address the round-29 review of #1796 (converged; two one-liners):

- AQ1: smoke-tui.mjs pins MCP_OAUTH_CALLBACK_URL="" like smoke-cli's SMOKE_BASE_ENV
  (round 27 AM4) — parseRunnerOAuthCallbackUrl runs before the TUI's Ink render,
  so an ambient non-loopback value would crash the smoke before render.
- AQ2: the root README Docker port-remap recipe now lists both loopback forms
  (http://localhost:8080,http://127.0.0.1:8080) and notes ALLOWED_ORIGINS replaces
  the default — the third recipe AP2's clause hadn't reached.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
@cliffhall

Copy link
Copy Markdown
Member Author

Round-29 addressed in 13a825c1 — both one-liners; thanks for the exhaustive audits.

AQ1 — smoke:tui missed AM4's env-pin (nit) — Fixed. smoke-tui.mjs now pins MCP_OAUTH_CALLBACK_URL: "" in the spawn env (empty reads as unset), with a comment cross-referencing smoke-cli.mjs's SMOKE_BASE_ENV — so an ambient non-loopback value can't crash the TUI before render via the loopback callback guard. It was the last spawn site of the class AM4 named.

AQ2 — root README Docker recipe missed AP2's clause (nit) — Fixed. The port-remap recipe now sets -e ALLOWED_ORIGINS=http://localhost:8080,http://127.0.0.1:8080 and states ALLOWED_ORIGINS replaces the default (cross-referencing the web README allow-list section) — so http://127.0.0.1:8080 doesn't 403 and the derived frame-ancestors admits a 127.0.0.1 embedder. That's the third and last ALLOWED_ORIGINS recipe.

AQ3 — observations, no action — All three are correct-by-design as you noted: the specific-IPv6-bind CSP/connect direction split is H4b's fallback + F2's documented IPv6-literal limit; resolveBindHostname returning the typed spelling (each consumer canonicalizes independently) is intentional and only reachable behind the opt-in; and smoke:cli step 9 pinning both AK1's routing and AJ1's classification is deliberate.

Green locally: smoke-tui.mjs syntax-checks and is prettier-clean (format:scripts), format, and the prior gates stand (no gated source or test changed this round — smoke script + README only).

@cliffhall

Copy link
Copy Markdown
Member Author

@claude review

@claude

claude Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Claude finished @cliffhall's task in 5m 30s —— View job


Code review — PR #1796 (round 30, after 13a825c1)

  • Gather context (full diff, 32 commits)
  • Verify the round-29 nits (AQ1, AQ2) landed
  • Fresh pass over the code (URL-throws-vs-real-listen() probe; OAuth accept-vs-bind probe; URL-builder + ambient-env sweeps)
  • Review tests / docs
  • Post review

Both round-29 items landed. AQ1's MCP_OAUTH_CALLBACK_URL: "" pin sits in smoke-tui.mjs's spawn env with the cross-reference to smoke-cli.mjs, and AQ2's root-README recipe now lists both loopback forms and states the replace semantics — I checked the anchor it links to (#host-binding--the-origin-allow-list) resolves against the real ## Host binding & the origin allow-list heading, as does the same link from docs/mcp-app-review.md and clients/web/README.md's own bullet.

# Round-29 finding Status
AQ1 smoke:tui missed the env pin ✅ pinned, comment cross-references SMOKE_BASE_ENV
AQ2 Root README Docker recipe missed the "replaces" clause ✅ both forms + the clause + link
AQ3 Three observations ✅ correct-by-design, no change — agreed

The audits I ran this round (fresh, not inherited)

1. The residual risk area for the wildcard cover — hosts where new URL() throws, so canonicalUrlHost falls back to the raw string and only the redundant second layer (isAllZeroIpv4 / canonicalizeIpv6) can catch them. I ran ~28 such candidates through the current core/node/hostUrl.ts logic and a real http.listen(). Zero misses — everything that binds the wildcard is flagged, and every unflagged spelling fails loudly:

HOST new URL throws flagged listen()
[0.0.0] / [0] / [] / [0.0.0.0] ENOTFOUND
0..0 / 0 0 / 0% / 0%0 / % ENOTFOUND
0X0 / 00 / 𝟎 (U+1D7CE) / 0.0 0.0.0.0
0. / 0x / 0:0 / 0.0.0.0. / [::ffff:0.0.0.0] EAI_AGAIN / ENOTFOUND
٠ (Arabic-Indic zero) → xn--8hb EAI_AGAIN (correctly not IDNA-folded)

So the isAllZeroIpv4 fallback layer AP1 documented as "only live input is a bracketed non-IPv6 like [0.0.0]" is exactly right, and that input isn't bindable.

2. The OAuth loopback guard through the real path (new URL(url).hostnameisLoopbackHoststripBracketslisten()), 19 callback URLs. No false accepts. Accepted-and-binds-loopback includes the ones a literal-set guard would miss: fullwidth localhost, percent-encoded %6c%6f%63…, 0177.0.0.1, 127.0.0.001, 2130706433, [0:0:0:0:0:ffff:127.0.0.1], 127.0.0.0, 127.255.255.255, localhost.. Rejected: 0.0.0.0, [::], 192.168.1.50, [::ffff:192.168.1.50], xn--8hb.

3. URL-builder sweep — still complete. Every http://${…} host interpolation across core/, clients/web/server/, clients/cli/, clients/tui/, clients/launcher/ goes through formatHostForUrl (the two OAuth redirect builders + server.ts's EADDRINUSE message — all deliberate) or canonicalUrlHost (web-server-config.ts:181, sandbox-controller.ts:213, cli.ts:429), except vite-hono-plugin.ts:144, which uses the request's own Host header.

4. Ambient-env sweep — now closed. This PR introduces exactly three env vars that can fatally abort a run that previously started: an all-interfaces HOST, an invalid CLIENT_PORT, and a non-loopback MCP_OAUTH_CALLBACK_URL. All three are pinned in the smoke harnesses that could hit them — prod-web-server.mjs pins HOST/CLIENT_PORT (shared by smoke:web + smoke:web:browser), smoke-cli.mjs's SMOKE_BASE_ENV and now smoke-tui.mjs pin the callback URL. AQ1 was the last hole.

I also re-checked the Docker path end to end: HEALTHCHECK fetches http://127.0.0.1:6274/ from inside the container — a same-origin-less GET against a wildcard bind that serves loopback, so it's unaffected by the origin guard; and printServerBanner advertises localhost while defaultAllowedOrigins emits the trio + the canonical wildcard pair, so both the printed URL and a hand-typed 0.0.0.0 connect.


Observations (no change requested)

  • localhost.localdomain is rejected by the OAuth guard, and it's distro-shipped, not user-configured. Verified it binds ::1 here. This is the AH3a class (hosts-file loopback alias) you declined in round 23 on the grounds that the usual reason to reach for a name is served by localhost — still the right call for a --callback-url value; noting only that RHEL/Fedora ship 127.0.0.1 localhost localhost.localdomain localhost4 by default, so this alias is slightly more "real" than the myapp.local example the declination was written against. If a report ever surfaces, that's the shape.
  • hostUrl.test.ts:117 mislabels one case. "[::1]" is in the it.each titled "passes the non-IPv6 / already-bracketed host … through", but it actually takes the IPv6 branch (stripBracketsisIPv6("::1") → re-bracket). The assertion (toBe(host.trim())) is green either way and the value is identical, so nothing is wrong — the title just describes a path this input doesn't take. A one-word retitle, or move it to the bracketing block above where ["[fe80::1%eth0]", "[fe80::1]"] already lives.
  • Three env-boolean parsers, two truth tables (isEnabled in resolve-bind-host.ts, wantsDebugStack in the launcher, its verbatim twin in clients/tui/index.ts) — unchanged since AM2, correctly declined; the TUI copy carries the keep-in-sync note.
  • The .gitignore *.tsbuildinfo rule is unanchored, so it also covers a stray buildinfo in any subdirectory, which is what you want given cli/tui write theirs next to their tsconfig. git ls-files | grep tsbuildinfo is empty.

Verdict: converged. Ship it. Thirty rounds on, I have no code change to ask for, and the two hardest properties — the wildcard cover and the OAuth loopback guard — held under probe classes designed specifically to defeat a canonicalize-first predicate (URL-parse failures, Unicode digit variants outside the fullwidth block, percent-encoding, IPv4-embedded IPv6, out-of-range octets). The final state:

  • The wildcard refusal covers both address families, the full legacy inet_aton family (decimal/octal/hex, 1–4 parts), bracketed, trailing-dot, zone-scoped, IPv4-mapped, and arbitrary IDNA/Unicode spellings — with every escape failing loudly at listen() — because the predicate reasons about the address the socket binds, not the string typed. The redundant second layer is now documented as redundant, with an explicit warning against removing the call that actually does the work.
  • Detection, listen(), the origin allow-list, the banner, the sandbox URL, the CLI deep-link, and both OAuth redirect builders consume one normalized value from one shared module in core/node/, with banner ⊆ allowedOrigins asserted as a property rather than a string.
  • The allow-list canonicalizes derived and typed inputs the way a browser does, fails closed on empty with no env knob to disable it, and warns-and-drops opaque, wildcard, and unparseable entries.
  • frame-ancestors derives from the real embedder list, with an IPv6 exclusion settled in a real browser rather than read off the CSP grammar.
  • All three web bind points enforce the same host and port invariants from one validated config object, with strictPort on each.
  • The OAuth callback — the credential listener — is loopback-only through its single hostname source, with message, --help text, READMEs, and tests in agreement; and its failure reaches an automated caller as a usage error with the documented exit code and JSON envelope through the documented mcp-inspector --cli invocation, pinned by two smoke steps covering both an exit-1 and a non-1 code.

Note: node_modules isn't installed in this checkout (root and all four clients absent), so I did not run the suite, validate, coverage, smoke, or a build — taking your reported green at face value. Audits 1 and 2 above were run for real via Node, against logic copied verbatim out of the current core/node/hostUrl.ts plus real http.listen() binds; audits 3 and 4 are exhaustive greps/reads of the current tree.
· branch v2/enforce-loopback-bind

@cliffhall

Copy link
Copy Markdown
Member Author

Round-30: converged — thanks for the thirty-round adversarial pass. No actionable findings this round, so I'm leaving HEAD (13a825c1) exactly as reviewed-clean rather than re-touching it. On the three observations (all "no change requested"):

  • localhost.localdomain rejected by the OAuth guard — leaving as-is (the AH3a call): a --callback-url value has localhost available, which is accepted, so the distro-shipped alias doesn't need to be. Noted for the shape of any future report.
  • hostUrl.test.ts [::1] case label — genuinely a cosmetic mislabel (it takes the IPv6 branch, not the passthrough one), but the assertion is correct and green; not worth un-blessing a reviewed-clean HEAD to retitle one it.each row. Flagging it here so it's on record.
  • .gitignore unanchored *.tsbuildinfo / the three env-boolean parsers — both correct as-is, as you noted.

Appreciate the empirical audits throughout — the headless-Chromium CSP check (F2), the pinned-Vite allowedHosts read (N1), the live vite dev boot (P2c), the launcher-envelope smoke (AK1/AL3), and the repeated flag-vs-real-listen() sweeps that caught the IDNA/fullwidth bypass (AD1) and confirmed the cover generalizes past enumerated spellings. Merging on green.

@cliffhall
cliffhall merged commit cea22d8 into v2/main Jul 27, 2026
3 checks passed
@cliffhall
cliffhall deleted the v2/enforce-loopback-bind branch July 27, 2026 02:15
cliffhall added a commit that referenced this pull request Jul 27, 2026
- cleanRoots' opening sentence still framed it as the settings-form
  normalizer, contradicting the paragraph below it that says every client
  feeds it from disk. It has four callers now; says so.
- Marked the two runtime guards `Keep:` with the reason. The `Root[]`
  parameter narrows both to `never`, so "the compiler says this can't
  happen" is the argument someone will use to delete the checks rounds 6-7
  installed. The reason now lives where the deletion would happen.
- Noted the constructor-normalization case in the timing test's header, so
  it is discoverable from a file otherwise about the initialized race.

Also merged origin/v2/main (#1796, loopback binding), which had advanced
by one commit and touched the smoke scripts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TrCvpunxToe7qnppk6zwr
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2 Issues and PRs for v2

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Enforce localhost-only binding: refuse HOST=0.0.0.0 without an explicit opt-in

1 participant