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)
225 lines
6.4 KiB
TypeScript
225 lines
6.4 KiB
TypeScript
import { createServer, type Server } from "node:http";
|
|
import { formatHostForUrl } from "../../node/hostUrl.js";
|
|
import { parseOAuthCallbackParams } from "../utils.js";
|
|
import { generateOAuthErrorDescription } from "../utils.js";
|
|
|
|
const DEFAULT_HOSTNAME = "127.0.0.1";
|
|
const DEFAULT_CALLBACK_PATH = "/oauth/callback";
|
|
|
|
const SUCCESS_HTML = `<!DOCTYPE html>
|
|
<html>
|
|
<head><meta charset="utf-8"><title>OAuth complete</title></head>
|
|
<body><p>OAuth complete. You can close this window.</p></body>
|
|
</html>`;
|
|
|
|
function errorHtml(message: string): string {
|
|
return `<!DOCTYPE html>
|
|
<html>
|
|
<head><meta charset="utf-8"><title>OAuth error</title></head>
|
|
<body><p>OAuth failed: ${escapeHtml(message)}</p></body>
|
|
</html>`;
|
|
}
|
|
|
|
function escapeHtml(s: string): string {
|
|
return s
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
|
|
export type OAuthCallbackHandler = (params: {
|
|
code: string;
|
|
state?: string;
|
|
/** RFC 9207 `iss`, when the authorization server sends it. */
|
|
iss?: string;
|
|
}) => Promise<void>;
|
|
|
|
export type OAuthErrorHandler = (params: {
|
|
error: string;
|
|
error_description?: string | null;
|
|
}) => void;
|
|
|
|
export interface OAuthCallbackServerStartOptions {
|
|
port?: number;
|
|
hostname?: string;
|
|
path?: string;
|
|
onCallback?: OAuthCallbackHandler;
|
|
onError?: OAuthErrorHandler;
|
|
}
|
|
|
|
export interface OAuthCallbackServerStartResult {
|
|
port: number;
|
|
redirectUrl: string;
|
|
}
|
|
|
|
/**
|
|
* Minimal HTTP server that receives OAuth 2.1 redirects at GET /oauth/callback.
|
|
* Used by TUI/CLI to complete the authorization code flow.
|
|
* Caller provides onCallback/onError; typically onCallback calls
|
|
* InspectorClient.completeOAuthFlow(code) then stops the server.
|
|
*/
|
|
export class OAuthCallbackServer {
|
|
private server: Server | null = null;
|
|
private port: number = 0;
|
|
private callbackPath: string = DEFAULT_CALLBACK_PATH;
|
|
private handled = false;
|
|
private onCallback?: OAuthCallbackHandler;
|
|
private onError?: OAuthErrorHandler;
|
|
|
|
/**
|
|
* Start the server. Port defaults to 0 (random) when omitted; TUI/CLI callers
|
|
* pass the resolved port from {@link parseRunnerOAuthCallbackUrl} (6276 by default).
|
|
* Returns port and redirectUrl for use as oauth.redirectUrl.
|
|
*/
|
|
async start(
|
|
options: OAuthCallbackServerStartOptions = {},
|
|
): Promise<OAuthCallbackServerStartResult> {
|
|
const {
|
|
port = 0,
|
|
hostname = DEFAULT_HOSTNAME,
|
|
path = DEFAULT_CALLBACK_PATH,
|
|
onCallback,
|
|
onError,
|
|
} = options;
|
|
if (!path.startsWith("/")) {
|
|
return Promise.reject(
|
|
new Error("Callback path must start with '/' (absolute path)"),
|
|
);
|
|
}
|
|
this.onCallback = onCallback;
|
|
this.onError = onError;
|
|
this.handled = false;
|
|
this.callbackPath = path;
|
|
|
|
return new Promise((resolve, reject) => {
|
|
this.server = createServer((req, res) => this.handleRequest(req, res));
|
|
this.server.on("error", reject);
|
|
this.server.listen(port, hostname, () => {
|
|
const a = this.server!.address();
|
|
/* v8 ignore next 4 -- defensive: a TCP server that has fired its
|
|
`listen` callback always reports an AddressInfo object (never null
|
|
and never a string, which only happens for a pipe/unix socket),
|
|
so this guard is unreachable from a successful `listen(port, host)`. */
|
|
if (!a || typeof a === "string") {
|
|
reject(new Error("Failed to get server address"));
|
|
return;
|
|
}
|
|
this.port = a.port;
|
|
resolve({
|
|
port: this.port,
|
|
redirectUrl: buildRedirectUrl(hostname, this.port, path),
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Stop the server. Idempotent.
|
|
*/
|
|
async stop(): Promise<void> {
|
|
if (!this.server) return;
|
|
await new Promise<void>((resolve) => {
|
|
this.server!.close(() => resolve());
|
|
});
|
|
this.server = null;
|
|
}
|
|
|
|
private handleRequest(
|
|
req: import("node:http").IncomingMessage,
|
|
res: import("node:http").ServerResponse<
|
|
import("node:http").IncomingMessage
|
|
>,
|
|
): void {
|
|
const needJson = req.headers["accept"]?.includes("application/json");
|
|
|
|
const send = (
|
|
status: number,
|
|
body: string,
|
|
contentType = "text/html; charset=utf-8",
|
|
) => {
|
|
res.writeHead(status, { "Content-Type": contentType });
|
|
res.end(body);
|
|
};
|
|
|
|
if (req.method !== "GET") {
|
|
send(405, needJson ? '{"error":"Method Not Allowed"}' : SUCCESS_HTML);
|
|
return;
|
|
}
|
|
|
|
let pathname: string;
|
|
let search: string;
|
|
let state: string | undefined;
|
|
try {
|
|
const u = new URL(
|
|
/* v8 ignore next -- defensive: Node always populates req.url for an
|
|
HTTP request, so the `?? ""` fallback is never taken. */
|
|
req.url ?? "",
|
|
"http://placeholder",
|
|
);
|
|
pathname = u.pathname;
|
|
search = u.search;
|
|
state = u.searchParams.get("state") ?? undefined;
|
|
} catch {
|
|
send(400, needJson ? '{"error":"Bad Request"}' : SUCCESS_HTML);
|
|
return;
|
|
}
|
|
|
|
if (pathname !== this.callbackPath) {
|
|
send(404, needJson ? '{"error":"Not Found"}' : SUCCESS_HTML);
|
|
return;
|
|
}
|
|
|
|
if (this.handled) {
|
|
send(
|
|
409,
|
|
needJson ? '{"error":"Callback already handled"}' : SUCCESS_HTML,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const params = parseOAuthCallbackParams(search);
|
|
|
|
if (params.successful) {
|
|
this.handled = true;
|
|
const cb = this.onCallback;
|
|
if (cb) {
|
|
cb({ code: params.code, state, iss: params.iss })
|
|
.then(() => {
|
|
send(200, SUCCESS_HTML);
|
|
void this.stop();
|
|
})
|
|
.catch((err) => {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
this.onError?.({ error: "callback_error", error_description: msg });
|
|
send(500, errorHtml(msg));
|
|
void this.stop();
|
|
});
|
|
} else {
|
|
send(200, SUCCESS_HTML);
|
|
void this.stop();
|
|
}
|
|
return;
|
|
}
|
|
|
|
this.handled = true;
|
|
const msg = generateOAuthErrorDescription(params);
|
|
this.onError?.({
|
|
error: params.error,
|
|
error_description: params.error_description ?? undefined,
|
|
});
|
|
send(400, errorHtml(msg));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create an OAuth callback server instance.
|
|
* Use start() then stop() when the OAuth flow is done.
|
|
*/
|
|
export function createOAuthCallbackServer(): OAuthCallbackServer {
|
|
return new OAuthCallbackServer();
|
|
}
|
|
|
|
function buildRedirectUrl(host: string, port: number, path: string): string {
|
|
return `http://${formatHostForUrl(host)}:${port}${path}`;
|
|
}
|