fix(mcp): accept only http and https OAuth URLs

A malicious MCP server could supply a file: or custom protocol
authorization endpoint, which sign-in passed to the platform URL handler.
This commit is contained in:
Armin Ronacher 2026-10-23 12:19:41 +02:00
parent e3bc05e871
commit 9ffd27cfaa
5 changed files with 54 additions and 3 deletions

View file

@ -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

View file

@ -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

View file

@ -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);

View file

@ -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);
}

View file

@ -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) => {