From 2f8fd41487acf380194579ecb6f8b1bbfe116be0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Moreno?= Date: Mon, 10 Jul 2023 21:08:36 +0200 Subject: [PATCH] fix: better handling of whitespace (#585) This is a backport of the following commits squashed to land on `release/v5`: - 717534e - abdd93d - cc6fde2 - 99d8287 Ref: npm#564 Co-authored-by: Luke Karrys Co-authored-by: joaomoreno --- semver.js | 94 +++++++++++++++++++++++++++++++++------------- test/index.js | 12 +++++- test/re.js | 14 +++++++ test/whitespace.js | 63 +++++++++++++++++++++++++++++++ 4 files changed, 156 insertions(+), 27 deletions(-) create mode 100644 test/re.js create mode 100644 test/whitespace.js diff --git a/semver.js b/semver.js index d315d5d6..dcb68334 100644 --- a/semver.js +++ b/semver.js @@ -26,11 +26,39 @@ var MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER || // Max safe segment length for coercion. var MAX_SAFE_COMPONENT_LENGTH = 16 +var MAX_SAFE_BUILD_LENGTH = MAX_LENGTH - 6 + // The actual regexps go on exports.re var re = exports.re = [] +var safeRe = exports.safeRe = [] var src = exports.src = [] var R = 0 +var LETTERDASHNUMBER = '[a-zA-Z0-9-]' + +// Replace some greedy regex tokens to prevent regex dos issues. These regex are +// used internally via the safeRe object since all inputs in this library get +// normalized first to trim and collapse all extra whitespace. The original +// regexes are exported for userland consumption and lower level usage. A +// future breaking change could export the safer regex only with a note that +// all input should have extra whitespace removed. +var safeRegexReplacements = [ + ['\\s', 1], + ['\\d', MAX_LENGTH], + [LETTERDASHNUMBER, MAX_SAFE_BUILD_LENGTH], +] + +function makeSafeRe (value) { + for (var i = 0; i < safeRegexReplacements.length; i++) { + var token = safeRegexReplacements[i][0] + var max = safeRegexReplacements[i][1] + value = value + .split(token + '*').join(token + '{0,' + max + '}') + .split(token + '+').join(token + '{1,' + max + '}') + } + return value +} + // The following Regular Expressions can be used for tokenizing, // validating, and parsing SemVer version strings. @@ -40,14 +68,14 @@ var R = 0 var NUMERICIDENTIFIER = R++ src[NUMERICIDENTIFIER] = '0|[1-9]\\d*' var NUMERICIDENTIFIERLOOSE = R++ -src[NUMERICIDENTIFIERLOOSE] = '[0-9]+' +src[NUMERICIDENTIFIERLOOSE] = '\\d+' // ## Non-numeric Identifier // Zero or more digits, followed by a letter or hyphen, and then zero or // more letters, digits, or hyphens. var NONNUMERICIDENTIFIER = R++ -src[NONNUMERICIDENTIFIER] = '\\d*[a-zA-Z-][a-zA-Z0-9-]*' +src[NONNUMERICIDENTIFIER] = '\\d*[a-zA-Z-]' + LETTERDASHNUMBER + '*' // ## Main Version // Three dot-separated numeric identifiers. @@ -89,7 +117,7 @@ src[PRERELEASELOOSE] = '(?:-?(' + src[PRERELEASEIDENTIFIERLOOSE] + // Any combination of digits, letters, or hyphens. var BUILDIDENTIFIER = R++ -src[BUILDIDENTIFIER] = '[0-9A-Za-z-]+' +src[BUILDIDENTIFIER] = LETTERDASHNUMBER + '+' // ## Build Metadata // Plus sign, followed by one or more period-separated build metadata @@ -174,6 +202,7 @@ src[LONETILDE] = '(?:~>?)' var TILDETRIM = R++ src[TILDETRIM] = '(\\s*)' + src[LONETILDE] + '\\s+' re[TILDETRIM] = new RegExp(src[TILDETRIM], 'g') +safeRe[TILDETRIM] = new RegExp(makeSafeRe(src[TILDETRIM]), 'g') var tildeTrimReplace = '$1~' var TILDE = R++ @@ -189,6 +218,7 @@ src[LONECARET] = '(?:\\^)' var CARETTRIM = R++ src[CARETTRIM] = '(\\s*)' + src[LONECARET] + '\\s+' re[CARETTRIM] = new RegExp(src[CARETTRIM], 'g') +safeRe[CARETTRIM] = new RegExp(makeSafeRe(src[CARETTRIM]), 'g') var caretTrimReplace = '$1^' var CARET = R++ @@ -210,6 +240,7 @@ src[COMPARATORTRIM] = '(\\s*)' + src[GTLT] + // this one has to use the /g flag re[COMPARATORTRIM] = new RegExp(src[COMPARATORTRIM], 'g') +safeRe[COMPARATORTRIM] = new RegExp(makeSafeRe(src[COMPARATORTRIM]), 'g') var comparatorTrimReplace = '$1$2$3' // Something like `1.2.3 - 1.2.4` @@ -238,6 +269,14 @@ for (var i = 0; i < R; i++) { debug(i, src[i]) if (!re[i]) { re[i] = new RegExp(src[i]) + + // Replace all greedy whitespace to prevent regex dos issues. These regex are + // used internally via the safeRe object since all inputs in this library get + // normalized first to trim and collapse all extra whitespace. The original + // regexes are exported for userland consumption and lower level usage. A + // future breaking change could export the safer regex only with a note that + // all input should have extra whitespace removed. + safeRe[i] = new RegExp(makeSafeRe(src[i])) } } @@ -262,7 +301,7 @@ function parse (version, options) { return null } - var r = options.loose ? re[LOOSE] : re[FULL] + var r = options.loose ? safeRe[LOOSE] : safeRe[FULL] if (!r.test(version)) { return null } @@ -317,7 +356,7 @@ function SemVer (version, options) { this.options = options this.loose = !!options.loose - var m = version.trim().match(options.loose ? re[LOOSE] : re[FULL]) + var m = version.trim().match(options.loose ? safeRe[LOOSE] : safeRe[FULL]) if (!m) { throw new TypeError('Invalid Version: ' + version) @@ -731,6 +770,7 @@ function Comparator (comp, options) { return new Comparator(comp, options) } + comp = comp.trim().split(/\s+/).join(' ') debug('comparator', comp, options) this.options = options this.loose = !!options.loose @@ -747,7 +787,7 @@ function Comparator (comp, options) { var ANY = {} Comparator.prototype.parse = function (comp) { - var r = this.options.loose ? re[COMPARATORLOOSE] : re[COMPARATOR] + var r = this.options.loose ? safeRe[COMPARATORLOOSE] : safeRe[COMPARATOR] var m = comp.match(r) if (!m) { @@ -861,9 +901,16 @@ function Range (range, options) { this.loose = !!options.loose this.includePrerelease = !!options.includePrerelease - // First, split based on boolean or || + // First reduce all whitespace as much as possible so we do not have to rely + // on potentially slow regexes like \s*. This is then stored and used for + // future error messages as well. this.raw = range - this.set = range.split(/\s*\|\|\s*/).map(function (range) { + .trim() + .split(/\s+/) + .join(' ') + + // First, split based on boolean or || + this.set = this.raw.split('||').map(function (range) { return this.parseRange(range.trim()) }, this).filter(function (c) { // throw out any that are not relevant for whatever reason @@ -871,7 +918,7 @@ function Range (range, options) { }) if (!this.set.length) { - throw new TypeError('Invalid SemVer Range: ' + range) + throw new TypeError('Invalid SemVer Range: ' + this.raw) } this.format() @@ -890,28 +937,23 @@ Range.prototype.toString = function () { Range.prototype.parseRange = function (range) { var loose = this.options.loose - range = range.trim() // `1.2.3 - 1.2.4` => `>=1.2.3 <=1.2.4` - var hr = loose ? re[HYPHENRANGELOOSE] : re[HYPHENRANGE] + var hr = loose ? safeRe[HYPHENRANGELOOSE] : safeRe[HYPHENRANGE] range = range.replace(hr, hyphenReplace) debug('hyphen replace', range) // `> 1.2.3 < 1.2.5` => `>1.2.3 <1.2.5` - range = range.replace(re[COMPARATORTRIM], comparatorTrimReplace) - debug('comparator trim', range, re[COMPARATORTRIM]) + range = range.replace(safeRe[COMPARATORTRIM], comparatorTrimReplace) + debug('comparator trim', range, safeRe[COMPARATORTRIM]) // `~ 1.2.3` => `~1.2.3` - range = range.replace(re[TILDETRIM], tildeTrimReplace) + range = range.replace(safeRe[TILDETRIM], tildeTrimReplace) // `^ 1.2.3` => `^1.2.3` - range = range.replace(re[CARETTRIM], caretTrimReplace) - - // normalize spaces - range = range.split(/\s+/).join(' ') + range = range.replace(safeRe[CARETTRIM], caretTrimReplace) // At this point, the range is completely trimmed and // ready to be split into comparators. - - var compRe = loose ? re[COMPARATORLOOSE] : re[COMPARATOR] + var compRe = loose ? safeRe[COMPARATORLOOSE] : safeRe[COMPARATOR] var set = range.split(' ').map(function (comp) { return parseComparator(comp, this.options) }, this).join(' ').split(/\s+/) @@ -987,7 +1029,7 @@ function replaceTildes (comp, options) { } function replaceTilde (comp, options) { - var r = options.loose ? re[TILDELOOSE] : re[TILDE] + var r = options.loose ? safeRe[TILDELOOSE] : safeRe[TILDE] return comp.replace(r, function (_, M, m, p, pr) { debug('tilde', comp, _, M, m, p, pr) var ret @@ -1028,7 +1070,7 @@ function replaceCarets (comp, options) { function replaceCaret (comp, options) { debug('caret', comp, options) - var r = options.loose ? re[CARETLOOSE] : re[CARET] + var r = options.loose ? safeRe[CARETLOOSE] : safeRe[CARET] return comp.replace(r, function (_, M, m, p, pr) { debug('caret', comp, _, M, m, p, pr) var ret @@ -1087,7 +1129,7 @@ function replaceXRanges (comp, options) { function replaceXRange (comp, options) { comp = comp.trim() - var r = options.loose ? re[XRANGELOOSE] : re[XRANGE] + var r = options.loose ? safeRe[XRANGELOOSE] : safeRe[XRANGE] return comp.replace(r, function (ret, gtlt, M, m, p, pr) { debug('xRange', comp, ret, gtlt, M, m, p, pr) var xM = isX(M) @@ -1157,10 +1199,10 @@ function replaceXRange (comp, options) { function replaceStars (comp, options) { debug('replaceStars', comp, options) // Looseness is ignored here. star is always as loose as it gets! - return comp.trim().replace(re[STAR], '') + return comp.trim().replace(safeRe[STAR], '') } -// This function is passed to string.replace(re[HYPHENRANGE]) +// This function is passed to string.replace(safeRe[HYPHENRANGE]) // M, m, patch, prerelease, build // 1.2 - 3.4.5 => >=1.2.0 <=3.4.5 // 1.2.3 - 3.4 => >=1.2.0 <3.5.0 Any 3.4.x will do @@ -1471,7 +1513,7 @@ function coerce (version) { return null } - var match = version.match(re[COERCE]) + var match = version.match(safeRe[COERCE]) if (match == null) { return null diff --git a/test/index.js b/test/index.js index 5e57c3fd..0aee90be 100644 --- a/test/index.js +++ b/test/index.js @@ -327,7 +327,8 @@ test('negative range tests', function (t) { ['blerg', '1.2.3'], ['git+https://user:password0123@github.com/foo', '123.0.0', true], ['^1.2.3', '2.0.0-pre'], - ['^1.2.3', false] + ['^1.2.3', false], + ['== 1.0.0 || foo', '2.0.0', { loose: true }] ].forEach(function (v) { var range = v[0] var ver = v[1] @@ -980,3 +981,12 @@ test('really big numeric prerelease value', function (t) { t.strictSame(r.prerelease, [ 'beta', '90071992547409910' ]) t.end() }) + +test('long build id', function (t) { + var longBuild = '-928490632884417731e7af463c92b034d6a78268fc993bcb88a57944' + var shortVersion = '1.1.1' + var longVersion = Number.MAX_SAFE_INTEGER + '.' + Number.MAX_SAFE_INTEGER + '.' + Number.MAX_SAFE_INTEGER + t.equal(semver.valid(shortVersion + longBuild), shortVersion + longBuild) + t.equal(semver.valid(longVersion + longBuild), longVersion + longBuild) + t.end() +}) \ No newline at end of file diff --git a/test/re.js b/test/re.js new file mode 100644 index 00000000..8943e503 --- /dev/null +++ b/test/re.js @@ -0,0 +1,14 @@ +var test = require('tap').test +var semver = require('../') + +test('has a list of src, re, and safeRe', function (t) { + semver.re.forEach(function (r) { return t.match(r, RegExp, 'regexps are regexps') }) + semver.src.forEach(function (s) { return t.match(s, String, 'src is strings') }) + + semver.safeRe.forEach(function (r) { + t.notMatch(r.source, '\\s+', 'safe regex do not contain greedy whitespace') + t.notMatch(r.source, '\\s*', 'safe regex do not contain greedy whitespace') + }) + + t.end() +}) diff --git a/test/whitespace.js b/test/whitespace.js new file mode 100644 index 00000000..9ee6f719 --- /dev/null +++ b/test/whitespace.js @@ -0,0 +1,63 @@ +var test = require('tap').test +var semver = require('../') + +var validRange = semver.validRange +var SemVer = semver.SemVer +var Range = semver.Range +var Comparator = semver.Comparator +var minVersion = semver.minVersion +var minSatisfying = semver.minSatisfying +var maxSatisfying = semver.maxSatisfying + +function s(n, char) { + if (!n) { + n = 500000 + } + if (!char) { + char = ' ' + } + var c = '' + for (var i = 0; i < n; i++) { + c += char + } + return c +} + +test('regex dos via range whitespace', function (t) { + // a range with this much whitespace would take a few minutes to process if + // any redos susceptible regexes were used. there is a global tap timeout per + // file set in the package.json that will error if this test takes too long. + var r = `1.2.3 ${s()} <1.3.0` + + t.equal(new Range(r).range, '1.2.3 <1.3.0') + t.equal(validRange(r), '1.2.3 <1.3.0') + t.equal(minVersion(r).version, '1.2.3') + t.equal(minSatisfying(['1.2.3'], r), '1.2.3') + t.equal(maxSatisfying(['1.2.3'], r), '1.2.3') + + t.end() +}) + +test('range with 0', function (t) { + var r = `1.2.3 ${s(null, '0')} <1.3.0` + t.throws(function () { return new Range(r).range }) + t.equal(validRange(r), null) + t.throws(function () { return minVersion(r).version }) + t.equal(minSatisfying(['1.2.3'], r), null) + t.equal(maxSatisfying(['1.2.3'], r), null) + t.end() +}) + +test('semver version', function (t) { + var v = `${s(125)}1.2.3${s(125)}` + var tooLong = `${s()}1.2.3${s()}` + t.equal(new SemVer(v).version, '1.2.3') + t.throws(function () { return new SemVer(tooLong) }) + t.end() +}) + +test('comparator', function (t) { + var c = `${s()}<${s()}1.2.3${s()}` + t.equal(new Comparator(c).value, '<1.2.3') + t.end() +})