Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions src/decode.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,54 @@ describe.each(implementations)("Decode test: %s", (_name, {
});
});

describe("pathologically long numeric entities (11-bit `consumed` overflow)", () => {
/*
* `parseNumericEntity` packs `consumed` into an 11-bit field; long
* digit runs used to wrap the `<< 21` shift and corrupt the advance
* past the entity. Full entity lengths (`#` + [x] + digits + `;`)
* cross the 2047 limit at 2046 decimal / 2045 hex digits, so this
* matrix brackets the boundary on both sides.
*
* Expected strings verified against entities@7.0.1.
*/
const cases = [2044, 2045, 2046, 2047, 2048, 4096].flatMap((digits) =>
[false, true].flatMap((isHex) =>
[true, false].map((hasSemi) => ({ digits, isHex, hasSemi })),
),
);

it.each(
cases,
)("should decode a body of $digits digits (hex: $isHex, semicolon: $hasSemi)", ({
digits,
isHex,
hasSemi,
}) => {
const body = (isHex ? "f" : "1").repeat(digits);
const input = `PRE&#${isHex ? "x" : ""}${body}${hasSemi ? ";" : ""}POST`;
const decoded = "PRE�POST";

expect(decodeHTML(input)).toBe(decoded);
expect(decodeHTMLAttribute(input)).toBe(decoded);
// Strict modes only decode semicolon-terminated entities.
expect(decodeHTMLStrict(input)).toBe(hasSemi ? decoded : input);
expect(decodeXML(input)).toBe(hasSemi ? decoded : input);
});

it("should advance exactly past an overlong entity", () =>
expect(decodeHTML(`&#${"1".repeat(2048)};X`)).toBe("�X"));

it("should not swallow input after an entity with overlong leading zeros", () => {
// The codepoint (38, `&`) is valid; only `consumed` overflows.
const input = `PRE&#${"0".repeat(4090)}38;POST`;

expect(decodeHTML(input)).toBe("PRE&POST");
expect(decodeHTMLStrict(input)).toBe("PRE&POST");
expect(decodeHTMLAttribute(input)).toBe("PRE&POST");
expect(decodeXML(input)).toBe("PRE&POST");
});
});

it("should parse &nbsp followed by < (#852)", () =>
expect(decodeHTML("&nbsp<")).toBe("\u{A0}<"));

Expand Down Expand Up @@ -427,6 +475,20 @@ describe("EntityDecoder", () => {
).toHaveBeenCalledWith(0x3a);
});

it("should clamp overflowing numeric values before reporting them", () => {
const digits = "9".repeat(2048);

expect(decoder.write(`&#${digits};`, 1)).toBe(digits.length + 3);

expect(callback).toHaveBeenCalledTimes(1);
// Consumed: `&` + `#` + digits + `;`.
expect(callback).toHaveBeenCalledWith(0xff_fd, digits.length + 3);
// Clamped to 1 past the Unicode max — not Infinity.
expect(
errorHandlers.validateNumericCharacterReference,
).toHaveBeenCalledWith(0x11_00_00);
});

