Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
fix(oauth): bound github-copilot OAuth response reads at 16 MiB
  • Loading branch information
wangmiao0668000666 committed Jun 28, 2026
commit 32ef926ff0e4ffc7c40da8ac387873612a89afde
82 changes: 82 additions & 0 deletions src/llm/utils/oauth/github-copilot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,3 +181,85 @@ describe("GitHub Copilot OAuth model policy", () => {
expect(cancel).toHaveBeenCalledTimes(1);
});
});

describe("GitHub Copilot OAuth bounded reads", () => {
it("caps oversized OAuth JSON responses instead of buffering the full body", async () => {
// 18 MiB body in 1 MiB chunks exceeds the 16 MiB default cap on
// the shared readProviderJsonResponse reader.
const CHUNK = 1024 * 1024;
const CHUNK_COUNT = 18;
let pulls = 0;
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
if (pulls >= CHUNK_COUNT) {
controller.close();
return;
}
pulls += 1;
controller.enqueue(encoder.encode("a".repeat(CHUNK)));
},
});
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(stream, {
status: 200,
headers: { "Content-Type": "application/json" },
}),
),
);

await expect(refreshGitHubCopilotToken("refresh-token")).rejects.toThrow(
"GitHub Copilot token refresh request: JSON response exceeds 16777216 bytes",
);
});

it("parses normal-size OAuth JSON responses under the byte cap", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(
JSON.stringify({
token: "copilot-token",
expires_at: Math.floor(Date.now() / 1000) + 3600,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
),
);

const result = await refreshGitHubCopilotToken("refresh-token");
expect(result.access).toBe("copilot-token");
expect(typeof result.expires).toBe("number");
});

it("cancels the upstream body when the bounded reader overflows", async () => {
const cancel = vi.fn(async () => undefined);
const encoder = new TextEncoder();
const source = new ReadableStream<Uint8Array>({
pull(controller) {
controller.enqueue(encoder.encode("a".repeat(1024 * 1024)));
},
cancel,
});
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(source, {
status: 200,
headers: { "Content-Type": "application/json" },
}),
),
);

await expect(refreshGitHubCopilotToken("refresh-token")).rejects.toThrow(
"GitHub Copilot token refresh request",
);

expect(cancel).toHaveBeenCalled();
});
});
11 changes: 9 additions & 2 deletions src/llm/utils/oauth/github-copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
*/

import { resolveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion";
import {
readProviderJsonResponse,
readProviderTextResponse,
} from "../../../agents/provider-http-errors.js";
import {
nonNegativeSecondsToSafeMilliseconds,
positiveSecondsToSafeMilliseconds,
Expand Down Expand Up @@ -175,18 +179,21 @@ async function fetchResponse(
}
}

// Shared 16 MiB bounded reader — a hostile OAuth endpoint cannot force the
// runtime to buffer an unbounded body through `.text()` / `.json()`.
async function fetchJson(
url: string,
init: RequestInit,
operation: string,
options: CopilotRequestOptions = {},
): Promise<unknown> {
const response = await fetchResponse(url, init, operation, options);
const label = `GitHub Copilot ${operation}`;
if (!response.ok) {
const text = await response.text();
const text = await readProviderTextResponse(response, label);
throw new Error(`${response.status} ${response.statusText}: ${text}`);
}
return response.json();
return readProviderJsonResponse(response, label);
}

async function startDeviceFlow(
Expand Down
Loading