uBlock/src/js/whitelist.js
Raymond Hill eaedaf5b10
Fix regexes with potential catastrophic backtracking
The quoted email below was sent to ubo-security at raymondhill dot net:

=====
Dear Raymond,

I am writing to report a potential Regular Expression Denial of Service (ReDoS)
vulnerability in the 1p-filters.js script of uBlock Origin. The vulnerability
occurs due to the use of the regular expression /\s+$/, which is used to remove
trailing whitespace. This issue can lead to a denial of service when processing
strings with a large number of trailing spaces, potentially causing a browser to
freeze.

Affected file(s)

    js/1p-filters.js

Vulnerable pattern(s)

    Lines 131 and 167: /\s+$/

Description of the issue

The regular expression /\s+$/ is applied to remove trailing whitespace in user‑
provided content. However, when the content has a large number of spaces
(e.g., ~100,000), this pattern causes excessive backtracking in the regular
expression engine, resulting in performance degradation and UI freezing. This is
a classic ReDoS attack vector.

Steps to reproduce

1. Open the uBlock Origin dashboard and navigate to the My filters tab.
2. Run the following code in the browser's DevTools Console or as a bookmarklet.
3. Observe the UI freezing for several seconds or even longer, depending on the
   number of spaces used.

PoC (Proof of Concept)

/**
 * poc.js — triggers ReDoS in 1p-filters.js
 * Expected: <1 ms; Actual: several seconds – UI freeze
 */
(() => {
  const payload = " ".repeat(100000) + "!";  // 100,000 spaces + sentinel
  const run = () => {
    if (!window.cmEditor) {
      console.error("cmEditor not ready");
      return;
    }
    // Inject payload into the editor
    cmEditor.setValue(payload);

    console.time("ReDoS");
    // Call the vulnerable function (mirroring getEditorText)
    cmEditor.getValue().replace(/\s+$/, '');
    // Alternatively, simulate a realistic user flow:
    // document.querySelector('#userFiltersApply').click();
    console.timeEnd("ReDoS");
  };

  if (document.readyState === "complete") {
    run();
  } else {
    window.addEventListener("load", run, { once: true });
  }
})();

Impact

This issue can significantly degrade the user experience, causing the page to
become unresponsive. If an attacker can inject this malicious string into the
page (for example, through XSS or other attacks), it could lead to a denial of
service (DoS). This vulnerability can be triggered repeatedly, causing the
browser to hang indefinitely.

Suggested fix

The issue can be mitigated by replacing /\s+$/ with a more efficient solution,
such as a look‑behind assertion /(?<=\S)\s+$/ (available in modern browsers)
which ensures no backtracking occurs, or using trimEnd() for legacy support:

// Example of using look-behind:
cmEditor.setValue(text.replace(/(?<=\S)\s+$/, '') + '\n\n');

// Alternatively, using trimEnd():
cmEditor.setValue(text.trimEnd() + '\n\n');

Additional information

If required, I am happy to assist in testing or provide more information.
Please feel free to contact me for further clarification.

Best regards,
[redacted]
=====
2025-04-15 12:47:02 -04:00

258 lines
8.1 KiB
JavaScript

