Diffing Dangerously: Infinite Loops and ReDoS in jsdiff
Jan 14, 2026·6 min read
Executive Summary (TL;DR)
The `jsdiff` library (< 8.1.0) fails to handle Unicode line separators correctly in its patch parser. A crafted patch file containing characters like `\u2028` in a filename can trap the parser in an infinite loop, causing memory exhaustion. Additionally, a ReDoS flaw exists in how patch headers are processed. Upgrading to 8.1.0 fixes this by replacing complex regexes with string manipulation.
A critical Denial of Service vulnerability in the popular `jsdiff` library. By exploiting JavaScript's quirk regarding regex dot-matching and line separators, attackers can trigger an infinite loop or catastrophic backtracking (ReDoS), effectively freezing Node.js applications that parse patches.
The Hook: When Diffing Becomes Deadly
We take text processing for granted. It is the boring plumbing of the software world. You have fileA, you have fileB, and you want to know what changed. Enter jsdiff, a library so ubiquitous in the JavaScript ecosystem that it likely powers the testing framework, git client, or code editor you are using right now.
But here is the thing about boring plumbing: when it bursts, the house floods. This vulnerability isn't some complex heap feng shui exploit involving precise memory layout manipulation. It is a testament to how fragile our foundational text processing tools can be when faced with the weirdness of Unicode and the treacherous nature of Regular Expressions.
Imagine a scenario where a developer submits a pull request. The CI/CD pipeline picks it up, attempts to generate a diff for the logs, and suddenly... silence. The server hangs. CPU spikes to 100%. Memory usage climbs until the process crashes. No error message, no stack trace, just a dead process. That is the reality of GHSA-73RR-HH4G-FPGX.
The Flaw: The Invisible Character Trap
The root cause of this vulnerability lies in a misunderstanding of how JavaScript's Regular Expression engine handles the dot (.) atom. Most developers assume . matches "anything except a newline." In JavaScript, "newline" usually means \n (Line Feed) or \r (Carriage Return). However, the ECMAScript specification has a few other characters it considers "line terminators," specifically the Unicode Line Separator (\u2028) and Paragraph Separator (\u2029).
The jsdiff parser iterates through a patch file line by line. When it encounters a file header (lines starting with --- or +++), it attempts to parse the filename using this regex:
/^(---|+++)\s+(.*)\r?$/See that (.*)? It greedily eats characters until it hits a line terminator. If an attacker crafts a patch file where the filename includes \u2028 (e.g., --- my_file\u2028.txt), the . will stop matching right before that invisible character. Consequently, the regex fails to match the whole line.
Here is where the logic flaw kicks in: The parser sees the line starts with ---, so it enters the "parse header" block. But because the regex match failed, the code that extracts the filename and—crucially—advances the parser index is skipped or malfunctions. The loop continues, sees the same --- line again, tries the regex again, fails again, and allocates new objects again. Infinite loop. Game over.
The Code: Regex vs. Substring
Let's look at the smoking gun in src/patch/parse.ts. The fix implemented in version 8.1.0 is a perfect example of "de-regexing" code for performance and security.
The Vulnerable Code:
// The parser tries to identify file headers with a regex
const fileHeader = (/^(---|+++)\s+(.*)\r?$/).exec(diffstr[i]);
if (fileHeader) {
// Extract filename and move on
}
// If the regex fails but the line starts with ---,
// we might get stuck or fall through incorrectly.The Fix (Commit 15a1585):
The maintainers realized that using a regex to split a string by tabs or spaces is overkill and dangerous. They replaced the regex with explicit string manipulation logic.
// Check strictly for the prefix first
const fileHeaderMatch = (/^(---|+++)\s+/).exec(diffstr[i]);
if (fileHeaderMatch) {
const prefix = fileHeaderMatch[1];
// Use substring to grab the rest of the line, avoiding the dot-match pitfall
const data = diffstr[i].substring(3).trim().split('\t', 2);
const header = (data[1] || '').trim();
// ... manual parsing logic ...
}By switching to substring(3) and split('\t'), the code no longer cares about what specific characters are in the filename. It just grabs the raw string data. This bypasses the ReDoS issue entirely and ensures that \u2028 doesn't break the parsing logic.
The Exploit: Crashing the Node Event Loop
Exploiting this is trivially easy and requires no authentication if the target application parses user-supplied patches. This is common in git-integrated tools, code review platforms, or CMS plugins.
Attack Vector 1: The Infinite Loop (OOM)
An attacker creates a .diff file containing the Unicode Line Separator. You don't even need a valid diff content, just the header is enough to trigger the loop.
// poc.js
const Diff = require('diff');
// \u2028 is the Line Separator
const payload = '--- a/hazardous\u2028file.js\n+++ b/hazardous\u2028file.js\n@@ -1 +1 @@\n-foo\n+bar';
try {
// This will hang indefinitely until V8 crashes with "Fatal Error: Heap out of memory"
Diff.parsePatch(payload);
} catch (e) {
console.log("We crashed.");
}Attack Vector 2: ReDoS
Alternatively, the cubic-complexity regex issue can be triggered by sending a header with excessive garbage data designed to trigger backtracking.
Index: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaWhile the infinite loop is more devastating (guaranteed crash), the ReDoS vector effectively allows a low-bandwidth attacker to freeze the server thread for extended periods.
The Impact: Why This Matters
Since jsdiff is a foundational library (receiving millions of downloads per week), the blast radius is massive. Any service that accepts patch files, displays diffs from user input, or processes git history via Node.js is potentially vulnerable.
- Availability Impact: The primary impact is Denial of Service (DoS). For a single-threaded Node.js application, parsing one malicious patch blocks the entire event loop. No other requests can be served.
- Resource Exhaustion: The infinite loop variant doesn't just spin the CPU; it continuously allocates objects inside the loop until the generic heap limit is reached. This causes the application to crash hard, requiring a restart.
- Cost: In a serverless environment (e.g., AWS Lambda), a ReDoS attack that hangs execution for the full timeout period (15 minutes) can rack up computation costs significantly if automated.
The Fix: Cleaning Up the Mess
The mitigation is straightforward: Update jsdiff to version 8.1.0 or later immediately.
If you are stuck on an older version (perhaps due to a transitive dependency you can't control), you must sanitize inputs before they reach the library. You can implement a middleware layer that rejects any input containing \u2028 or \u2029, and enforces a strict length limit on lines to mitigate the ReDoS aspect.
// Emergency Workaround Middleware
function sanitizePatchInput(input) {
if (/[\u2028\u2029]/.test(input)) {
throw new Error("Illegal characters in patch file.");
}
return input;
}However, patching the library is the only robust solution, as the internal parsing logic was fundamentally flawed in how it handled headers.
Official Patches
Fix Analysis (1)
Technical Appendix
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:HAffected Systems
Affected Versions Detail
| Product | Affected Versions | Fixed Version |
|---|---|---|
jsdiff kpdecker | < 8.1.0 | 8.1.0 |
| Attribute | Detail |
|---|---|
| Vulnerability Type | Denial of Service (DoS) |
| Weaknesses | CWE-1333 (ReDoS), CWE-835 (Infinite Loop) |
| CVSS Estimate | 7.5 (High) |
| Attack Vector | Network (Input-based) |
| Affected Component | src/patch/parse.ts (parsePatch) |
| Patch Commit | 15a1585230748c8ae6f8274c202e0c87309142f5 |
MITRE ATT&CK Mapping
Inefficient Regular Expression Complexity and Loop with Unreachable Exit Condition
Known Exploits & Detection
Vulnerability Timeline
Subscribe to updates
Get the latest CVE analysis reports delivered to your inbox.