mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(dev): bound realtime SDP answer reads
Keep the OpenAI Realtime WebRTC smoke's SDP offer request in the browser fetch path while moving the browser-side SDP answer reader into a testable helper. Reject unsafe decimal Content-Length values before acquiring a body reader and preserve streamed byte limiting for responses without a safe declared length. Proof: direct bounded-reader repro rejects unsafe content-length before getReader and cancels the body; node --check --experimental-strip-types scripts/dev/realtime-talk-live-smoke.ts; node --check --experimental-strip-types test/scripts/dev-tooling-safety.test.ts; git diff --check origin/main...HEAD; autoreview clean overall 0.84; exact-head release gate succeeded at https://github.com/openclaw/openclaw/actions/runs/27848673438.
This commit is contained in:
@@ -43,6 +43,16 @@ type OpenAIHttpOptions = {
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type OpenAIRealtimeBrowserResponseReader = (
|
||||
response: Response,
|
||||
label: string,
|
||||
maxBytes: number,
|
||||
) => Promise<string>;
|
||||
|
||||
type OpenAIWebRtcSmokeGlobal = typeof globalThis & {
|
||||
openclawReadBoundedRealtimeResponseText?: OpenAIRealtimeBrowserResponseReader;
|
||||
};
|
||||
|
||||
function getEnv(name: string): string | undefined {
|
||||
const value = process.env[name]?.trim();
|
||||
return value ? value : undefined;
|
||||
@@ -114,6 +124,63 @@ function compareStrings(left: string | undefined, right: string | undefined): nu
|
||||
return (left ?? "").localeCompare(right ?? "");
|
||||
}
|
||||
|
||||
async function readOpenAIRealtimeBrowserResponseText(
|
||||
response: Response,
|
||||
label: string,
|
||||
maxBytes: number,
|
||||
): Promise<string> {
|
||||
const responseBodyTooLargeError = (errorLabel: string, errorMaxBytes: number): Error =>
|
||||
new Error(`${errorLabel} response body exceeded ${errorMaxBytes} bytes`);
|
||||
const rawContentLength = response.headers.get("content-length");
|
||||
if (rawContentLength && /^\d+$/u.test(rawContentLength)) {
|
||||
const contentLength = Number(rawContentLength);
|
||||
if (!Number.isSafeInteger(contentLength) || contentLength > maxBytes) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
throw responseBodyTooLargeError(label, maxBytes);
|
||||
}
|
||||
}
|
||||
if (!response.body) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
const chunks: string[] = [];
|
||||
let totalBytes = 0;
|
||||
let canceled = false;
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
const tail = decoder.decode();
|
||||
if (tail) {
|
||||
chunks.push(tail);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
totalBytes += value.byteLength;
|
||||
if (totalBytes > maxBytes) {
|
||||
canceled = true;
|
||||
await reader.cancel().catch(() => undefined);
|
||||
throw responseBodyTooLargeError(label, maxBytes);
|
||||
}
|
||||
chunks.push(decoder.decode(value, { stream: true }));
|
||||
}
|
||||
} finally {
|
||||
if (!canceled) {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
return chunks.join("");
|
||||
}
|
||||
|
||||
function openAIRealtimeBrowserResponseReaderInitScript(): string {
|
||||
return `globalThis.openclawReadBoundedRealtimeResponseText = ${readOpenAIRealtimeBrowserResponseText.toString()};`;
|
||||
}
|
||||
|
||||
async function createOpenAIClientSecret(
|
||||
apiKey: string,
|
||||
options: OpenAIHttpOptions = {},
|
||||
@@ -219,57 +286,14 @@ async function smokeOpenAIWebRtc(browser: Browser, apiKey: string): Promise<Smok
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
await page.evaluate("globalThis.__name = (fn) => fn");
|
||||
await page.evaluate(openAIRealtimeBrowserResponseReaderInitScript());
|
||||
const result = await page.evaluate(
|
||||
async ({ clientSecret: secret, sdpAnswerMaxBytes, timeoutMs }) => {
|
||||
const responseBodyTooLargeError = (label: string, maxBytes: number): Error =>
|
||||
new Error(`${label} response body exceeded ${maxBytes} bytes`);
|
||||
const readBoundedTextLocal = async (
|
||||
response: Response,
|
||||
label: string,
|
||||
maxBytes: number,
|
||||
): Promise<string> => {
|
||||
const contentLength = Number(response.headers.get("content-length") ?? "");
|
||||
if (Number.isSafeInteger(contentLength) && contentLength > maxBytes) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
throw responseBodyTooLargeError(label, maxBytes);
|
||||
}
|
||||
if (!response.body) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
const chunks: string[] = [];
|
||||
let totalBytes = 0;
|
||||
let canceled = false;
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
const tail = decoder.decode();
|
||||
if (tail) {
|
||||
chunks.push(tail);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
totalBytes += value.byteLength;
|
||||
if (totalBytes > maxBytes) {
|
||||
canceled = true;
|
||||
await reader.cancel().catch(() => undefined);
|
||||
throw responseBodyTooLargeError(label, maxBytes);
|
||||
}
|
||||
chunks.push(decoder.decode(value, { stream: true }));
|
||||
}
|
||||
} finally {
|
||||
if (!canceled) {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
return chunks.join("");
|
||||
};
|
||||
const readBoundedTextLocal = (globalThis as OpenAIWebRtcSmokeGlobal)
|
||||
.openclawReadBoundedRealtimeResponseText;
|
||||
if (!readBoundedTextLocal) {
|
||||
throw new Error("OpenAI Realtime bounded response reader was not installed");
|
||||
}
|
||||
const withBrowserTimeout = async <T>(
|
||||
label: string,
|
||||
run: (signal: AbortSignal) => Promise<T>,
|
||||
@@ -327,12 +351,16 @@ async function smokeOpenAIWebRtc(browser: Browser, apiKey: string): Promise<Smok
|
||||
});
|
||||
const offer = await peer.createOffer();
|
||||
await peer.setLocalDescription(offer);
|
||||
const offerSdp = offer.sdp;
|
||||
if (!offerSdp) {
|
||||
throw new Error("OpenAI Realtime SDP offer did not include SDP");
|
||||
}
|
||||
const answer = await withBrowserTimeout(
|
||||
"OpenAI Realtime SDP offer request",
|
||||
async (signal) => {
|
||||
const response = await fetch("https://api.openai.com/v1/realtime/calls", {
|
||||
method: "POST",
|
||||
body: offer.sdp,
|
||||
body: offerSdp,
|
||||
headers: {
|
||||
Authorization: `Bearer ${secret}`,
|
||||
"Content-Type": "application/sdp",
|
||||
@@ -761,6 +789,7 @@ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
export const testing = {
|
||||
OPENAI_HTTP_RESPONSE_MAX_BYTES,
|
||||
createOpenAIClientSecret,
|
||||
readOpenAIRealtimeBrowserResponseText,
|
||||
readBoundedText,
|
||||
resolveOpenAIHttpTimeoutMs,
|
||||
};
|
||||
|
||||
@@ -370,6 +370,30 @@ describe("script-specific dev tooling hardening", () => {
|
||||
).rejects.toThrow(`OpenAI Realtime test response body exceeded ${maxBytes} bytes`);
|
||||
});
|
||||
|
||||
it("rejects unsafe OpenAI realtime SDP answer content-length values before reading", async () => {
|
||||
const maxBytes = realtimeSmokeTesting.OPENAI_HTTP_RESPONSE_MAX_BYTES;
|
||||
const body = {
|
||||
cancel: vi.fn(() => Promise.resolve()),
|
||||
getReader: vi.fn(() => {
|
||||
throw new Error("reader should not be acquired");
|
||||
}),
|
||||
};
|
||||
const response = {
|
||||
headers: new Headers({ "content-length": "9007199254740993" }),
|
||||
body,
|
||||
} as unknown as Response;
|
||||
|
||||
await expect(
|
||||
realtimeSmokeTesting.readOpenAIRealtimeBrowserResponseText(
|
||||
response,
|
||||
"OpenAI Realtime SDP answer",
|
||||
maxBytes,
|
||||
),
|
||||
).rejects.toThrow(`OpenAI Realtime SDP answer response body exceeded ${maxBytes} bytes`);
|
||||
expect(body.getReader).not.toHaveBeenCalled();
|
||||
expect(body.cancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("bounds OpenAI realtime smoke response body reads by streamed bytes", async () => {
|
||||
const maxBytes = realtimeSmokeTesting.OPENAI_HTTP_RESPONSE_MAX_BYTES;
|
||||
const response = new Response(
|
||||
|
||||
Reference in New Issue
Block a user