it("should produce an error for numeric entities without digits", () => {
expect(decoder.write("&#", 1)).toBe(-1);
expect(decoder.end()).toBe(0);
Expand Down
84 changes: 78 additions & 6 deletions src/decode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,8 @@ export class EntityDecoder {
* Parses a hexadecimal numeric entity.
*
* Equivalent to the `Hexademical character reference state` in the HTML spec.
*
* Mirrors the sync `parseNumericEntity`; fixes here must be mirrored there.
* @param input The string containing the entity (or a continuation of the entity).
* @param offset The current offset.
* @returns The number of characters that were consumed, or -1 if the entity is incomplete.
Expand All @@ -219,6 +221,8 @@ export class EntityDecoder {
? char - CharCodes.ZERO
: (char | TO_LOWER_BIT) - CharCodes.LOWER_A + 10;
this.result = this.result * 16 + digit;
// Clamp overflow to 1 past the Unicode max (like `parseNumericEntity`).
if (this.result > 0x10_ff_ff) this.result = 0x11_00_00;
this.consumed += 1;
offset += 1;
} else {
Expand All @@ -232,6 +236,8 @@ export class EntityDecoder {
* Parses a decimal numeric entity.
*
* Equivalent to the `Decimal character reference state` in the HTML spec.
*
* Mirrors the sync `parseNumericEntity`; fixes here must be mirrored there.
* @param input The string containing the entity (or a continuation of the entity).
* @param offset The current offset.
* @returns The number of characters that were consumed, or -1 if the entity is incomplete.
Expand All @@ -241,6 +247,8 @@ export class EntityDecoder {
const char = input.charCodeAt(offset);
if (isNumber(char)) {
this.result = this.result * 10 + (char - CharCodes.ZERO);
// Clamp overflow to 1 past the Unicode max (like `parseNumericEntity`).
if (this.result > 0x10_ff_ff) this.result = 0x11_00_00;
this.consumed += 1;
offset += 1;
} else {
Expand Down Expand Up @@ -630,13 +638,64 @@ function readTrieValue(
);
}

/**
* Maximum value of the 11-bit `consumed` field in `parseNumericEntity`'s
* packed result. Callers that see this value must recompute the true
* length via `numericEntityLength`.
*/
const NUMERIC_CONSUMED_MAX = 0x7_ff;

/**
* Recompute the length of a numeric entity directly from the input.
*
* Only used when the `consumed` field of `parseNumericEntity`'s packed
* result saturates at `NUMERIC_CONSUMED_MAX` (entities of 2047+
* characters), so this cold path favors simplicity over speed.
* @param input The input string.
* @param numberStart Index of the `#` character.
* @returns The number of characters in the entity, starting at (and
* including) the `#`, including the terminating semicolon if
* present.
*/
function numericEntityLength(input: string, numberStart: number): number {
const inputLength = input.length;
let offset = numberStart + 1; // Skip "#"
let isHex = false;

if (offset < inputLength) {
const first = input.charCodeAt(offset);
if (first === CharCodes.LOWER_X || first === CharCodes.UPPER_X) {
isHex = true;
offset += 1;
}
}

while (
offset < inputLength &&
(isNumber(input.charCodeAt(offset)) ||
(isHex && isHexadecimalCharacter(input.charCodeAt(offset))))
) {
offset += 1;
}

// Include the semicolon when present, mirroring `parseNumericEntity`.
if (offset < inputLength && input.charCodeAt(offset) === CharCodes.SEMI) {
offset += 1;
}

return offset - numberStart;
}

/**
* Parse a numeric entity (`&#DDD;` or `&#xHHH;`).
*
* Encodes the result as `(consumed << 21) | codepoint`. The 21-bit codepoint
* field fits any valid Unicode value (max 0x10FFFF), and packing both into
* one integer avoids the file-scope `_numericCp` tuple-by-globals pattern.
* Returns 0 when no digits were found.
*
* Mirrors the streaming `stateNumericDecimal` / `stateNumericHex` in
* `EntityDecoder`; fixes here must be mirrored there.
* @param input The input string.
* @param numberStart Index of the `#` character.
* @param inputLength Cached `input.length`.
Expand Down Expand Up @@ -691,14 +750,19 @@ function parseNumericEntity(
* like `&#3145728;` would silently truncate to a valid-looking
* codepoint instead of mapping to U+FFFD via `replaceCodePoint`.
*
* `consumed` fits 11 bits, which covers any practical entity body.
* Pathologically long digit runs (>2047 chars) overflow the field,
* but those inputs already produce U+FFFD for the entity value, so
* the only observable difference is a slightly wrong advance — never
* an incorrect emitted character.
* `consumed` gets 11 bits, which covers any practical entity body.
* Pathological digit runs would overflow the field and wrap the `<< 21`,
* corrupting the advance past the entity — swallowing or re-emitting
* input, and not only around U+FFFD outputs: with leading zeros (e.g.
* `&#000…038;`) `cp` stays perfectly valid. Instead, `consumed` is
* saturated at NUMERIC_CONSUMED_MAX; callers seeing that value
* recompute the true length with `numericEntityLength` — a cold path
* that keeps the common case branch-predictable and allocation-free.
*/
if (cp > 0x10_ff_ff) cp = 0x11_00_00;
return ((offset - numberStart) << 21) | cp;
let consumed = offset - numberStart;
if (consumed > NUMERIC_CONSUMED_MAX) consumed = NUMERIC_CONSUMED_MAX;
return (consumed << 21) | cp;
}

/**
Expand Down Expand Up @@ -738,6 +802,10 @@ function decodeWithTrie(
if (firstChar === CharCodes.NUM) {
const packed = parseNumericEntity(input, entityStart, inputLength);
consumed = packed >>> 21;
// Saturated `consumed` — recompute the true length (cold path).
if (consumed === NUMERIC_CONSUMED_MAX) {
consumed = numericEntityLength(input, entityStart);
}
// In strict mode, require semicolon termination.
if (
isStrict &&
Expand Down Expand Up @@ -1014,6 +1082,10 @@ export function decodeXML(xmlString: string): string {
xmlString.length,
);
consumed = packed >>> 21;
// Saturated `consumed` — recompute the true length (cold path).
if (consumed === NUMERIC_CONSUMED_MAX) {
consumed = numericEntityLength(xmlString, start);
}
// XML is always strict — require semicolon.
if (
consumed === 0 ||
Expand Down