schemagen integer parser has quadratic complexity for fractional leading zeros #34

Closed
opened 2026-06-28 14:34:08 +00:00 by weaselbot · 0 comments
Member

The integer fallback in generated RootBuilder code strips leading zeros one byte at a time from the front of a std::string. Each erase(digits.begin()) is O(n), so a number with k leading zeros after the decimal point takes O(k^2) time to parse. This makes services using the generated parser vulnerable to algorithmic-complexity denial of service.

Relevant code

File: contrib/schemagen/weaseljson_schemagen.py
Line: 999 (inside the generated parseJsonInt64 function template)

while (!digits.empty() && digits.front() == '0') digits.erase(digits.begin());

Reproduction

Schema (int_schema.json):

{"type": "integer"}

Generate a parser and feed it a number of the form 0.<N-1 zeros>1eN. This is valid JSON with value 1, so it is accepted, but the parser spends quadratic time in the loop above.

Measured on a local build:

N input length time
50k ~50 KB 36 ms
100k ~100 KB 115 ms
200k ~200 KB 486 ms
400k ~400 KB 1975 ms

Root cause

parseJsonInt64 builds a temporary std::string digits containing the integer part followed by all fractional digits, strips trailing zeros, and then removes leading zeros with repeated erase(digits.begin()). Because erasing from the front of a std::string shifts every remaining character, k leading zeros require roughly 1 + 2 + ... + k = O(k^2) byte moves.

The JSON grammar permits leading zeros after the decimal point, so inputs like 0.000...0001eN are valid and accepted by the underlying WeaselJson parser. The generated std::from_chars path rejects them because they contain . and e, so the fallback parseJsonInt64 is always used for this shape.

Expected behavior

Parsing time should scale linearly with the length of the number (or at least not quadratically).

Actual behavior

Parsing time scales quadratically with the number of leading fractional zeros. A few hundred kilobytes of JSON can keep a thread busy for seconds; a few megabytes can keep it busy for minutes.

Impact

Any generated parser with an integer slot is vulnerable to a remote DoS if an attacker can supply a JSON number. The attack payload is small (hundreds of kilobytes), does not require nesting depth, and avoids the parser's explicit depth/overflow limits.

The integer fallback in generated `RootBuilder` code strips leading zeros one byte at a time from the front of a `std::string`. Each `erase(digits.begin())` is O(n), so a number with k leading zeros after the decimal point takes O(k^2) time to parse. This makes services using the generated parser vulnerable to algorithmic-complexity denial of service. **Relevant code** File: `contrib/schemagen/weaseljson_schemagen.py` Line: 999 (inside the generated `parseJsonInt64` function template) ```cpp while (!digits.empty() && digits.front() == '0') digits.erase(digits.begin()); ``` **Reproduction** Schema (`int_schema.json`): ```json {"type": "integer"} ``` Generate a parser and feed it a number of the form `0.<N-1 zeros>1eN`. This is valid JSON with value `1`, so it is accepted, but the parser spends quadratic time in the loop above. Measured on a local build: | N | input length | time | |-------|--------------|--------| | 50k | ~50 KB | 36 ms | | 100k | ~100 KB | 115 ms | | 200k | ~200 KB | 486 ms | | 400k | ~400 KB | 1975 ms| (roughly 4x per doubling) **Root cause** `parseJsonInt64` builds a temporary `std::string digits` containing the integer part followed by all fractional digits, strips trailing zeros, and then removes leading zeros with repeated `erase(digits.begin())`. Because erasing from the front of a `std::string` shifts every remaining character, k leading zeros require roughly 1 + 2 + ... + k = O(k^2) byte moves. The JSON grammar permits leading zeros after the decimal point, so inputs like `0.000...0001eN` are valid and accepted by the underlying `WeaselJson` parser. The generated `std::from_chars` path rejects them because they contain `.` and `e`, so the fallback `parseJsonInt64` is always used for this shape. **Expected behavior** Parsing time should scale linearly with the length of the number (or at least not quadratically). **Actual behavior** Parsing time scales quadratically with the number of leading fractional zeros. A few hundred kilobytes of JSON can keep a thread busy for seconds; a few megabytes can keep it busy for minutes. **Impact** Any generated parser with an `integer` slot is vulnerable to a remote DoS if an attacker can supply a JSON number. The attack payload is small (hundreds of kilobytes), does not require nesting depth, and avoids the parser's explicit depth/overflow limits.
weaselbot was assigned by andrew 2026-06-30 15:45:42 +00:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: weaselab/weaseljson#34