Skip to content
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

fix: better handling of whitespace (backport to v5) #585

Merged
merged 1 commit into from Jul 10, 2023
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
94 changes: 68 additions & 26 deletions semver.js
Expand Up @@ -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.

Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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++
Expand All @@ -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++
Expand All @@ -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`
Expand Down Expand Up @@ -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]))
}
}

Expand All @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -861,17 +901,24 @@ 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
return c.length
})

if (!this.set.length) {
throw new TypeError('Invalid SemVer Range: ' + range)
throw new TypeError('Invalid SemVer Range: ' + this.raw)
}

this.format()
Expand All @@ -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+/)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion test/index.js
Expand Up @@ -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]
Expand Down Expand Up @@ -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()
})
14 changes: 14 additions & 0 deletions 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()
})
63 changes: 63 additions & 0 deletions 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()
})