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

Closes #1795.

Enforce localhost-only binding in code (the "never 0.0.0.0" rule was only a spec note): refuse an all-interfaces HOST — across every spelling the OS binds as the wildcard (inet_aton decimal/octal/hex, IPv6, IPv4-mapped, zone-scoped, IDNA/Unicode) — unless DANGEROUSLY_BIND_ALL_INTERFACES=true (Docker's sanctioned opt-in). Fixes the reported stdio-connect 403 by expanding the default loopback origin allow-list to all three interchangeable forms.

Hardening surfaced across an extended review: closes an IDNA/fullwidth wildcard bypass, a blank-ALLOWED_ORIGINS fail-open, and the CLI/TUI OAuth-callback wildcard bind (loopback-only now, RFC 8252); canonicalizes the origin allow-list the way a browser does; derives frame-ancestors from the real embedder list (IPv6-literal exclusion verified in headless Chromium); strictPort on all three bind points; and preserves the CLI exit-code map + JSON envelope through the mcp-inspector --cli launcher path. Shared host normalization lives in core/node/hostUrl.ts. Docs + tests + smokes throughout.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Cliff Hall 2026-07-26 22:15:10 -04:00 • committed by GitHub
commit cea22d86b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 1610 additions and 86 deletions

View file

@ -76,6 +76,29 @@ describe("runner OAuth callback URL", () => {
).toThrow(/must use http scheme/);
});
it.each([
"http://0.0.0.0:6276/oauth/callback", // wildcard
"http://[::]:6276/oauth/callback", // IPv6 wildcard
"http://192.168.1.50:6276/oauth/callback", // LAN — plaintext code over the network
"http://example.com:6276/oauth/callback", // routable hostname
])(
"rejects a non-loopback callback host %j (the code listener must be loopback)",
(url) => {
expect(() => parseRunnerOAuthCallbackUrl(url)).toThrow(
/must bind a loopback host/,
);
},
);
it.each([
["http://127.0.0.1:6276/oauth/callback", "127.0.0.1"],
["http://localhost:6276/oauth/callback", "localhost"],
["http://127.5:6276/oauth/callback", "127.0.0.5"], // 127.0.0.0/8, shorthand normalized by new URL
["http://[::1]:6276/oauth/callback", "::1"], // de-bracketed so listen() can bind it
])("accepts the loopback callback host %j", (url, expectedHostname) => {
expect(parseRunnerOAuthCallbackUrl(url).hostname).toBe(expectedHostname);
});
it("formatRunnerOAuthRedirectUrl round-trips default config", () => {
const config = parseRunnerOAuthCallbackUrl();
expect(formatRunnerOAuthRedirectUrl(config)).toBe(

View file

@ -0,0 +1,164 @@
import { describe, it, expect } from "vitest";
import {
canonicalUrlHost,
formatHostForUrl,
isAllInterfacesHost,
isLoopbackHost,
stripBrackets,
} from "@inspector/core/node/hostUrl.js";
describe("isLoopbackHost", () => {
it.each([
"localhost",
"LOCALHOST",
"127.0.0.1",
"127.5", // 127.0.0.0/8 shorthand
"0x7f.0.0.1",
"2130706433",
"::1",
"[::1]",
"0:0:0:0:0:0:0:1",
"::ffff:127.0.0.1", // IPv4-mapped loopback → unmapped to 127.0.0.1
"::ffff:7f00:1", // its canonical serialization (what new URL().hostname yields)
"127.255.255.255", // top of 127.0.0.0/8
"localhost.", // root-anchored FQDN (WHATWG keeps the dot; binds loopback)
"127.0.0.1.", // trailing dot on an IP literal (WHATWG strips it)
])("flags the loopback host %j", (host) => {
expect(isLoopbackHost(host)).toBe(true);
});
it.each([
"0.0.0.0",
"::",
"192.168.1.50",
"example.com",
"126.0.0.1", // adjacent to but outside 127/8
"128.0.0.1",
"0.0.0.127", // bare "127" resolves here, not loopback
"", // empty is the wildcard (isAllInterfacesHost's job), not loopback
// Out-of-range octets survive canonicalUrlHost's non-URL fallback — the
// bounded regex must still reject them.
"127.999.0.1",
"127.0.0.256",
])("does not flag the non-loopback host %j", (host) => {
expect(isLoopbackHost(host)).toBe(false);
});
});
describe("isAllInterfacesHost", () => {
it.each([
"0.0.0.0",
"::",
"",
" 0.0.0.0 ",
" :: ",
"[::]",
"0:0:0:0:0:0:0:0",
"::ffff:0.0.0.0",
"::ffff:0:0",
// IPv6 wildcard spellings that canonicalize to `::`.
"::0",
"0::0",
"::0.0.0.0",
"0:0::0",
"0000:0000:0000:0000:0000:0000:0000:0000",
// Zone-scoped wildcard: net.isIPv6 accepts the %zone, new URL() rejects it,
// so the zone must be stripped before canonicalizing (else it throws).
"::%eth0",
// Legacy inet_aton spellings the OS still binds as 0.0.0.0.
"0",
"0x0",
"0x0.0.0.0",
"000.000.000.000",
"0.0", // short inet_aton form (1–3 parts) still binds the wildcard
"0.0.0",
"00000000000", // bare octal zero — parseAddressPart's [0-9]+ handles octal
"0x00000000", // bare hex zero
// Fullwidth (IDNA-mapped) spellings the resolver folds to 0.0.0.0 before
// binding — the guard must canonicalize, not read the raw string.
"0", // fullwidth "0"
"00.0.0", // fullwidth "0" + ".0.0"
"0.0.0.0", // fullwidth dots
])("flags the all-interfaces host %j", (host) => {
expect(isAllInterfacesHost(host)).toBe(true);
});
it.each([
"localhost",
"127.0.0.1",
"::1",
"[::1]",
"example.com",
"192.168.1.50",
"1.0.0.0",
"0.0.0.1",
"::ffff:0", // canonicalizes to ::ffff:0, a distinct address — not the wildcard
"0.0.0.0.0", // 5 octets — not a valid IPv4, must not be flagged (parts.length > 4)
"fe80::1%eth0", // a zone-scoped link-local — a real bind host, not the wildcard
"::1%lo0", // zone-scoped loopback — must not be flagged and must not throw
"127.0.0.1", // fullwidth 127 → 127.0.0.1, a real loopback address, not the wildcard
])("does not flag the loopback/specific host %j", (host) => {
expect(isAllInterfacesHost(host)).toBe(false);
});
});
describe("formatHostForUrl", () => {
it.each([
["::1", "[::1]"],
["fe80::1", "[fe80::1]"],
[" ::1 ", "[::1]"],
["fe80::1%eth0", "[fe80::1]"], // zone id dropped — a URL host can't carry one
["::1%lo0", "[::1]"],
["[fe80::1%eth0]", "[fe80::1]"], // bracketed-with-zone → still a valid URL host
])("brackets the IPv6 literal %j", (host, expected) => {
expect(formatHostForUrl(host)).toBe(expected);
});
it.each(["localhost", "127.0.0.1", "192.168.1.50", "example.com", "[::1]"])(
"passes the non-IPv6 / already-bracketed host %j through",
(host) => {
expect(formatHostForUrl(host)).toBe(host.trim());
},
);
it("does not bracket a non-IPv6 value that merely contains a colon", () => {
// A mistyped host:port must not be wrapped as [host:port].
expect(formatHostForUrl("localhost:6274")).toBe("localhost:6274");
});
});
describe("stripBrackets", () => {
it.each([
["[::1]", "::1"],
["[fe80::1%eth0]", "fe80::1%eth0"],
["[]", ""], // zero-or-more: an empty bracket pair reduces to ""
["127.0.0.1", "127.0.0.1"],
])("strips a surrounding bracket pair from %j", (host, expected) => {
expect(stripBrackets(host)).toBe(expected);
});
});
describe("canonicalUrlHost", () => {
it.each([
["127.1", "127.0.0.1"],
["0x7f.0.0.1", "127.0.0.1"],
["2130706433", "127.0.0.1"],
["0:0:0:0:0:0:0:1", "[::1]"],
["::0001", "[::1]"],
["LOCALHOST", "localhost"],
["Example.COM", "example.com"],
["fe80::1", "[fe80::1]"],
// IPv4-mapped IPv6 → the dotted IPv4 the socket answers on (intentional
// divergence from browser canonicalization).
["::ffff:127.0.0.1", "127.0.0.1"],
["::ffff:192.168.1.50", "192.168.1.50"],
// A distinct address is unchanged.
["127.0.0.2", "127.0.0.2"],
])("canonicalizes %j to %j", (host, expected) => {
expect(canonicalUrlHost(host)).toBe(expected);
});
it("falls back to the formatted value when the host isn't a parseable URL", () => {
expect(canonicalUrlHost("")).toBe("");
});
});