Skip to content

Commit 2f8fd41

Browse files
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: #564 Co-authored-by: Luke Karrys <luke@lukekarrys.com> Co-authored-by: joaomoreno <mail@joaomoreno.com>
1 parent deb5ad5 commit 2f8fd41

File tree

4 files changed

+156
-27
lines changed

4 files changed

+156
-27
lines changed

semver.js

+68-26
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,39 @@ var MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER ||
2626
// Max safe segment length for coercion.
2727
var MAX_SAFE_COMPONENT_LENGTH = 16
2828

29+
var MAX_SAFE_BUILD_LENGTH = MAX_LENGTH - 6
30+
2931
// The actual regexps go on exports.re
3032
var re = exports.re = []
33+
var safeRe = exports.safeRe = []
3134
var src = exports.src = []
3235
var R = 0
3336

37+
var LETTERDASHNUMBER = '[a-zA-Z0-9-]'
38+
39+
// Replace some greedy regex tokens to prevent regex dos issues. These regex are
40+
// used internally via the safeRe object since all inputs in this library get
41+
// normalized first to trim and collapse all extra whitespace. The original
42+
// regexes are exported for userland consumption and lower level usage. A
43+
// future breaking change could export the safer regex only with a note that
44+
// all input should have extra whitespace removed.
45+
var safeRegexReplacements = [
46+
['\\s', 1],
47+
['\\d', MAX_LENGTH],
48+
[LETTERDASHNUMBER, MAX_SAFE_BUILD_LENGTH],
49+
]
50+
51+
function makeSafeRe (value) {
52+
for (var i = 0; i < safeRegexReplacements.length; i++) {
53+
var token = safeRegexReplacements[i][0]
54+
var max = safeRegexReplacements[i][1]
55+
value = value
56+
.split(token + '*').join(token + '{0,' + max + '}')
57+
.split(token + '+').join(token + '{1,' + max + '}')
58+
}
59+
return value
60+
}
61+
3462
// The following Regular Expressions can be used for tokenizing,
3563
// validating, and parsing SemVer version strings.
3664

@@ -40,14 +68,14 @@ var R = 0
4068
var NUMERICIDENTIFIER = R++
4169
src[NUMERICIDENTIFIER] = '0|[1-9]\\d*'
4270
var NUMERICIDENTIFIERLOOSE = R++
43-
src[NUMERICIDENTIFIERLOOSE] = '[0-9]+'
71+
src[NUMERICIDENTIFIERLOOSE] = '\\d+'
4472

4573
// ## Non-numeric Identifier
4674
// Zero or more digits, followed by a letter or hyphen, and then zero or
4775
// more letters, digits, or hyphens.
4876

4977
var NONNUMERICIDENTIFIER = R++
50-
src[NONNUMERICIDENTIFIER] = '\\d*[a-zA-Z-][a-zA-Z0-9-]*'
78+
src[NONNUMERICIDENTIFIER] = '\\d*[a-zA-Z-]' + LETTERDASHNUMBER + '*'
5179

5280
// ## Main Version
5381
// Three dot-separated numeric identifiers.
@@ -89,7 +117,7 @@ src[PRERELEASELOOSE] = '(?:-?(' + src[PRERELEASEIDENTIFIERLOOSE] +
89117
// Any combination of digits, letters, or hyphens.
90118

91119
var BUILDIDENTIFIER = R++
92-
src[BUILDIDENTIFIER] = '[0-9A-Za-z-]+'
120+
src[BUILDIDENTIFIER] = LETTERDASHNUMBER + '+'
93121

94122
// ## Build Metadata
95123
// Plus sign, followed by one or more period-separated build metadata
@@ -174,6 +202,7 @@ src[LONETILDE] = '(?:~>?)'
174202
var TILDETRIM = R++
175203
src[TILDETRIM] = '(\\s*)' + src[LONETILDE] + '\\s+'
176204
re[TILDETRIM] = new RegExp(src[TILDETRIM], 'g')
205+
safeRe[TILDETRIM] = new RegExp(makeSafeRe(src[TILDETRIM]), 'g')
177206
var tildeTrimReplace = '$1~'
178207

