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
This commit is contained in:
pablodiazjorge authored and GitHub committed 2026-10-15 16:09:07 +09:00
1 parent f8f3593a8c
commit 35e17a7172
2 files changed
+107 -17

No files matched your search

+70 -7
View File
@@ -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<unknown>(
(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).",
);
},
);
+37 -10
View File
@@ -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");