Skip to content

Commit 2929baf

Browse files
Fixes ReDOS vulnerabilities.
Jamie Davis (@davisjam) from Virginia Tech reported that clean-css suffers from ReDOS vulnerability [0] when fed with crafted input. Since not so many people use clean-css allowing untrusted input such cases may be rare, but this commit reworks vulnerable code to prevent such attacks. It also limits certain whitespace blocks to sane length of 31 characters in validation regexes to prevent similar issues. [0] https://snyk.io/blog/redos-and-catastrophic-backtracking
1 parent 9693ae6 commit 2929baf

File tree

8 files changed

+96
-14
lines changed

8 files changed

+96
-14
lines changed

History.md

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* Fixed issue [#861](https://github.com/jakubpawlowicz/clean-css/issues/861) - new `transition` property optimizer.
66
* Fixed issue [#895](https://github.com/jakubpawlowicz/clean-css/issues/895) - ignoring specific styles.
77
* Fixed issue [#947](https://github.com/jakubpawlowicz/clean-css/issues/947) - selector based filtering.
8+
* Fixes ReDOS vulnerabilities in validator code.
89

910
[4.1.10 / 2018-03-05](https://github.com/jakubpawlowicz/clean-css/compare/v4.1.9...v4.1.10)
1011
==================

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -743,6 +743,7 @@ Sorted alphabetically by GitHub handle:
743743
* [@alexlamsl](https://github.com/alexlamsl) (Alex Lam S.L.) for testing early clean-css 4 versions, reporting bugs, and suggesting numerous improvements.
744744
* [@altschuler](https://github.com/altschuler) (Simon Altschuler) for fixing `@import` processing inside comments;
745745
* [@ben-eb](https://github.com/ben-eb) (Ben Briggs) for sharing ideas about CSS optimizations;
746+
* [@davisjam](https://github.com/davisjam) (Jamie Davis) for disclosing ReDOS vulnerabilities;
746747
* [@facelessuser](https://github.com/facelessuser) (Isaac) for pointing out a flaw in clean-css' stateless mode;
747748
* [@grandrath](https://github.com/grandrath) (Martin Grandrath) for improving `minify` method source traversal in ES6;
748749
* [@jmalonzo](https://github.com/jmalonzo) (Jan Michael Alonzo) for a patch removing node.js' old `sys` package;

lib/optimizer/level-2/can-override.js

+20-1
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,24 @@ function unitOrKeywordWithGlobal(propertyName) {
199199
};
200200
}
201201

202+
function unitOrNumber(validator, value1, value2) {
203+
if (!understandable(validator, value1, value2, 0, true) && !(validator.isUnit(value2) || validator.isNumber(value2))) {
204+
return false;
205+
} else if (validator.isVariable(value1) && validator.isVariable(value2)) {
206+
return true;
207+
} else if ((validator.isUnit(value1) || validator.isNumber(value1)) && !(validator.isUnit(value2) || validator.isNumber(value2))) {
208+
return false;
209+
} else if (validator.isUnit(value2) || validator.isNumber(value2)) {
210+
return true;
211+
} else if (validator.isUnit(value1) || validator.isNumber(value1)) {
212+
return false;
213+
} else if (validator.isFunction(value1) && !validator.isPrefixed(value1) && validator.isFunction(value2) && !validator.isPrefixed(value2)) {
214+
return true;
215+
}
216+
217+
return sameFunctionOrValue(validator, value1, value2);
218+
}
219+
202220
function zIndex(validator, value1, value2) {
203221
if (!understandable(validator, value1, value2, 0, true) && !validator.isZIndex(value2)) {
204222
return false;
@@ -217,7 +235,8 @@ module.exports = {
217235
propertyName: propertyName,
218236
time: time,
219237
timingFunction: timingFunction,
220-
unit: unit
238+
unit: unit,
239+
unitOrNumber: unitOrNumber
221240
},
222241
property: {
223242
animationDirection: keywordWithGlobal('animation-direction'),

lib/optimizer/level-2/compactable.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -681,7 +681,7 @@ var compactable = {
681681
defaultValue: 'auto'
682682
},
683683
'line-height': {
684-
canOverride: canOverride.generic.unit,
684+
canOverride: canOverride.generic.unitOrNumber,
685685
defaultValue: 'normal',
686686
shortestValue: '0'
687687
},

lib/optimizer/level-2/remove-unused-at-rules.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ var Token = require('../../tokenizer/token');
88
var animationNameRegex = /^(\-moz\-|\-o\-|\-webkit\-)?animation-name$/;
99
var animationRegex = /^(\-moz\-|\-o\-|\-webkit\-)?animation$/;
1010
var keyframeRegex = /^@(\-moz\-|\-o\-|\-webkit\-)?keyframes /;
11-
var importantRegex = /\s*!important$/;
11+
var importantRegex = /\s{0,31}!important$/;
1212
var optionalMatchingQuotesRegex = /^(['"]?)(.*)\1$/;
1313

1414
function normalize(value) {

lib/optimizer/validator.js

+49-10
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,24 @@ var variableRegexStr = 'var\\(\\-\\-[^\\)]+\\)';
44
var functionAnyRegexStr = '(' + variableRegexStr + '|' + functionNoVendorRegexStr + '|' + functionVendorRegexStr + ')';
55

66
var calcRegex = new RegExp('^(\\-moz\\-|\\-webkit\\-)?calc\\([^\\)]+\\)$', 'i');
7+
var decimalRegex = /[0-9]/;
78
var functionAnyRegex = new RegExp('^' + functionAnyRegexStr + '$', 'i');
8-
var hslColorRegex = /^hsl\(\s*[\-\.\d]+\s*,\s*[\.\d]+%\s*,\s*[\.\d]+%\s*\)|hsla\(\s*[\-\.\d]+\s*,\s*[\.\d]+%\s*,\s*[\.\d]+%\s*,\s*[\.\d]+\s*\)$/;
9+
var hslColorRegex = /^hsl\(\s{0,31}[\-\.]?\d+\s{0,31},\s{0,31}\.?\d+%\s{0,31},\s{0,31}\.?\d+%\s{0,31}\)|hsla\(\s{0,31}[\-\.]?\d+\s{0,31},\s{0,31}\.?\d+%\s{0,31},\s{0,31}\.?\d+%\s{0,31},\s{0,31}\.?\d+\s{0,31}\)$/;
910
var identifierRegex = /^(\-[a-z0-9_][a-z0-9\-_]*|[a-z][a-z0-9\-_]*)$/i;
1011
var longHexColorRegex = /^#[0-9a-f]{6}$/i;
1112
var namedEntityRegex = /^[a-z]+$/i;
1213
var prefixRegex = /^-([a-z0-9]|-)*$/i;
13-
var rgbColorRegex = /^rgb\(\s*[\d]{1,3}\s*,\s*[\d]{1,3}\s*,\s*[\d]{1,3}\s*\)|rgba\(\s*[\d]{1,3}\s*,\s*[\d]{1,3}\s*,\s*[\d]{1,3}\s*,\s*[\.\d]+\s*\)$/;
14+
var rgbColorRegex = /^rgb\(\s{0,31}[\d]{1,3}\s{0,31},\s{0,31}[\d]{1,3}\s{0,31},\s{0,31}[\d]{1,3}\s{0,31}\)|rgba\(\s{0,31}[\d]{1,3}\s{0,31},\s{0,31}[\d]{1,3}\s{0,31},\s{0,31}[\d]{1,3}\s{0,31},\s{0,31}[\.\d]+\s{0,31}\)$/;
1415
var shortHexColorRegex = /^#[0-9a-f]{3}$/i;
15-
var timeRegex = new RegExp('^(\\-?\\+?\\.?\\d+\\.?\\d*(s|ms))$');
1616
var timingFunctionRegex = /^(cubic\-bezier|steps)\([^\)]+\)$/;
17+
var validTimeUnits = ['ms', 's'];
1718
var urlRegex = /^url\([\s\S]+\)$/i;
1819
var variableRegex = new RegExp('^' + variableRegexStr + '$', 'i');
1920

21+
var DECIMAL_DOT = '.';
22+
var MINUS_SIGN = '-';
23+
var PLUS_SIGN = '+';
24+
2025
var Keywords = {
2126
'^': [
2227
'inherit',
@@ -386,7 +391,7 @@ function isNamedEntity(value) {
386391
}
387392

388393
function isNumber(value) {
389-
return value.length > 0 && ('' + parseFloat(value)) === value;
394+
return scanForNumber(value) == value.length;
390395
}
391396

392397
function isRgbColor(value) {
@@ -407,7 +412,10 @@ function isVariable(value) {
407412
}
408413

409414
function isTime(value) {
410-
return timeRegex.test(value);
415+
var numberUpTo = scanForNumber(value);
416+
417+
return numberUpTo == value.length && parseInt(value) === 0 ||
418+
numberUpTo > -1 && validTimeUnits.indexOf(value.slice(numberUpTo + 1)) > -1;
411419
}
412420

413421
function isTimingFunction() {
@@ -418,8 +426,13 @@ function isTimingFunction() {
418426
};
419427
}
420428

421-
function isUnit(compatibleCssUnitRegex, value) {
422-
return compatibleCssUnitRegex.test(value);
429+
function isUnit(validUnits, value) {
430+
var numberUpTo = scanForNumber(value);
431+
432+
return numberUpTo == value.length && parseInt(value) === 0 ||
433+
numberUpTo > -1 && validUnits.indexOf(value.slice(numberUpTo + 1)) > -1 ||
434+
value == 'auto' ||
435+
value == 'inherit';
423436
}
424437

425438
function isUrl(value) {
@@ -432,13 +445,38 @@ function isZIndex(value) {
432445
isKeyword('^')(value);
433446
}
434447

448+
function scanForNumber(value) {
449+
var hasDot = false;
450+
var hasSign = false;
451+
var character;
452+
var i, l;
453+
454+
for (i = 0, l = value.length; i < l; i++) {
455+
character = value[i];
456+
457+
if (i === 0 && (character == PLUS_SIGN || character == MINUS_SIGN)) {
458+
hasSign = true;
459+
} else if (i > 0 && hasSign && (character == PLUS_SIGN || character == MINUS_SIGN)) {
460+
return i - 1;
461+
} else if (character == DECIMAL_DOT && !hasDot) {
462+
hasDot = true;
463+
} else if (character == DECIMAL_DOT && hasDot) {
464+
return i - 1;
465+
} else if (decimalRegex.test(character)) {
466+
continue;
467+
} else {
468+
return i - 1;
469+
}
470+
}
471+
472+
return i;
473+
}
474+
435475
function validator(compatibility) {
436476
var validUnits = Units.slice(0).filter(function (value) {
437477
return !(value in compatibility.units) || compatibility.units[value] === true;
438478
});
439479

440-
var compatibleCssUnitRegex = new RegExp('^(\\-?\\.?\\d+\\.?\\d*(' + validUnits.join('|') + '|)|auto|inherit)$', 'i');
441-
442480
return {
443481
colorOpacity: compatibility.colors.opacity,
444482
isAnimationDirectionKeyword: isKeyword('animation-direction'),
@@ -471,12 +509,13 @@ function validator(compatibility) {
471509
isLineHeightKeyword: isKeyword('line-height'),
472510
isListStylePositionKeyword: isKeyword('list-style-position'),
473511
isListStyleTypeKeyword: isKeyword('list-style-type'),
512+
isNumber: isNumber,
474513
isPrefixed: isPrefixed,
475514
isPositiveNumber: isPositiveNumber,
476515
isRgbColor: isRgbColor,
477516
isStyleKeyword: isKeyword('*-style'),
478517
isTime: isTime,
479-
isUnit: isUnit.bind(null, compatibleCssUnitRegex),
518+
isUnit: isUnit.bind(null, validUnits),
480519
isUrl: isUrl,
481520
isVariable: isVariable,
482521
isWidth: isKeyword('width'),

lib/tokenizer/tokenize.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ var EXTRA_PAGE_BOXES = [
5959
'@right'
6060
];
6161

62-
var REPEAT_PATTERN = /^\[\s*\d+\s*\]$/;
62+
var REPEAT_PATTERN = /^\[\s{0,31}\d+\s{0,31}\]$/;
6363
var RULE_WORD_SEPARATOR_PATTERN = /[\s\(]/;
6464
var TAIL_BROKEN_VALUE_PATTERN = /[\s|\}]*$/;
6565

test/module-test.js

+22
Original file line numberDiff line numberDiff line change
@@ -869,5 +869,27 @@ vows.describe('module tests').addBatch({
869869
'should raise no errors': function (error, minified) {
870870
assert.isEmpty(minified.errors);
871871
}
872+
},
873+
'vulnerabilities': {
874+
'ReDOS in time units': {
875+
'topic': function () {
876+
var prefix = '-+.0';
877+
var pump = [];
878+
var suffix = '-0';
879+
var input;
880+
var i;
881+
882+
for (i = 0; i < 10000; i++) {
883+
pump.push('0000000000');
884+
}
885+
886+
input = '.block{animation:1s test;animation-duration:' + prefix + pump.join('') + suffix + 's}';
887+
888+
return new CleanCSS({ level: { 1: { replaceZeroUnits: false }, 2: true } }).minify(input);
889+
},
890+
'finishes in less than a second': function (error, minified) {
891+
assert.isTrue(minified.stats.timeSpent < 1000);
892+
}
893+
}
872894
}
873895
}).export(module);

0 commit comments

Comments
 (0)