179208
var TILDE = R++
@@ -189,6 +218,7 @@ src[LONECARET] = '(?:\\^)'
189218
var CARETTRIM = R++
190219
src[CARETTRIM] = '(\\s*)' + src[LONECARET] + '\\s+'
191220
re[CARETTRIM] = new RegExp(src[CARETTRIM], 'g')
221+
safeRe[CARETTRIM] = new RegExp(makeSafeRe(src[CARETTRIM]), 'g')
192222
var caretTrimReplace = '$1^'
193223

194224
var CARET = R++
@@ -210,6 +240,7 @@ src[COMPARATORTRIM] = '(\\s*)' + src[GTLT] +
210240

211241
// this one has to use the /g flag
212242
re[COMPARATORTRIM] = new RegExp(src[COMPARATORTRIM], 'g')
243+
safeRe[COMPARATORTRIM] = new RegExp(makeSafeRe(src[COMPARATORTRIM]), 'g')
213244
var comparatorTrimReplace = '$1$2$3'
214245

215246
// Something like `1.2.3 - 1.2.4`
@@ -238,6 +269,14 @@ for (var i = 0; i < R; i++) {
238269
debug(i, src[i])
239270
if (!re[i]) {
240271
re[i] = new RegExp(src[i])
272+
273+
// Replace all greedy whitespace to prevent regex dos issues. These regex are
274+
// used internally via the safeRe object since all inputs in this library get
275+
// normalized first to trim and collapse all extra whitespace. The original
276+
// regexes are exported for userland consumption and lower level usage. A
277+
// future breaking change could export the safer regex only with a note that
278+
// all input should have extra whitespace removed.
279+
safeRe[i] = new RegExp(makeSafeRe(src[i]))
241280
}
242281
}
243282

@@ -262,7 +301,7 @@ function parse (version, options) {
262301
return null
263302
}
264303

