fix(browser): bound cached responses with zero-padded lengths (#10237)

This commit is contained in:
Kaoru0822 authored and GitHub committed 2026-11-12 20:40:26 -08:00
1 parent feca30c72c
commit 0084810ef7
3 files changed
+93 -56

No files matched your search

+2 -1
View File
@@ -131,10 +131,11 @@ export const decodedBodyBound = (
headers: Readonly<Record<string, string>>,
): 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;
+34 -17
View File
@@ -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", () => {
@@ -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);