SEC2-080: A hostile JSON-LD description makes the page reader take quadratic time before anything is cut to size #37

Closed
opened 2026-10-17 10:40:16 +00:00 by tiagoagueda · 0 comments
Owner

Found on 2026-10-16 in a read-only security review of postulo-chromium at b6c1009, checked by a second, independent pass.

Severity: Low · Category: Input Validation · Reference: CWE-1333 (Inefficient Regular Expression Complexity); CWE-400 (Uncontrolled Resource Consumption); OWASP A05:2021 (Security Misconfiguration)

Location:

  • src/lib/parse.js:390-399 (fragmentText, the tag-splitting regular expression)
  • src/lib/parse.js:641-665 (fromPosting: described is handed over uncut)
  • src/lib/parse.js:785-800 (tidy cuts to 40 000 characters, after the reading)
  • src/lib/parse.js:898-947 (readPostings: once per posting on a results page)
  • src/lib/browser.js:62-67 (the whole outerHTML is read, unbounded; slimPage caps only what is kept)

Issue

function fragmentText(parse, html) {
  const unescaped = html
    // A tag runs to its closing ">", and a quoted attribute may hold one of its own.
    .split(/(<\/?[a-zA-Z][^>"']*(?:(?:"[^"]*"|'[^']*')[^>"']*)*>)/)

String.prototype.split with a regular expression tries a match at every position. On a
description made of tag openers that never close, each attempt scans to the end of the string and
backtracks, so the time grows with the square of the length. Measured on Node 22 with the
expression copied verbatim: 10 000 characters 0.17 s, 20 000 0.68 s, 40 000 2.7 s; a 400 000-
character description would take minutes, all on the popup's thread. fromPosting passes
posting.description as the page wrote it (src/lib/parse.js:664-665); the 40 000-character cut
in tidy comes after. A results page is read posting by posting (readPostings), so the cost
repeats. MAX_FIELD, MAX_DESCRIPTION and MAX_PAGE bound what is kept and sent, not what is
read.

Impact

A job page that wants to can freeze the popup when the person presses the toolbar button on it;
the capture is never made. No data leaves, nothing is stored, and only a page the person chose to
capture can do it. Low.

Fix

Bound the input before reading it:

// src/lib/parse.js, in fromPosting
const described = typeof posting.description === "string" ? cut(posting.description, 4 * MAX_DESCRIPTION) : "";

This alone bounds the worst case to about 450 ms. Then make the split linear: in fragmentText,
replace the backtracking split with a single pass that walks the string once (two booleans for
"inside a tag" and "inside a quote") and emits the same pieces. Consider also cutting tab.html
to a multiple of MAX_PAGE in readCurrentTab before DOMParser sees it.

Done when a test feeds a 1 MB description of repeated <a to readPosting and it returns within
a second with the description truncated.

#24 and #25 (closed) are about cutting lengths, not reading time.

Found on 2026-10-16 in a read-only security review of `postulo-chromium` at `b6c1009`, checked by a second, independent pass. **Severity:** Low · **Category:** Input Validation · **Reference:** CWE-1333 (Inefficient Regular Expression Complexity); CWE-400 (Uncontrolled Resource Consumption); OWASP A05:2021 (Security Misconfiguration) **Location:** - `src/lib/parse.js:390-399` (`fragmentText`, the tag-splitting regular expression) - `src/lib/parse.js:641-665` (`fromPosting`: `described` is handed over uncut) - `src/lib/parse.js:785-800` (`tidy` cuts to 40 000 characters, after the reading) - `src/lib/parse.js:898-947` (`readPostings`: once per posting on a results page) - `src/lib/browser.js:62-67` (the whole `outerHTML` is read, unbounded; `slimPage` caps only what is kept) ## Issue ```js function fragmentText(parse, html) { const unescaped = html // A tag runs to its closing ">", and a quoted attribute may hold one of its own. .split(/(<\/?[a-zA-Z][^>"']*(?:(?:"[^"]*"|'[^']*')[^>"']*)*>)/) ``` `String.prototype.split` with a regular expression tries a match at every position. On a description made of tag openers that never close, each attempt scans to the end of the string and backtracks, so the time grows with the square of the length. Measured on Node 22 with the expression copied verbatim: 10 000 characters 0.17 s, 20 000 0.68 s, 40 000 2.7 s; a 400 000- character description would take minutes, all on the popup's thread. `fromPosting` passes `posting.description` as the page wrote it (`src/lib/parse.js:664-665`); the 40 000-character cut in `tidy` comes after. A results page is read posting by posting (`readPostings`), so the cost repeats. `MAX_FIELD`, `MAX_DESCRIPTION` and `MAX_PAGE` bound what is kept and sent, not what is read. ## Impact A job page that wants to can freeze the popup when the person presses the toolbar button on it; the capture is never made. No data leaves, nothing is stored, and only a page the person chose to capture can do it. Low. ## Fix Bound the input before reading it: ```js // src/lib/parse.js, in fromPosting const described = typeof posting.description === "string" ? cut(posting.description, 4 * MAX_DESCRIPTION) : ""; ``` This alone bounds the worst case to about 450 ms. Then make the split linear: in `fragmentText`, replace the backtracking split with a single pass that walks the string once (two booleans for "inside a tag" and "inside a quote") and emits the same pieces. Consider also cutting `tab.html` to a multiple of `MAX_PAGE` in `readCurrentTab` before `DOMParser` sees it. Done when a test feeds a 1 MB description of repeated `<a` to `readPosting` and it returns within a second with the description truncated. ## Related #24 and #25 (closed) are about cutting lengths, not reading time.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo-chromium#37
No description provided.