265-
var r = options.loose ? re[LOOSE] : re[FULL]
304+
var r = options.loose ? safeRe[LOOSE] : safeRe[FULL]
266305
if (!r.test(version)) {
267306
return null
268307
}
@@ -317,7 +356,7 @@ function SemVer (version, options) {
317356
this.options = options
318357
this.loose = !!options.loose
319358

320-
var m = version.trim().match(options.loose ? re[LOOSE] : re[FULL])
359+
var m = version.trim().match(options.loose ? safeRe[LOOSE] : safeRe[FULL])
321360

322361
if (!m) {
323362
throw new TypeError('Invalid Version: ' + version)
@@ -731,6 +770,7 @@ function Comparator (comp, options) {
731770
return new Comparator(comp, options)
732771
}
733772

773+
comp = comp.trim().split(/\s+/).join(' ')
734774
debug('comparator', comp, options)
735775
this.options = options
736776
this.loose = !!options.loose
@@ -747,7 +787,7 @@ function Comparator (comp, options) {
747787

748788
var ANY = {}
749789
Comparator.prototype.parse = function (comp) {
750-
var r = this.options.loose ? re[COMPARATORLOOSE] : re[COMPARATOR]
790+
var r = this.options.loose ? safeRe[COMPARATORLOOSE] : safeRe[COMPARATOR]
751791
var m = comp.match(r)
752792

753793
if (!m) {
@@ -861,17 +901,24 @@ function Range (range, options) {
861901
this.loose = !!options.loose
862902
this.includePrerelease = !!options.includePrerelease
863903

864-
// First, split based on boolean or ||
904+
// First reduce all whitespace as much as possible so we do not have to rely
905+
// on potentially slow regexes like \s*. This is then stored and used for
906+
// future error messages as well.
865907
this.raw = range
866-
this.set = range.split(/\s*\|\|\s*/).map(function (range) {
908+
.trim()
909+
.split(/\s+/)
910+
.join(' ')
911+
912+
// First, split based on boolean or ||
913+
this.set = this.raw.split('||').map(function (range) {
867914
return this.parseRange(range.trim())
868915
}, this).filter(function (c) {
869916
// throw out any that are not relevant for whatever reason
870917
return c.length
871918
})
872919

873920
if (!this.set.length) {
874-
throw new TypeError('Invalid SemVer Range: ' + range)
921+
throw new TypeError('Invalid SemVer Range: ' + this.raw)
875922
}
876923

877924
this.format()
@@ -890,28 +937,23 @@ Range.prototype.toString = function () {
890937

891938
Range.prototype.parseRange = function (range) {
892939
var loose = this.options.loose
893-
range = range.trim()
894940
// `1.2.3 - 1.2.4` => `>=1.2.3 <=1.2.4`
895-
var hr = loose ? re[HYPHENRANGELOOSE] : re[HYPHENRANGE]
941+
var hr = loose ? safeRe[HYPHENRANGELOOSE] : safeRe[HYPHENRANGE]
896942
range = range.replace(hr, hyphenReplace)
897943
debug('hyphen replace', range)
898944
// `> 1.2.3 < 1.2.5` => `>1.2.3 <1.2.5`
899-
range = range.replace(re[COMPARATORTRIM], comparatorTrimReplace)
900-
debug('comparator trim', range, re[COMPARATORTRIM])
945+
range = range.replace(safeRe[COMPARATORTRIM], comparatorTrimReplace)
946+
debug('comparator trim', range, safeRe[COMPARATORTRIM])
901947

902948
// `~ 1.2.3` => `~1.2.3`
903-
range = range.replace(re[TILDETRIM], tildeTrimReplace)
949+
range = range.replace(safeRe[TILDETRIM], tildeTrimReplace)
904950

905951
// `^ 1.2.3` => `^1.2.3`
906-
range = range.replace(re[CARETTRIM], caretTrimReplace)
907-
908-
// normalize spaces
909-
range = range.split(/\s+/).join(' ')
952+
range = range.replace(safeRe[CARETTRIM], caretTrimReplace)
910953

911954
// At this point, the range is completely trimmed and
912955
// ready to be split into comparators.
913-
914-
var compRe = loose ? re[COMPARATORLOOSE] : re[COMPARATOR]
956+
var compRe = loose ? safeRe[COMPARATORLOOSE] : safeRe[COMPARATOR]
915957
var set = range.split(' ').map(function (comp) {
916958
return parseComparator(comp, this.options)
917959
}, this).join(' ').split(/\s+/)
@@ -987,7 +1029,7 @@ function replaceTildes (comp, options) {
9871029
}
9881030

9891031
function replaceTilde (comp, options) {
990-
var r = options.loose ? re[TILDELOOSE] : re[TILDE]
1032+
var r = options.loose ? safeRe[TILDELOOSE] : safeRe[TILDE]
9911033
return comp.replace(r, function (_, M, m, p, pr) {
9921034
debug('tilde', comp, _, M, m, p, pr)
9931035
var ret
@@ -1028,7 +1070,7 @@ function replaceCarets (comp, options) {
10281070

10291071
function replaceCaret (comp, options) {
10301072
debug('caret', comp, options)
1031-
var r = options.loose ? re[CARETLOOSE] : re[CARET]
1073+
var r = options.loose ? safeRe[CARETLOOSE] : safeRe[CARET]
10321074
return comp.replace(r, function (_, M, m, p, pr) {
10331075
debug('caret', comp, _, M, m, p, pr)
10341076
var ret
@@ -1087,7 +1129,7 @@ function replaceXRanges (comp, options) {
10871129

10881130
function replaceXRange (comp, options) {
10891131
comp = comp.trim()
1090-
var r = options.loose ? re[XRANGELOOSE] : re[XRANGE]
1132+
var r = options.loose ? safeRe[XRANGELOOSE] : safeRe[XRANGE]
10911133
return comp.replace(r, function (ret, gtlt, M, m, p, pr) {
10921134
debug('xRange', comp, ret, gtlt, M, m, p, pr)
10931135
var xM = isX(M)
@@ -1157,10 +1199,10 @@ function replaceXRange (comp, options) {
11571199
function replaceStars (comp, options) {
11581200
debug('replaceStars', comp, options)
11591201
// Looseness is ignored here. star is always as loose as it gets!
1160-
return comp.trim().replace(re[STAR], '')
1202+
return comp.trim().replace(safeRe[STAR], '')
11611203
}
11621204

1163-
// This function is passed to string.replace(re[HYPHENRANGE])
1205+
// This function is passed to string.replace(safeRe[HYPHENRANGE])
11641206
// M, m, patch, prerelease, build
11651207
// 1.2 - 3.4.5 => >=1.2.0 <=3.4.5
11661208
// 1.2.3 - 3.4 => >=1.2.0 <3.5.0 Any 3.4.x will do
@@ -1471,7 +1513,7 @@ function coerce (version) {
14711513
return null
14721514
}
14731515

1474-
var match = version.match(re[COERCE])
1516+
var match = version.match(safeRe[COERCE])
14751517

14761518
if (match == null) {
14771519
return null

test/index.js

+11-1
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,8 @@ test('negative range tests', function (t) {
327327
['blerg', '1.2.3'],
328328
['git+https://user:password0123@github.com/foo', '123.0.0', true],
329329
['^1.2.3', '2.0.0-pre'],
330-
['^1.2.3', false]
330+
['^1.2.3', false],
331+
['== 1.0.0 || foo', '2.0.0', { loose: true }]
331332
].forEach(function (v) {
332333
var range = v[0]
333334
var ver = v[1]
@@ -980,3 +981,12 @@ test('really big numeric prerelease value', function (t) {
980981
t.strictSame(r.prerelease, [ 'beta', '90071992547409910' ])
981982
t.end()
982983
})
984+
985+
test('long build id', function (t) {
986+
var longBuild = '-928490632884417731e7af463c92b034d6a78268fc993bcb88a57944'
987+
var shortVersion = '1.1.1'
988+
var longVersion = Number.MAX_SAFE_INTEGER + '.' + Number.MAX_SAFE_INTEGER + '.' + Number.MAX_SAFE_INTEGER
989+
t.equal(semver.valid(shortVersion + longBuild), shortVersion + longBuild)
990+
t.equal(semver.valid(longVersion + longBuild), longVersion + longBuild)
991+
t.end()
992+
})

test/re.js

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
var test = require('tap').test
2+
var semver = require('../')
3+
4+
test('has a list of src, re, and safeRe', function (t) {
5+
semver.re.forEach(function (r) { return t.match(r, RegExp, 'regexps are regexps') })
6+
semver.src.forEach(function (s) { return t.match(s, String, 'src is strings') })
7+
8+
semver.safeRe.forEach(function (r) {
9+
t.notMatch(r.source, '\\s+', 'safe regex do not contain greedy whitespace')
10+
t.notMatch(r.source, '\\s*', 'safe regex do not contain greedy whitespace')
11+
})
12+
13+
t.end()
14+
})

test/whitespace.js

+63
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
var test = require('tap').test
2+
var semver = require('../')
3+
4+
var validRange = semver.validRange
5+
var SemVer = semver.SemVer
6+
var Range = semver.Range
7+
var Comparator = semver.Comparator
8+
var minVersion = semver.minVersion
9+
var minSatisfying = semver.minSatisfying
10+
var maxSatisfying = semver.maxSatisfying
11+
12+
function s(n, char) {
13+
if (!n) {
14+
n = 500000
15+
}
16+
if (!char) {
17+
char = ' '
18+
}
19+
var c = ''
20+
for (var i = 0; i < n; i++) {
21+
c += char
22+
}
23+
return c
24+
}
25+
26+
test('regex dos via range whitespace', function (t) {
27+
// a range with this much whitespace would take a few minutes to process if
28+
// any redos susceptible regexes were used. there is a global tap timeout per
29+
// file set in the package.json that will error if this test takes too long.
30+
var r = `1.2.3 ${s()} <1.3.0`
31+
32+
t.equal(new Range(r).range, '1.2.3 <1.3.0')
33+
t.equal(validRange(r), '1.2.3 <1.3.0')
34+
t.equal(minVersion(r).version, '1.2.3')
35+
t.equal(minSatisfying(['1.2.3'], r), '1.2.3')
36+
t.equal(maxSatisfying(['1.2.3'], r), '1.2.3')
37+
38+
t.end()
39+
})
40+
41+
test('range with 0', function (t) {
42+
var r = `1.2.3 ${s(null, '0')} <1.3.0`
43+
t.throws(function () { return new Range(r).range })
44+
t.equal(validRange(r), null)
45+
t.throws(function () { return minVersion(r).version })
46+
t.equal(minSatisfying(['1.2.3'], r), null)
47+
t.equal(maxSatisfying(['1.2.3'], r), null)
48+
t.end()
49+
})
50+
51+
test('semver version', function (t) {
52+
var v = `${s(125)}1.2.3${s(125)}`
53+
var tooLong = `${s()}1.2.3${s()}`
54+
t.equal(new SemVer(v).version, '1.2.3')
55+
t.throws(function () { return new SemVer(tooLong) })
56+
t.end()
57+
})
58+
59+
test('comparator', function (t) {
60+
var c = `${s()}<${s()}1.2.3${s()}`
61+
t.equal(new Comparator(c).value, '<1.2.3')
62+
t.end()
63+
})

0 commit comments

Comments
 (0)