fix(browser): keep malformed Content-Length declarations unknown (#2773)

* fix(browser): keep malformed Content-Length declarations unknown

Response metadata normalized Content-Length with Number(), truncation and
clamping, so "-5", "0x10", "1e3" and "+12" became observed lengths 0, 16,
1000 and 12. RFC 9110 section 8.6 defines the field as 1*DIGIT; any other
spelling declares no length. Parse only decimal digits within the safe
integer range and keep every other value null.

Fixes #2596

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(browser): cover the largest accepted Content-Length

Pin the safe-integer boundary beside the overflow case so a future change
cannot reject the largest representable declared length unnoticed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
yasshmittal 2026-10-13 19:18:55 +05:30 committed by GitHub
parent 4c79127fcb
commit c22d7cd756
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 29 additions and 9 deletions

View File

@ -289,4 +289,25 @@ describe("safe CDP response metadata referrer policy", () => {
expect(captured.response.policies.referrer_policy).toBe(expected);
expect(captured.response.policies.coop).toBe("same-origin");
});
it.each<[string | number | undefined, number | null]>([
["100", 100],
[" 0 ", 0],
[100, 100],
["-5", null],
["+12", null],
["0x10", null],
["1e3", null],
["9007199254740991", 9007199254740991],
["9007199254740992", null],
[undefined, null],
])("declares Content-Length %j only for decimal digits", (raw, expected) => {
const captured = safeResponseMetadata(
"request-length",
`${origin}/api`,
{ headers: raw === undefined ? {} : { "Content-Length": raw } },
new Set([origin]),
);
expect(captured.response.content_length).toBe(expected);
});
});

View File

@ -1,10 +1,6 @@
import type { WebPageInspection } from "../domain/browserObservationSchemas.js";
import { sanitizeBrowserUrl } from "../domain/browserObservation.js";
import {
numberValue,
cdpStringValue,
type UnknownRecord,
} from "./CdpCaptureValues.js";
import { cdpStringValue, type UnknownRecord } from "./CdpCaptureValues.js";
type ResponseMetadata = WebPageInspection["metadata"]["responses"][number];
type LinkMetadata = ResponseMetadata["links"][number];
@ -43,7 +39,7 @@ export const safeResponseMetadata = (
request_id: requestId,
url,
mime_type: boundedHeader(cdpStringValue(response.mimeType)),
content_length: nonnegativeInteger(headers.get("content-length")),
content_length: contentLength(headers.get("content-length")),
content_encoding: boundedHeader(headers.get("content-encoding")),
csp,
links,
@ -343,9 +339,12 @@ const policyToken = (value: string | undefined): string | null => {
return /^[a-z][a-z0-9_.\x2d]*$/u.test(token) ? token : null;
};
const nonnegativeInteger = (value: string | undefined): number | null => {
const number = numberValue(value === undefined ? undefined : Number(value));
return number === undefined ? null : Math.max(0, Math.trunc(number));
// RFC 9110 section 8.6: Content-Length = 1*DIGIT. Other spellings declare no length.
const contentLength = (value: string | undefined): number | null => {
const token = (value ?? "").trim();
if (!/^[0-9]+$/u.test(token)) return null;
const length = Number(token);
return Number.isSafeInteger(length) ? length : null;
};
const boundedHeader = (value: string | undefined): string | null =>