/*******************************************************************************
uBlock Origin - a comprehensive, efficient content blocker
Copyright (C) 2014-2018 Raymond Hill
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see {http://www.gnu.org/licenses/}.
Home: https://github.com/gorhill/uBlock
*/
/* global CodeMirror, uBlockDashboard */
import { dom, qs$ } from './dom.js';
import { i18n$ } from './i18n.js';
/******************************************************************************/
const reComment = /^\s*#\s*/;
function directiveFromLine(line) {
const match = reComment.exec(line);
return match === null
? line.trim()
: line.slice(match.index + match[0].length).trim();
}
/******************************************************************************/
CodeMirror.defineMode("ubo-whitelist-directives", function() {
const reRegex = /^\/.+\/$/;
return {
token: function token(stream) {
const line = stream.string.trim();
stream.skipToEnd();
if ( reBadHostname === undefined ) {
return null;
}
if ( reComment.test(line) ) {
return 'comment';
}
if ( line.indexOf('/') === -1 ) {
if ( reBadHostname.test(line) ) { return 'error'; }
if ( whitelistDefaultSet.has(line.trim()) ) {
return 'keyword';
}
return null;
}
if ( reRegex.test(line) ) {
try {
new RegExp(line.slice(1, -1));
} catch {
return 'error';
}
return null;
}
if ( reHostnameExtractor.test(line) === false ) {
return 'error';
}
if ( whitelistDefaultSet.has(line.trim()) ) {
return 'keyword';
}
return null;
}
};
});
let reBadHostname;
let reHostnameExtractor;
let whitelistDefaultSet = new Set();
/******************************************************************************/
const messaging = vAPI.messaging;
const noopFunc = function(){};
let cachedWhitelist = '';
const cmEditor = new CodeMirror(qs$('#whitelist'), {
autofocus: true,
lineNumbers: true,
lineWrapping: true,
styleActiveLine: true,
});
uBlockDashboard.patchCodeMirrorEditor(cmEditor);
/******************************************************************************/
function getEditorText() {
const text = cmEditor.getValue().trimEnd();
return text === '' ? text : `${text}\n`;
}
function setEditorText(text) {
cmEditor.setValue(`${text.trimEnd()}\n`);
}
/******************************************************************************/
function whitelistChanged() {
const whitelistElem = qs$('#whitelist');
const bad = qs$(whitelistElem, '.cm-error') !== null;
const changedWhitelist = getEditorText().trim();
const changed = changedWhitelist !== cachedWhitelist;
qs$('#whitelistApply').disabled = !changed || bad;
qs$('#whitelistRevert').disabled = !changed;
CodeMirror.commands.save = changed && !bad ? applyChanges : noopFunc;
}
cmEditor.on('changes', whitelistChanged);
/******************************************************************************/
async function renderWhitelist() {
const details = await messaging.send('dashboard', {
what: 'getWhitelist',
});
const first = reBadHostname === undefined;
if ( first ) {
reBadHostname = new RegExp(details.reBadHostname);
reHostnameExtractor = new RegExp(details.reHostnameExtractor);
whitelistDefaultSet = new Set(details.whitelistDefault);
}
const toAdd = new Set(whitelistDefaultSet);
for ( const line of details.whitelist ) {
const directive = directiveFromLine(line);
if ( whitelistDefaultSet.has(directive) === false ) { continue; }
toAdd.delete(directive);
if ( toAdd.size === 0 ) { break; }
}
if ( toAdd.size !== 0 ) {
details.whitelist.push(...Array.from(toAdd).map(a => `# ${a}`));
}
details.whitelist.sort((a, b) => {
const ad = directiveFromLine(a);
const bd = directiveFromLine(b);
const abuiltin = whitelistDefaultSet.has(ad);
if ( abuiltin !== whitelistDefaultSet.has(bd) ) {
return abuiltin ? -1 : 1;
}
return ad.localeCompare(bd);
});
const whitelistStr = details.whitelist.join('\n').trim();
cachedWhitelist = whitelistStr;
setEditorText(whitelistStr);
if ( first ) {
cmEditor.clearHistory();
}
}
/******************************************************************************/
function handleImportFilePicker() {
const file = this.files[0];
if ( file === undefined || file.name === '' ) { return; }
if ( file.type.indexOf('text') !== 0 ) { return; }
const fr = new FileReader();
fr.onload = ev => {
if ( ev.type !== 'load' ) { return; }
const content = uBlockDashboard.mergeNewLines(
getEditorText().trim(),
fr.result.trim()
);
setEditorText(content);
};
fr.readAsText(file);
}
/******************************************************************************/
function startImportFilePicker() {
const input = qs$('#importFilePicker');
// Reset to empty string, this will ensure an change event is properly
// triggered if the user pick a file, even if it is the same as the last
// one picked.
input.value = '';
input.click();
}
/******************************************************************************/
function exportWhitelistToFile() {
const val = getEditorText();
if ( val === '' ) { return; }
const filename =
i18n$('whitelistExportFilename')
.replace('{{datetime}}', uBlockDashboard.dateNowToSensibleString())
.replace(/ +/g, '_');
vAPI.download({
'url': `data:text/plain;charset=utf-8,${encodeURIComponent(val + '\n')}`,
'filename': filename
});
}
/******************************************************************************/
async function applyChanges() {
cachedWhitelist = getEditorText().trim();
await messaging.send('dashboard', {
what: 'setWhitelist',
whitelist: cachedWhitelist,
});
renderWhitelist();
}
function revertChanges() {
setEditorText(cachedWhitelist);
}
/******************************************************************************/
function getCloudData() {
return getEditorText();
}
function setCloudData(data, append) {
if ( typeof data !== 'string' ) { return; }
if ( append ) {
data = uBlockDashboard.mergeNewLines(getEditorText().trim(), data);
}
setEditorText(data.trim());
}
self.cloud.onPush = getCloudData;
self.cloud.onPull = setCloudData;
/******************************************************************************/
self.wikilink = 'https://github.com/gorhill/uBlock/wiki/Dashboard:-Trusted-sites';
self.hasUnsavedData = function() {
return getEditorText().trim() !== cachedWhitelist;
};
/******************************************************************************/
dom.on('#importWhitelistFromFile', 'click', startImportFilePicker);
dom.on('#importFilePicker', 'change', handleImportFilePicker);
dom.on('#exportWhitelistToFile', 'click', exportWhitelistToFile);
dom.on('#whitelistApply', 'click', ( ) => { applyChanges(); });
dom.on('#whitelistRevert', 'click', revertChanges);
renderWhitelist();
/******************************************************************************/