diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1870f885f..ed3a38eaa 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,6 +6,10 @@ - `pi update` for installs from the pi.dev installer no longer passes `--min-release-age=0` to `npm ci`. The release lockfile pins every package, so the flag had no effect, and security scanners that block it stopped the update ([#11262](https://github.com/earendil-works/pi/issues/11262)) +### Fixed + +- MCP sign-in now rejects authorization endpoints and other OAuth metadata URLs that are not `http:` or `https:`. Before, a malicious MCP server could supply a `file:` or custom protocol URL, which pi passed to the platform URL handler. + ## [1.4.0] - 2026-10-21 ### New Features diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index da6dedffd..412df6f3f 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- OAuth metadata parsing and `startAuthorization()` now accept only `http:` and `https:` URLs. Before, a server could supply a `file:` or custom protocol authorization endpoint (for example `search-ms:`), which the platform URL handler would launch on sign-in. + ## [1.4.0] - 2026-10-21 ## [1.3.0] - 2026-10-15 diff --git a/packages/mcp/src/oauth/flow.ts b/packages/mcp/src/oauth/flow.ts index 937a5369f..63821fcdb 100644 --- a/packages/mcp/src/oauth/flow.ts +++ b/packages/mcp/src/oauth/flow.ts @@ -21,6 +21,7 @@ import { } from "./errors.ts"; import { type AuthorizationServerMetadata, + isHttpUrl, type OAuthClientInformation, type OAuthClientInformationFull, type OAuthClientInformationMixed, @@ -186,6 +187,8 @@ export async function startAuthorization( throw new Error("Authorization server does not support PKCE S256"); } const url = new URL(metadata?.authorization_endpoint ?? new URL("/authorize", authorizationServerUrl)); + // Callers open this URL with the platform URL handler, which also launches files and custom protocols. + if (!isHttpUrl(url)) throw new Error(`Invalid authorization endpoint ${url.href}`); const { verifier, challenge } = await pkce(); url.searchParams.set("response_type", "code"); url.searchParams.set("client_id", options.clientInformation.client_id); diff --git a/packages/mcp/src/oauth/types.ts b/packages/mcp/src/oauth/types.ts index df5552564..d7d915394 100644 --- a/packages/mcp/src/oauth/types.ts +++ b/packages/mcp/src/oauth/types.ts @@ -123,15 +123,21 @@ function optionalStrings(value: unknown, name: string): string[] | undefined { return [...value]; } +/** + * Accepts only `http:` and `https:` URLs. Servers supply these URLs, and the authorization endpoint is + * opened with the platform URL handler, so a `file:` or custom protocol URL would launch local programs. + */ function safeUrl(value: unknown, name: string): string { const text = requiredString(value, name); // URL parsing throws a `TypeError`, which discovery reserves for network failures. - if (!URL.canParse(text)) throw new Error(`Invalid ${name}`); - const url = new URL(text); - if (["javascript:", "data:", "vbscript:"].includes(url.protocol)) throw new Error(`Invalid ${name}`); + if (!URL.canParse(text) || !isHttpUrl(new URL(text))) throw new Error(`Invalid ${name}`); return text; } +export function isHttpUrl(url: URL): boolean { + return url.protocol === "http:" || url.protocol === "https:"; +} + function optionalUrl(value: unknown, name: string): string | undefined { return absent(value) ? undefined : safeUrl(value, name); } diff --git a/packages/mcp/test/oauth.test.ts b/packages/mcp/test/oauth.test.ts index 90ded0230..c6d175b8c 100644 --- a/packages/mcp/test/oauth.test.ts +++ b/packages/mcp/test/oauth.test.ts @@ -17,6 +17,7 @@ import { OAuthIssuerMismatchError, type OAuthTokens, registerClient, + startAuthorization, } from "../src/oauth/index.ts"; import { closeServers, listen, readBody } from "./helpers.ts"; @@ -450,6 +451,39 @@ describe("MCP OAuth", () => { await expect(discoverAuthorizationServerMetadata(origin)).rejects.toBeInstanceOf(OAuthIssuerMismatchError); }); + it("rejects non-HTTP authorization endpoints, which the platform URL handler would launch", async () => { + const endpoints = ["file://attacker.example/share/x.exe", "search-ms:query=x", "ms-msdt:/id"]; + let endpoint = ""; + const origin = await listen(async (_request, response, serverOrigin) => { + response.setHeader("content-type", "application/json"); + response.end( + JSON.stringify({ + issuer: serverOrigin, + authorization_endpoint: endpoint, + token_endpoint: `${serverOrigin}/token`, + response_types_supported: ["code"], + }), + ); + }); + for (const value of endpoints) { + endpoint = value; + await expect(discoverAuthorizationServerMetadata(origin)).rejects.toThrow("Invalid authorization endpoint"); + const metadata = { + issuer: origin, + authorization_endpoint: value, + token_endpoint: `${origin}/token`, + response_types_supported: ["code"], + }; + await expect( + startAuthorization(origin, { + metadata, + clientInformation: { client_id: "client" }, + redirectUrl: "http://127.0.0.1/callback", + }), + ).rejects.toThrow("Invalid authorization endpoint"); + } + }); + // #10172 it("uses a configured authorization server metadata document as is", async () => { const origin = await listen(async (request, response, serverOrigin) => {