diff --git a/src/browser/BrowserScenarioResponseValues.ts b/src/browser/BrowserScenarioResponseValues.ts index bf7ed27d30..acb69a65de 100644 --- a/src/browser/BrowserScenarioResponseValues.ts +++ b/src/browser/BrowserScenarioResponseValues.ts @@ -131,10 +131,11 @@ export const decodedBodyBound = ( headers: Readonly>, ): number | undefined => { const declared = headers["content-length"]?.trim(); + // Content-Length allows leading zeros; only significant digits bound precision. const bytes = received !== undefined && Number.isSafeInteger(received) && received > 0 ? received - : declared !== undefined && /^[1-9]\d{0,14}$/u.test(declared) + : declared !== undefined && /^0*[1-9]\d{0,14}$/u.test(declared) ? Number(declared) : undefined; if (bytes === undefined) return undefined; diff --git a/src/browser/PlaywrightScenarioRequest.test.ts b/src/browser/PlaywrightScenarioRequest.test.ts index 962b7b840a..aa8f24cf6f 100644 --- a/src/browser/PlaywrightScenarioRequest.test.ts +++ b/src/browser/PlaywrightScenarioRequest.test.ts @@ -1699,6 +1699,14 @@ describe("saving from a UI step's response", () => { { encoding: "identity", size: 0 }, { encoding: "gzip", size: 0 }, { size: 0, length: "0" }, + { size: 0, length: "0000" }, + { size: 0, length: "+17" }, + { size: 0, length: "17.0" }, + { size: 0, length: "1.7e1" }, + { size: 0, length: "17, 17" }, + { size: 0, length: "0001000000000000000" }, + { size: 0, length: "9007199254740993" }, + { size: 0, length: "0009007199254740993" }, { size: -1 }, { size: Number.NaN }, ])( @@ -1831,7 +1839,10 @@ describe("saving from a compressed or cached UI response", () => { { encoding: "deflate", size: 32 }, { encoding: "x-gzip", size: 32 }, { size: 0, length: "17" }, + { size: 0, length: "0017" }, + { size: 0, length: "00000000000000000017" }, { encoding: "gzip", size: 0, length: "32" }, + { encoding: "gzip", size: 0, length: "0032" }, ])( "saves from a response whose received size or declared length bounds its decoded size: %j", async (bounds) => { @@ -1852,23 +1863,29 @@ describe("saving from a compressed or cached UI response", () => { }, ); - it("refuses a compressed response that could decode past the limit without reading it", async () => { - const parsed = clickSaving("/api/items"); - const { page, reads } = uiPage([ - { - method: "POST", - url: `${ORIGIN}/api/items`, - status: 201, - body: '{"id":"x"}', - encoding: "gzip", - size: 12_000, - }, - ]); - await expect( - perform(page, parsed.actions[0]!, resolved(parsed)), - ).rejects.toThrow("could decode past the size limit"); - expect(reads.count).toBe(0); - }); + it.each([ + { encoding: "gzip", size: 12_000 }, + { size: 0, length: "00012000001" }, + { encoding: "gzip", size: 0, length: "00012000" }, + ])( + "refuses a response that could decode past the limit without reading it: %j", + async (bounds) => { + const parsed = clickSaving("/api/items"); + const { page, reads } = uiPage([ + { + method: "POST", + url: `${ORIGIN}/api/items`, + status: 201, + body: '{"id":"x"}', + ...bounds, + }, + ]); + await expect( + perform(page, parsed.actions[0]!, resolved(parsed)), + ).rejects.toThrow("could decode past the size limit"); + expect(reads.count).toBe(0); + }, + ); }); describe("saved response limits and deadlines", () => { diff --git a/src/browser/network/PlaywrightScenarioNetwork.test.ts b/src/browser/network/PlaywrightScenarioNetwork.test.ts index d570e43fe9..fedc66d8bb 100644 --- a/src/browser/network/PlaywrightScenarioNetwork.test.ts +++ b/src/browser/network/PlaywrightScenarioNetwork.test.ts @@ -256,35 +256,48 @@ it.each([ }, ); -it("reads and redacts marked gzip content its received size bounds", async () => { - const { collector, events, secrets } = harness({ - request_body: false, - response_body: true, - header_values: false, - }); - const request = new RequestFixture("marked"); - const body = '{"token":"secret-token"}'; - request.responseSize = 20; - const response = new ResponseFixture(request, body); - vi.spyOn(response, "headers").mockReturnValue({ - "content-type": "application/json", - "content-encoding": "gzip", - }); - secrets.responseProtector("login", [ - { name: "token", json_pointer: "/token", secret: true }, - ])?.(request); - collector.request(request); - collector.response(response); - collector.finished(request); - await collector.finish(); - const content = events.find( - (event) => event.kind === "network-content" && event.phase === "response", - ); - expect(content).toBeDefined(); - const text = bodyText(content!); - expect(text).not.toContain("secret-token"); - expect(text).toContain("[REDACTED:page:login.token]"); -}); +it.each([ + { size: 20, headers: { "content-encoding": "gzip" } }, + { size: 0, headers: { "content-length": "24" } }, + { size: 0, headers: { "content-length": "0024" } }, + { size: 0, headers: { "content-length": "00000000000000000024" } }, + { + size: 0, + headers: { "content-encoding": "gzip", "content-length": "0024" }, + }, +])( + "reads and redacts marked content within its decoded bound: %j", + async (bounds) => { + const { collector, events, secrets } = harness({ + request_body: false, + response_body: true, + header_values: false, + }); + const request = new RequestFixture("marked"); + const body = '{"token":"secret-token"}'; + request.responseSize = bounds.size; + const response = new ResponseFixture(request, body); + vi.spyOn(response, "headers").mockReturnValue({ + "content-type": "application/json", + ...bounds.headers, + }); + secrets.responseProtector("login", [ + { name: "token", json_pointer: "/token", secret: true }, + ])?.(request); + collector.request(request); + collector.response(response); + collector.finished(request); + await collector.finish(); + const content = events.find( + (event) => event.kind === "network-content" && event.phase === "response", + ); + expect(content).toBeDefined(); + const text = bodyText(content!); + expect(text).not.toContain("secret-token"); + expect(text).toContain("[REDACTED:page:login.token]"); + expect(response.bodyReads).toBe(1); + }, +); it("preserves unselected source spellings while redacting escaped aliases", async () => { const { collector, events, secrets } = harness({ @@ -346,22 +359,28 @@ it("associates sensitive fields by request identity while keeping same-URL unmar ); }); -it.each(["br", "cache"])( - "withholds marked response content when decoded bounds are unknown: %s", - async (kind) => { +it.each([ + { size: 64, headers: { "content-encoding": "br" } }, + { size: 0, headers: {} }, + { size: 0, headers: { "content-length": "0000" } }, + { size: 0, headers: { "content-length": "24, 24" } }, + { size: 0, headers: { "content-length": "0009007199254740993" } }, + { size: 0, headers: { "content-length": "00012000001" } }, +])( + "withholds marked response content when decoded bounds are unknown or over budget: %j", + async (bounds) => { const { collector, events, secrets } = harness(); const request = new RequestFixture("marked"); - if (kind === "cache") request.responseSize = 0; + request.responseSize = bounds.size; const secret = "private-source-token"; const response = new ResponseFixture( request, JSON.stringify({ token: secret }), ); - if (kind === "br") - vi.spyOn(response, "headers").mockReturnValue({ - "content-type": "application/json", - "content-encoding": "br", - }); + vi.spyOn(response, "headers").mockReturnValue({ + "content-type": "application/json", + ...bounds.headers, + }); secrets.responseProtector("login", [ { name: "token", json_pointer: "/token", secret: true }, ])?.(request);