mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
e3bc05e871
commit
9ffd27cfaa
5 changed files with 54 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue