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: set max lengths in regex for numeric and build identifiers #571

Merged
merged 1 commit into from
Jun 22, 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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions classes/range.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,15 +98,18 @@ class Range {
const hr = loose ? re[t.HYPHENRANGELOOSE] : re[t.HYPHENRANGE]
range = range.replace(hr, hyphenReplace(this.options.includePrerelease))
debug('hyphen replace', range)

// `> 1.2.3 < 1.2.5` => `>1.2.3 <1.2.5`
range = range.replace(re[t.COMPARATORTRIM], comparatorTrimReplace)
debug('comparator trim', range)

// `~ 1.2.3` => `~1.2.3`
range = range.replace(re[t.TILDETRIM], tildeTrimReplace)
debug('tilde trim', range)

// `^ 1.2.3` => `^1.2.3`
range = range.replace(re[t.CARETTRIM], caretTrimReplace)
debug('caret trim', range)

// At this point, the range is completely trimmed and
// ready to be split into comparators.
Expand Down
5 changes: 5 additions & 0 deletions internal/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ const MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER ||
// Max safe segment length for coercion.
const MAX_SAFE_COMPONENT_LENGTH = 16

// Max safe length for a build identifier. The max length minus 6 characters for
// the shortest version with a build 0.0.0+BUILD.
const MAX_SAFE_BUILD_LENGTH = MAX_LENGTH - 6

const RELEASE_TYPES = [
'major',
'premajor',
Expand All @@ -22,6 +26,7 @@ const RELEASE_TYPES = [
module.exports = {
MAX_LENGTH,
MAX_SAFE_COMPONENT_LENGTH,
MAX_SAFE_BUILD_LENGTH,
MAX_SAFE_INTEGER,
RELEASE_TYPES,
SEMVER_SPEC_VERSION,
Expand Down
41 changes: 28 additions & 13 deletions internal/re.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const { MAX_SAFE_COMPONENT_LENGTH } = require('./constants')
const { MAX_SAFE_COMPONENT_LENGTH, MAX_SAFE_BUILD_LENGTH } = require('./constants')
const debug = require('./debug')
exports = module.exports = {}

Expand All @@ -9,16 +9,31 @@ const src = exports.src = []
const t = exports.t = {}
let R = 0

const 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.
const safeRegexReplacements = [
['\\s', 1],
['\\d', MAX_SAFE_COMPONENT_LENGTH],
[LETTERDASHNUMBER, MAX_SAFE_BUILD_LENGTH],
]

const makeSafeRegex = (value) => {
for (const [token, max] of safeRegexReplacements) {
value = value
.split(`${token}*`).join(`${token}{0,${max}}`)
.split(`${token}+`).join(`${token}{1,${max}}`)
}
return value
}

const createToken = (name, value, isGlobal) => {
// 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.
const safe = value
.split('\\s*').join('\\s{0,1}')
.split('\\s+').join('\\s')
const safe = makeSafeRegex(value)
const index = R++
debug(name, index, value)
t[name] = index
Expand All @@ -34,13 +49,13 @@ const createToken = (name, value, isGlobal) => {
// A single `0`, or a non-zero digit followed by zero or more digits.

createToken('NUMERICIDENTIFIER', '0|[1-9]\\d*')
createToken('NUMERICIDENTIFIERLOOSE', '[0-9]+')
createToken('NUMERICIDENTIFIERLOOSE', '\\d+')

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

createToken('NONNUMERICIDENTIFIER', '\\d*[a-zA-Z-][a-zA-Z0-9-]*')
createToken('NONNUMERICIDENTIFIER', `\\d*[a-zA-Z-]${LETTERDASHNUMBER}*`)

// ## Main Version
// Three dot-separated numeric identifiers.
Expand Down Expand Up @@ -75,7 +90,7 @@ createToken('PRERELEASELOOSE', `(?:-?(${src[t.PRERELEASEIDENTIFIERLOOSE]
// ## Build Metadata Identifier
// Any combination of digits, letters, or hyphens.

createToken('BUILDIDENTIFIER', '[0-9A-Za-z-]+')
createToken('BUILDIDENTIFIER', `${LETTERDASHNUMBER}+`)

// ## Build Metadata
// Plus sign, followed by one or more period-separated build metadata
Expand Down
28 changes: 19 additions & 9 deletions test/integration/whitespace.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,33 +7,43 @@ const minVersion = require('../../ranges/min-version')
const minSatisfying = require('../../ranges/min-satisfying')
const maxSatisfying = require('../../ranges/max-satisfying')

const s = (n = 500000) => ' '.repeat(n)
const wsMedium = ' '.repeat(125)
const wsLarge = ' '.repeat(500000)
const zeroLarge = '0'.repeat(500000)

test('regex dos via range whitespace', (t) => {
// a range with this much whitespace would take a few minutes to process if
test('range with whitespace', (t) => {
// a range with these extra characters 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.
const r = `1.2.3 ${s()} <1.3.0`

const r = `1.2.3 ${wsLarge} <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', (t) => {
const r = `1.2.3 ${zeroLarge} <1.3.0`
t.throws(() => new Range(r).range)
t.equal(validRange(r), null)
t.throws(() => minVersion(r).version)
t.equal(minSatisfying(['1.2.3']), null)
t.equal(maxSatisfying(['1.2.3']), null)
lukekarrys marked this conversation as resolved.
Show resolved Hide resolved
t.end()
})

test('semver version', (t) => {
const v = `${s(125)}1.2.3${s(125)}`
const tooLong = `${s()}1.2.3${s()}`
const v = `${wsMedium}1.2.3${wsMedium}`
const tooLong = `${wsLarge}1.2.3${wsLarge}`
t.equal(new SemVer(v).version, '1.2.3')
t.throws(() => new SemVer(tooLong))
t.end()
})

test('comparator', (t) => {
const c = `${s()}<${s()}1.2.3${s()}`
t.equal(new Comparator(c).value, '<1.2.3')
const comparator = `${wsLarge}<${wsLarge}1.2.3${wsLarge}`
t.equal(new Comparator(comparator).value, '<1.2.3')
t.end()
})