mirror of
https://github.com/morluto/rea.git
synced 2026-11-13 18:22:42 +00:00
fix(browser): bound cached responses with zero-padded lengths (#10237)
This commit is contained in:
1 parent
feca30c72c
commit
0084810ef7
3 files changed
+93
-56
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user