fix(oauth): harden browser launch handling
This commit is contained in:
@@ -132,10 +132,22 @@ async function startDeviceFlow(domain: string): Promise<DeviceCodeResponse> {
|
||||
throw new Error("Invalid device code response fields");
|
||||
}
|
||||
|
||||
// The verification URI is opened in the user's browser and to prevent `open` from
|
||||
// opening an executable or similar, we force it to be a URL.
|
||||
let parsedUri: URL;
|
||||
try {
|
||||
parsedUri = new URL(verificationUri);
|
||||
} catch {
|
||||
throw new Error("Untrusted verification_uri in device code response");
|
||||
}
|
||||
if (parsedUri.protocol !== "https:" && parsedUri.protocol !== "http:") {
|
||||
throw new Error("Untrusted verification_uri in device code response");
|
||||
}
|
||||
|
||||
return {
|
||||
device_code: deviceCode,
|
||||
user_code: userCode,
|
||||
verification_uri: verificationUri,
|
||||
verification_uri: parsedUri.href,
|
||||
interval,
|
||||
expires_in: expiresIn,
|
||||
};
|
||||
|
||||
@@ -28,7 +28,6 @@ import type {
|
||||
OAuthProviderInterface,
|
||||
} from "./types.ts";
|
||||
|
||||
const CALLBACK_HOST = process.env.PI_OAUTH_CALLBACK_HOST || "127.0.0.1";
|
||||
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
||||
const AUTH_BASE_URL = "https://auth.openai.com";
|
||||
const AUTHORIZE_URL = `${AUTH_BASE_URL}/oauth/authorize`;
|
||||
@@ -47,6 +46,10 @@ const JWT_CLAIM_PATH = "https://api.openai.com/auth";
|
||||
type OAuthToken = { access: string; refresh: string; expires: number };
|
||||
type TokenOperation = "exchange" | "refresh";
|
||||
|
||||
function getCallbackHost(): string {
|
||||
return typeof process !== "undefined" ? process.env.PI_OAUTH_CALLBACK_HOST || "127.0.0.1" : "127.0.0.1";
|
||||
}
|
||||
|
||||
type DeviceAuthInfo = {
|
||||
deviceAuthId: string;
|
||||
userCode: string;
|
||||
@@ -368,7 +371,7 @@ function startLocalOAuthServer(state: string): Promise<OAuthServerInfo> {
|
||||
|
||||
return new Promise((resolve) => {
|
||||
server
|
||||
.listen(1455, CALLBACK_HOST, () => {
|
||||
.listen(1455, getCallbackHost(), () => {
|
||||
resolve({
|
||||
close: () => server.close(),
|
||||
cancelWait: () => {
|
||||
|
||||
@@ -84,6 +84,97 @@ describe("GitHub Copilot OAuth device flow", () => {
|
||||
await loginPromise;
|
||||
});
|
||||
|
||||
it("rejects a non-http(s) verification_uri before it reaches onDeviceCode", async () => {
|
||||
// A malicious enterprise OAuth server could return a verification_uri that
|
||||
// the browser launcher would otherwise hand to the OS. Ensure such values
|
||||
// are rejected at the deserialization boundary.
|
||||
const fetchMock = vi.fn(async (input: unknown): Promise<Response> => {
|
||||
const url = getUrl(input);
|
||||
if (url.endsWith("/login/device/code")) {
|
||||
return jsonResponse({
|
||||
device_code: "device-code",
|
||||
user_code: "ABCD-EFGH",
|
||||
verification_uri: "$(id>/tmp/pwned)",
|
||||
interval: 1,
|
||||
expires_in: 900,
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const onDeviceCode = vi.fn();
|
||||
await expect(
|
||||
loginGitHubCopilot({
|
||||
onDeviceCode,
|
||||
onPrompt: async () => "",
|
||||
}),
|
||||
).rejects.toThrow(/Untrusted verification_uri/);
|
||||
expect(onDeviceCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("normalizes verification_uri before it reaches onDeviceCode", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-09T00:00:00Z"));
|
||||
|
||||
const rawVerificationUri = "https://github.com/login/\x1b]8;;evil";
|
||||
const normalizedVerificationUri = new URL(rawVerificationUri).href;
|
||||
expect(normalizedVerificationUri).not.toBe(rawVerificationUri);
|
||||
|
||||
const fetchMock = vi.fn(async (input: unknown): Promise<Response> => {
|
||||
const url = getUrl(input);
|
||||
|
||||
if (url.endsWith("/login/device/code")) {
|
||||
return jsonResponse({
|
||||
device_code: "device-code",
|
||||
user_code: "ABCD-EFGH",
|
||||
verification_uri: rawVerificationUri,
|
||||
interval: 1,
|
||||
expires_in: 900,
|
||||
});
|
||||
}
|
||||
|
||||
if (url.endsWith("/login/oauth/access_token")) {
|
||||
return jsonResponse({ access_token: "ghu_refresh_token" });
|
||||
}
|
||||
|
||||
if (url.includes("/copilot_internal/v2/token")) {
|
||||
return jsonResponse({
|
||||
token: "tid=test;exp=9999999999;proxy-ep=proxy.individual.githubcopilot.com;",
|
||||
expires_at: 9999999999,
|
||||
});
|
||||
}
|
||||
|
||||
if (url.includes("/models/") && url.endsWith("/policy")) {
|
||||
return new Response("", { status: 200 });
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const onDeviceCode = vi.fn();
|
||||
const loginPromise = loginGitHubCopilot({
|
||||
onDeviceCode,
|
||||
onPrompt: async () => "",
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(onDeviceCode).toHaveBeenCalledWith({
|
||||
userCode: "ABCD-EFGH",
|
||||
verificationUri: normalizedVerificationUri,
|
||||
intervalSeconds: 1,
|
||||
expiresInSeconds: 900,
|
||||
});
|
||||
expect(onDeviceCode).not.toHaveBeenCalledWith(expect.objectContaining({ verificationUri: rawVerificationUri }));
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await loginPromise;
|
||||
});
|
||||
|
||||
it("polls immediately and increases the interval after slow_down", async () => {
|
||||
vi.useFakeTimers();
|
||||
const startTime = new Date("2026-03-09T00:00:00Z");
|
||||
|
||||
Reference in New Issue
Block a user