From 35e17a717229c1905828c0a0664a1825a985cf28 Mon Sep 17 00:00:00 2001 From: pablodiazjorge <102741971+pablodiazjorge@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:09:07 +0200 Subject: [PATCH] fix(captures): admit schema-valid HAR numbers that evidence cannot publish exactly (#3159) * fix(captures): admit schema-valid HAR numbers that evidence cannot publish exactly * fix(captures): judge overflowing HAR integers by their lexeme --- src/browser/history/HarCapture.test.ts | 77 +++++++++++++++++++++++--- src/browser/history/HarCapture.ts | 47 ++++++++++++---- 2 files changed, 107 insertions(+), 17 deletions(-) diff --git a/src/browser/history/HarCapture.test.ts b/src/browser/history/HarCapture.test.ts index c1e933eb3..3f26b2b10 100644 --- a/src/browser/history/HarCapture.test.ts +++ b/src/browser/history/HarCapture.test.ts @@ -290,7 +290,7 @@ it.each([ }, ); -it("keeps unsafe extension numeric lexemes and rejects unsafe mandatory schema numbers", () => { +it("keeps unsafe numeric lexemes in extension and mandatory schema fields", () => { const text = JSON.stringify(historicalHar()); const result = decodeHarCapture( text.replace('"cache":{}', '"cache":{},"_big":9007199254740993'), @@ -302,12 +302,17 @@ it("keeps unsafe extension numeric lexemes and rejects unsafe mandatory schema n literal: "9007199254740993", }); expect(result.records[0]?.reported).toMatchObject({ _big: null }); - expect(() => - decodeHarCapture( - text.replace('"bodySize":0', '"bodySize":9007199254740993'), - [], - ), - ).toThrow("HAR schema validation failed"); + // The pinned schema admits a valid integer the evidence cannot publish exactly. + const mandatory = decodeHarCapture( + text.replace('"bodySize":0', '"bodySize":9007199254740993'), + [], + ).records[0]; + expect(mandatory?.numeric_literals).toContainEqual({ + pointer: "/request/bodySize", + producer_type: "json-number", + literal: "9007199254740993", + }); + expect(mandatory?.reported).toMatchObject({ request: { bodySize: null } }); }); it.each(["container", "record"] as const)( @@ -693,3 +698,61 @@ it("ignores one leading byte-order mark, as HAR 1.2 requires of readers", () => decodeCaptureText(Buffer.from([0x7b, 0xff, 0x7d]), "HAR"), ).toThrow("HAR is not valid UTF-8 JSON."); }); + +const reportedAt = (reported: unknown, pointer: string): unknown => + pointer + .split("/") + .slice(1) + .reduce( + (value, key) => + value !== null && typeof value === "object" + ? Reflect.get(value, key) + : undefined, + reported, + ); + +it.each([ + { + from: '"time":1.25', + literal: "0.123456789012345678901", + pointer: "/time", + }, + { from: '"bodySize":99', literal: "1e400", pointer: "/response/bodySize" }, +])( + "admits a schema-valid number that evidence cannot publish exactly: $literal", + ({ from, literal, pointer }) => { + const key = from.slice(0, from.indexOf(":") + 1); + const text = JSON.stringify(historicalHar()).replace( + from, + `${key}${literal}`, + ); + expect(text).toContain(`${key}${literal}`); + const record = decodeHarCapture(text, []).records[0]; + // The exact producer lexeme is retained, and the published value stays unknown. + expect(record?.numeric_literals).toContainEqual({ + pointer, + producer_type: "json-number", + literal, + }); + expect(reportedAt(record?.reported, pointer)).toBeNull(); + }, +); + +it.each([ + "9007199254740993.5", + "1e-400", + "1.0000000000000000001", + `1${"0".repeat(309)}.5`, + `-1${"0".repeat(309)}.5`, +])( + "judges an integer field by the lexeme when rounding or overflow would hide a fraction: %s", + (literal) => { + const text = JSON.stringify(historicalHar()).replace( + '"bodySize":99', + `"bodySize":${literal}`, + ); + expect(() => decodeHarCapture(text, [])).toThrow( + "HAR schema validation failed: type (must be integer).", + ); + }, +); diff --git a/src/browser/history/HarCapture.ts b/src/browser/history/HarCapture.ts index c0339bd80..3c0cc86bc 100644 --- a/src/browser/history/HarCapture.ts +++ b/src/browser/history/HarCapture.ts @@ -101,6 +101,7 @@ export const decodeHarCapture = ( }; }; +/** Evidence projection when `redact` is true; the upstream schema validation copy otherwise. */ const projectHar = ( value: unknown, sensitiveValues: readonly string[], @@ -109,6 +110,18 @@ const projectHar = ( const redactor = new CaptureRedaction(sensitiveValues); const numeric_literals: Projection["numeric_literals"] = []; const binary_fields: Projection["binary_fields"] = []; + const projectNumber = (item: LosslessNumber, pointer: string): JsonValue => { + // Evidence publishes an unsafe number as null; the schema must still judge it as a number. + if (!redact) return validationNumber(item.value); + const number = redactor.number(item, pointer, redact); + if (number === null) return null; + numeric_literals.push({ + pointer, + producer_type: "json-number", + literal: number.literal, + }); + return number.reported; + }; const visit = ( item: unknown, pointer: string, @@ -127,16 +140,7 @@ const projectHar = ( redactor.harCredential(pointer, parent) ) return redactor.credential(pointer); - if (item instanceof LosslessNumber) { - const number = redactor.number(item, pointer, redact); - if (number === null) return null; - numeric_literals.push({ - pointer, - producer_type: "json-number", - literal: number.literal, - }); - return number.reported; - } + if (item instanceof LosslessNumber) return projectNumber(item, pointer); if (typeof item === "string") { if ( redact && @@ -235,6 +239,29 @@ const projectHar = ( }; }; +/** + * A JSON number lexeme as the validation copy carries it: its nearest double, unless rounding or + * overflow erased a fractional part, so the schema's `integer` checks the lexeme, not the double. + */ +const validationNumber = (literal: string): number => { + const nearest = Number(literal); + if (integerLexeme(literal)) return nearest; + if (Number.isFinite(nearest) && !Number.isInteger(nearest)) return nearest; + return literal.startsWith("-") ? -0.5 : 0.5; +}; + +/** Whether a JSON number lexeme denotes an integer, decided from its digits and exponent. */ +const integerLexeme = (literal: string): boolean => { + const match = /^-?(\d+)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/.exec(literal); + if (match === null) return false; + const [, whole = "", fraction = "", exponent = "0"] = match; + const digits = `${whole}${fraction}`; + const significant = digits.replace(/0+$/, ""); + if (/^0*$/.test(significant)) return true; + const trailingZeros = digits.length - significant.length; + return Number(exponent) - fraction.length + trailingZeros >= 0; +}; + const decodeHarBase64 = (value: string, pointer: string): Buffer => { const compact = value.replace(/[\t\n\r ]/g, ""); const bytes = Buffer.from(compact, "base64");