Fix ReDoS in stripHtml - #308
Conversation
|
🤖 OpenHands is reviewing this PR. Trigger label: This comment was posted by an AI agent (OpenHands). |
rbren
left a comment
There was a problem hiding this comment.
This review was posted by an AI agent (OpenHands).
Taste Rating: 🟢 Good taste
This is exactly how a ReDoS fix should look: replace the unbounded lazy wildcard (?:.|\n)*? with the bounded character class [^<>]*. Because [^<>]* and the required closing > are disjoint, the regex engine cannot backtrack, and each match attempt is bounded by the distance to the next < — the quadratic scanning behavior is structurally eliminated, not papered over. No new dependencies, two-line production change.
I verified the claims locally at the head SHA:
npx mocha test/html.js— both tests pass (11 ms total).- The 160 KB adversarial input (
'a<br'.repeat(40000)) runs in ~2 ms with the new regexes. - Multiline tags (
<div\nclass=x>) and attribute-bearing tags (<br class="x">) still strip correctly, since[^<>]includes\n. - I probed for residual pathological inputs (runs of
<, long unterminated segments, repeated<aaa<aaapatterns) — all linear.
Findings (non-blocking)
- Behavior change on
<inside quoted attribute values (lib/utils.jslines 6–7, inline comment):a<p title="5 < 3">bpreviously produceda\nb; it now producesa<p title="5 b, leaking a raw tag fragment into snippets. A literal<in a quoted attribute value is valid HTML5, so this is not purely a malformed-input case. Rare in real feeds and an acceptable trade-off for the security win, but it should be a conscious decision. - Test relies implicitly on Mocha's default timeout (
test/html.jsline 47, inline comment): the "efficiently" guarantee only holds because the pre-fix ~19 s runtime exceeds Mocha's 2 s default. Worth making explicit so a future global timeout bump doesn't silently mask a regression.
[RISK ASSESSMENT]
- [Overall PR]
⚠️ Risk Assessment: 🟢 LOW
Two-line change confined tolib/utils.jsstripHtml(), which only feedscontentSnippet/getSnippetoutput. No new dependencies, no API changes, existing tests pass, and the only behavioral delta is on an uncommon edge case in already-lossy snippet generation. The security posture strictly improves: crafted feed content can no longer block the event loop.
KEY INSIGHT: Bounding the tag regex at </> boundaries removes backtracking structurally rather than mitigating it, which is the right way to kill a ReDoS.
Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:
- Add a
.agents/skills/custom-codereview-guide.mdfile to your branch (or edit it if one already exists) with the/codereviewtrigger and the context the reviewer is missing. See the customization docs for the required frontmatter format.- Re-request a review — the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
- When your PR is merged, the guideline file goes through normal code review by repository maintainers.
Resolve with AI? Install the iterate skill in your agent and run
/iterateto automatically drive this PR through CI, review, and QA until it's merge-ready.Was this review helpful? React with 👍 or 👎 to give feedback.
✅ APPROVED
|
🤖 OpenHands is reviewing this PR. Trigger label: This comment was posted by an AI agent (OpenHands). |
nikanand04
left a comment
There was a problem hiding this comment.
This review was posted by an AI agent (OpenHands).
Taste Rating: 🟢 Good taste (with two non-blocking notes)
This is the right shape for a ReDoS fix: the unbounded lazy wildcard (?:.|\n)*? is replaced with the bounded class [^<>]*. Because [^<>]* and the required > are disjoint, the engine has nothing to backtrack over, and every match attempt is bounded by the distance to the next < or >. The quadratic behavior is eliminated structurally, not mitigated. Two-line production change, no new dependencies. [^<>] still matches \n, so multiline tags keep working — that's the subtle part the patch got right.
Verification I ran at the head SHA
I reimplemented both the old and new stripHtml bodies verbatim and measured them on this machine (Node v22):
| Input | Old | New |
|---|---|---|
'a<br'.repeat(40000) (~160 KB) |
8451 ms | 0.71 ms |
I also probed for residual pathological inputs against the new regexes — all linear:
'<'.repeat(80000)→ 0.35 ms('<' + 'a'.repeat(50)).repeat(4000)→ 0.76 ms('x<div ' + 'a'.repeat(100)).repeat(2000)→ 0.85 ms'<' + 'a'.repeat(160000)→ 0.30 ms('a<br\n').repeat(30000)→ 1.28 ms
And the existing behavior in test/html.js is preserved: hello<br>world, <h4>hi</h4>my name is, x<div\nclass=y>z, <!-- c -->x, and a<p title="a>b">c all produce identical output before and after. The class of bug is real for this project: stripHtml runs on attacker-supplied feed content via getSnippet → contentSnippet (lib/parser.js:171, lib/parser.js:240), on the main thread. Polynomial rather than exponential, but 8+ seconds of blocked event loop from 160 KB of feed text is a genuine DoS.
Notes (non-blocking)
- Behavior change on
<inside a tag — inline comment onlib/utils.js:7. This is the only observable output delta I could find, and there is no test pinning it. - The regression test's "efficiently" claim is implicit — inline comment on
test/html.js:47. - The committed browser bundles still carry the vulnerable regex.
dist/rss-parser.jsanddist/rss-parser.min.jsare tracked in this repo and still contain(?:.|\n)*?>(0 occurrences of[^<>]). The repo's convention is clearly to rebuilddist/in separate "build distro" commits (bba9cf3,0413e12), so regenerating it is not this PR's job — but bower/browser consumers stay exposed until a maintainer rebuild + release, which is worth tracking on the merge. - No CI evidence on this head SHA.
GET /commits/f8855012/check-runsreturns an empty list, so thetestsworkflow has no recorded run for this commit. The before/after numbers in the description are also just prose. That's why I re-measured independently above; a maintainer should make sure CI actually runs before merging.
One thing worth stating explicitly so it isn't mistaken for a regression in this PR: getSnippet calls entities.decodeHTML() after stripping, so its output can already contain raw </> from </> in the source feed. Snippets are plain text and were never HTML-safe. The fragment leak in note 1 is therefore an output-quality change, not a new XSS class.
[RISK ASSESSMENT]
- [Overall PR]
⚠️ Risk Assessment: 🟢 LOW
Two lines inside one pure string function whose output feeds only contentSnippet/*Snippet fields. No API surface change, no dependency change, no state. The security posture strictly improves (measured 8451 ms → 0.71 ms on the adversarial input), and the single behavioral delta is confined to malformed-or-unusual markup in already-lossy snippet text. Main residual risks are process, not code: missing CI run on the head SHA and the stale dist/ bundles.
VERDICT: ✅ Worth merging — address notes 1 and 2 if convenient; neither blocks.
KEY INSIGHT: Bounding the tag body at </> removes the backtracking rather than merely making it cheaper, which is the only kind of ReDoS fix that stays fixed — the cost is that a literal < inside a tag now truncates the match, and that trade-off deserves a test rather than silence.
Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:
- Add a
.agents/skills/custom-codereview-guide.mdfile to your branch (or edit it if one already exists) with the/codereviewtrigger and the context the reviewer is missing (e.g., "Security concerns about X do not apply here because Y"). See the customization docs for the required frontmatter format.- Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
- When your PR is merged, the guideline file goes through normal code review by repository maintainers.
Resolve with AI? Install the iterate skill in your agent and run
/iterateto automatically drive this PR through CI, review, and QA until it's merge-ready.Was this review helpful? React with 👍 or 👎 to give feedback.
✅ APPROVED
| str = str.replace(/([^\n])<\/?(h|br|p|ul|ol|li|blockquote|section|table|tr|div)(?:.|\n)*?>([^\n])/gm, '$1\n$3') | ||
| str = str.replace(/<(?:.|\n)*?>/gm, ''); | ||
| str = str.replace(/([^\n])<\/?(h|br|p|ul|ol|li|blockquote|section|table|tr|div)[^<>]*>([^\n])/gm, '$1\n$3') | ||
| str = str.replace(/<[^<>]*>/gm, ''); |
There was a problem hiding this comment.
Behavior change: a literal < inside a tag now truncates the match, leaking a fragment into the snippet.
Measured with the old vs. new bodies of stripHtml:
'a<p title="5 < 3">b' OLD -> 'a\nb' NEW -> 'a<p title="5 b'
'a<b<c>d' OLD -> 'ad' NEW -> 'a<bd'
A literal < in a quoted attribute value is valid HTML5, and the HTML5 tokenizer also treats < in tag-name/attribute-name position as an ordinary character, so both cases above are "a tag" to a browser but now leave visible markup in contentSnippet. This is a reasonable trade-off for killing the quadratic scan — the leftover fragment can never be a complete tag, since the match stops precisely at the next < — but it should be a deliberate decision rather than a side effect.
Concretely: add a case to the testCases table in test/html.js pinning the new output for < inside a tag. That documents the intent, and it means a future "improvement" to this regex can't silently change snippet text again.
(If you ever want the old semantics back without the ReDoS, /<[^<>"']*(?:"[^"]*"|'[^']*')?[^<>"']*>/ style attribute-aware matching is possible, but it is materially more complex for a case this rare — I would not do it here.)
stripHtml()can exhibit quadratic runtime on malformed HTML containing repeated unterminated tags.The current regex may repeatedly scan the remaining input while searching for a closing
>, allowing crafted RSS/Atom content to block the Node.js event loop.This change prevents tag matching from crossing another
<, bounding each attempted match and avoiding the pathological repeated scanning behavior.A regression test was added using ~160 KB of repeated unterminated
<brsequences.Benchmark on Node.js v22.16.0 with the same 160 KB input:
The patch is intentionally minimal and does not add dependencies.