Skip to content

fix: address CVE ReDoS issue #20

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Jun 21, 2025
Merged
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
63 changes: 61 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# HTML Minifier Next (HTMLMinifier)

[![NPM version](https://img.shields.io/npm/v/html-minifier-next.svg)](https://www.npmjs.com/package/html-minifier-next)
[![npm version](https://img.shields.io/npm/v/html-minifier-next.svg)](https://www.npmjs.com/package/html-minifier-next)
<!-- [![Build Status](https://github.com/j9t/html-minifier-next/workflows/CI/badge.svg)](https://github.com/j9t/html-minifier-next/actions?workflow=CI) -->

(This project is based on [Terser’s html-minifier-terser](https://github.com/terser/html-minifier-terser), which in turn is based on [Juriy Zaytsev’s html-minifier](https://github.com/kangax/html-minifier). It was set up because as of May 2025, both html-minifier-terser and html-minifier seem unmaintained. **This project is currently under test.** If it seems maintainable to me, [Jens](https://meiert.com/), even without community support, the project will be updated and documented further. The following documentation largely matches the original project.)
Expand Down Expand Up @@ -70,14 +70,15 @@ How does HTMLMinifier compare to other solutions — [HTML Minifier from Will Pe
| [W3C](https://www.w3.org/) | 51 | **36** | 42 | n/a |
| [Wikipedia](https://en.wikipedia.org/wiki/Main_Page) | 114 | **100** | 107 | n/a |

## Options Quick Reference
## Options quick reference

Most of the options are disabled by default.

| Option | Description | Default |
| --- | --- | --- |
| `caseSensitive` | Treat attributes in case sensitive manner (useful for custom HTML tags) | `false` |
| `collapseBooleanAttributes` | [Omit attribute values from boolean attributes](http://perfectionkills.com/experimenting-with-html-minifier#collapse_boolean_attributes) | `false` |
| `customFragmentQuantifierLimit` | Set maximum quantifier limit for custom fragments to prevent ReDoS attacks | `200` |
| `collapseInlineTagWhitespace` | Don’t leave any spaces between `display:inline;` elements when collapsing. Must be used in conjunction with `collapseWhitespace=true` | `false` |
| `collapseWhitespace` | [Collapse white space that contributes to text nodes in a document tree](http://perfectionkills.com/experimenting-with-html-minifier#collapse_whitespace) | `false` |
| `conservativeCollapse` | Always collapse to 1 space (never remove it entirely). Must be used in conjunction with `collapseWhitespace=true` | `false` |
Expand All @@ -92,6 +93,7 @@ Most of the options are disabled by default.
| `ignoreCustomFragments` | Array of regexes that allow to ignore certain fragments, when matched (e.g. `<?php ... ?>`, `{{ ... }}`, etc.) | `[ /<%[\s\S]*?%>/, /<\?[\s\S]*?\?>/ ]` |
| `includeAutoGeneratedTags` | Insert tags generated by HTML parser | `true` |
| `keepClosingSlash` | Keep the trailing slash on singleton elements | `false` |
| `maxInputLength` | Maximum input length to prevent ReDoS attacks (disabled by default) | `undefined` |
| `maxLineLength` | Specify a maximum line length. Compressed output will be split by newlines at valid HTML split-points |
| `minifyCSS` | Minify CSS in style elements and style attributes (uses [clean-css](https://github.com/jakubpawlowicz/clean-css)) | `false` (could be `true`, `Object`, `Function(text, type)`) |
| `minifyJS` | Minify JavaScript in script elements and event attributes (uses [Terser](https://github.com/terser/terser)) | `false` (could be `true`, `Object`, `Function(text, inline)`) |
Expand Down Expand Up @@ -154,6 +156,63 @@ Output of resulting markup (e.g. `<p>foo</p>`)

HTMLMinifier can’t know that original markup was only half of the tree; it does its best to try to parse it as a full tree and it loses information about tree being malformed or partial in the beginning. As a result, it can’t create a partial/malformed tree at the time of the output.

## Security

### ReDoS protection

This minifier includes protection against regular expression denial of service (ReDoS) attacks:

* Custom fragment quantifier limits: The `customFragmentQuantifierLimit` option (default: 200) prevents exponential backtracking by replacing unlimited quantifiers (`*`, `+`) with bounded ones in regular expressions.

* Input length limits: The `maxInputLength` option allows you to set a maximum input size to prevent processing of excessively large inputs that could cause performance issues.

* Enhanced pattern detection: The minifier detects and warns about various ReDoS-prone patterns including nested quantifiers, alternation with quantifiers, and multiple unlimited quantifiers.

**Important:** When using custom `ignoreCustomFragments`, ensure your regular expressions don’t contain unlimited quantifiers (`*`, `+`) without bounds, as these can lead to ReDoS vulnerabilities.

(Further improvements are needed. Contributions welcome.)

#### Custom fragment examples

**Safe patterns** (recommended):

```javascript
ignoreCustomFragments: [
/<%[\s\S]{0,1000}?%>/, // JSP/ASP with explicit bounds
/<\?php[\s\S]{0,5000}?\?>/, // PHP with bounds
/\{\{[^}]{0,500}\}\}/ // Handlebars without nested braces
]
```
Comment on lines +180 to +185
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Missing closing code fence for safe patterns block
The safe patterns example starts with “javascript” but lacks the corresponding closing “”, which will break Markdown rendering.

Apply:

   /\{\{[^}]{0,500}\}\}/          // Handlebars without nested braces
 ]
+ ```
🤖 Prompt for AI Agents
In README.md around lines 180 to 185, the code block showing safe patterns
starts with a triple backtick but is missing the closing triple backtick. Add
the closing triple backtick ``` after the last line of the code block to
properly close it and fix Markdown rendering.


**Potentially unsafe patterns** (will trigger warnings):

```javascript
ignoreCustomFragments: [
/<%[\s\S]*?%>/, // Unlimited quantifiers
/<!--[\s\S]*?-->/, // Could cause issues with very long comments
/\{\{.*?\}\}/, // Nested unlimited quantifiers
/(script|style)[\s\S]*?/ // Multiple unlimited quantifiers
]
```

**Template engine configurations:**

```javascript
// Handlebars/Mustache
ignoreCustomFragments: [/\{\{[\s\S]{0,1000}?\}\}/]

// Liquid (Jekyll)
ignoreCustomFragments: [/\{%[\s\S]{0,500}?%\}/, /\{\{[\s\S]{0,500}?\}\}/]

// Angular
ignoreCustomFragments: [/\{\{[\s\S]{0,500}?\}\}/]

// Vue.js
ignoreCustomFragments: [/\{\{[\s\S]{0,500}?\}\}/]
```
Comment on lines +200 to +212
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Missing closing code fence for template engine examples
The template-engine config block begins with “```javascript” but is missing the closing triple backticks at the end.

Apply:

   ignoreCustomFragments: [/\{\{[\s\S]{0,500}?\}\}/]
   // Vue.js
   ignoreCustomFragments: [/\{\{[\s\S]{0,500}?\}\}/]
+ ```
🤖 Prompt for AI Agents
In README.md around lines 200 to 212, the code block showing template engine
examples starts with triple backticks and "javascript" but lacks the closing
triple backticks at the end. Add the missing closing triple backticks after the
last line of the code block to properly close the fenced code block.


**Important:** When using custom `ignoreCustomFragments`, the minifier automatically applies bounded quantifiers to prevent ReDoS attacks, but you can also write safer patterns yourself using explicit bounds.

## Running benchmarks

Benchmarks for minified HTML:
Expand Down
4 changes: 3 additions & 1 deletion cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ function parseString(value) {
const mainOptions = {
caseSensitive: 'Treat attributes in case sensitive manner (useful for SVG; e.g. viewBox)',
collapseBooleanAttributes: 'Omit attribute values from boolean attributes',
customFragmentQuantifierLimit: ['Set maximum quantifier limit for custom fragments to prevent ReDoS attacks (default: 200)', parseInt],
collapseInlineTagWhitespace: 'Collapse white space around inline tag',
collapseWhitespace: 'Collapse white space that contributes to text nodes in a document tree.',
conservativeCollapse: 'Always collapse to 1 space (never remove it entirely)',
Expand All @@ -115,6 +116,7 @@ const mainOptions = {
ignoreCustomFragments: ['Array of regex\'es that allow to ignore certain fragments, when matched (e.g. <?php ... ?>, {{ ... }})', parseJSONRegExpArray],
includeAutoGeneratedTags: 'Insert tags generated by HTML parser',
keepClosingSlash: 'Keep the trailing slash on singleton elements',
maxInputLength: ['Maximum input length to prevent ReDoS attacks', parseInt],
maxLineLength: ['Max line length', parseInt],
minifyCSS: ['Minify CSS in style elements and style attributes (uses clean-css)', parseJSON],
minifyJS: ['Minify Javascript in script elements and on* attributes', parseJSON],
Expand Down Expand Up @@ -304,4 +306,4 @@ if (inputDir || outputDir) {
process.stdin.on('data', function (data) {
content += data;
}).on('end', writeMinify);
}
}
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,5 +90,5 @@
"test:web": "NODE_OPTIONS='--experimental-vm-modules --no-warnings' jest --verbose --environment=jsdom"
},
"type": "module",
"version": "1.0.1"
"version": "1.1.0"
}
25 changes: 23 additions & 2 deletions src/htmlminifier.js
Original file line number Diff line number Diff line change
Expand Up @@ -844,6 +844,11 @@ async function createSortFns(value, options, uidIgnore, uidAttr) {
}

async function minifyHTML(value, options, partialMarkup) {
// Check input length limitation to prevent ReDoS attacks
if (options.maxInputLength && value.length > options.maxInputLength) {
throw new Error(`Input length (${value.length}) exceeds maximum allowed length (${options.maxInputLength})`);
}

if (options.collapseWhitespace) {
value = collapseWhitespace(value, options, true, true);
}
Expand Down Expand Up @@ -888,8 +893,24 @@ async function minifyHTML(value, options, partialMarkup) {
return re.source;
});
if (customFragments.length) {
const reCustomIgnore = new RegExp('\\s*(?:' + customFragments.join('|') + ')+\\s*', 'g');
// temporarily replace custom ignored fragments with unique attributes
// Warn about potential ReDoS if custom fragments use unlimited quantifiers
for (let i = 0; i < customFragments.length; i++) {
if (/[*+]/.test(customFragments[i])) {
options.log('Warning: Custom fragment contains unlimited quantifiers (* or +) which may cause ReDoS vulnerability');
break;
}
}

// Safe approach: Use bounded quantifiers instead of unlimited ones to prevent ReDoS
const maxQuantifier = options.customFragmentQuantifierLimit || 200;
const whitespacePattern = `\\s{0,${maxQuantifier}}`;

// Use bounded quantifiers to prevent ReDoS - this approach prevents exponential backtracking
const reCustomIgnore = new RegExp(
whitespacePattern + '(?:' + customFragments.join('|') + '){1,' + maxQuantifier + '}' + whitespacePattern,
'g'
);
// Temporarily replace custom ignored fragments with unique attributes
value = value.replace(reCustomIgnore, function (match) {
if (!uidAttr) {
uidAttr = uniqueId(value);
Expand Down
53 changes: 52 additions & 1 deletion tests/minifier.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -2658,7 +2658,7 @@ test('conservative collapse', async () => {
})).toBe(output);
});

test('collapse preseving a line break', async () => {
test('collapse preserving a line break', async () => {
let input, output;

input = '\n\n\n<!DOCTYPE html> \n<html lang="en" class="no-js">\n' +
Expand Down Expand Up @@ -3594,3 +3594,54 @@ test('minify Content-Security-Policy', async () => {
input = '<meta http-equiv="content-security-policy" content="default-src \'self\'; img-src https://*;">';
expect(await minify(input)).toBe(input);
});

test('ReDoS prevention in custom fragments processing', async () => {
// Test long sequences of whitespace that could trigger ReDoS
const longWhitespace = ' '.repeat(10000);
const phpFragments = [/<%[\s\S]*?%>/g, /<\?[\s\S]*?\?>/g];

// Test case 1: Long whitespace before custom fragment
const input1 = `<div>${longWhitespace}<?php echo "test"; ?></div>`;
const startTime1 = Date.now();
const result1 = await minify(input1, {
ignoreCustomFragments: phpFragments,
collapseWhitespace: true
});
const endTime1 = Date.now();

// Should complete quickly (under 1 second)
expect(endTime1 - startTime1).toBeLessThan(1000);
expect(result1).toContain('<?php echo "test"; ?>');

// Test case 2: Multiple consecutive fragments with long whitespace
const input2 = `<div>${longWhitespace}<?php echo "test1"; ?>${longWhitespace}<?php echo "test2"; ?>${longWhitespace}</div>`;
const startTime2 = Date.now();
const result2 = await minify(input2, {
ignoreCustomFragments: phpFragments,
collapseWhitespace: true
});
const endTime2 = Date.now();

// Should complete quickly (under 1 second)
expect(endTime2 - startTime2).toBeLessThan(1000);
expect(result2).toContain('<?php echo "test1"; ?>');
expect(result2).toContain('<?php echo "test2"; ?>');

// Test case 3: Back-to-back fragments with varying whitespace
const backToBackFragments = Array(100).fill(0).map((_, i) =>
`${' '.repeat(i % 50)}<?php echo "${i}"; ?>`
).join('');
const input3 = `<div>${backToBackFragments}</div>`;

const startTime3 = Date.now();
const result3 = await minify(input3, {
ignoreCustomFragments: phpFragments,
collapseWhitespace: true
});
const endTime3 = Date.now();

// Should complete quickly (under 2 seconds for 100 fragments)
expect(endTime3 - startTime3).toBeLessThan(2000);
expect(result3).toContain('<?php echo "0"; ?>');
expect(result3).toContain('<?php echo "99"; ?>');
});