Skip to content
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
5 changes: 5 additions & 0 deletions .changeset/strict-semver-leading-zeros.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog-node": patch
---

Reject semver values with leading zeros in local flag evaluation. Per semver 2.0.0 §2, numeric identifiers must not include leading zeros — values like `1.07.3` are not valid semver and should not match targeting conditions. Both override values and flag values are now validated; invalid inputs surface as `InconclusiveMatchError` so the condition does not match.
44 changes: 39 additions & 5 deletions packages/node/src/__tests__/feature-flags.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3291,8 +3291,14 @@ describe('semver parsing', () => {
expect(parseSemver('1.2.3.4.5.6')).toEqual([1, 2, 3])
})

it('handles leading zeros', () => {
expect(parseSemver('01.02.03')).toEqual([1, 2, 3])
it('rejects leading zeros per semver 2.0.0 §2', () => {
expect(() => parseSemver('01.02.03')).toThrow(InconclusiveMatchError)
expect(() => parseSemver('1.07.3')).toThrow(InconclusiveMatchError)
expect(() => parseSemver('001.2.3')).toThrow(InconclusiveMatchError)
// Literal "0" components remain valid.
expect(parseSemver('0.1.0')).toEqual([0, 1, 0])
expect(parseSemver('1.0.0')).toEqual([1, 0, 0])
expect(parseSemver('0.0.0')).toEqual([0, 0, 0])
})

it('throws on invalid input', () => {
Expand Down Expand Up @@ -3639,12 +3645,40 @@ describe('semver operators', () => {
)
})

it('handles leading zeros', () => {
expect(matchProperty({ key: 'version', value: '01.02.03', operator: 'semver_eq' }, { version: '1.2.3' })).toBe(
true
// Per semver 2.0.0 §2, numeric identifiers MUST NOT include leading zeros.
it.each(['01.2.3', '1.02.3', '1.2.03', '1.07.3', '001.2.3'])('rejects leading-zero override value %p', (bad) => {
expect(() => matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_eq' }, { version: bad })).toThrow(
InconclusiveMatchError
)
})

it.each<[string]>([
['semver_eq'],
['semver_neq'],
['semver_gt'],
['semver_gte'],
['semver_lt'],
['semver_lte'],
['semver_tilde'],
['semver_caret'],
])('rejects leading-zero flag value for %s', (operator) => {
expect(() => matchProperty({ key: 'version', value: '1.07.3', operator }, { version: '1.8.0' })).toThrow(
InconclusiveMatchError
)
})

it.each(['01.*', '1.07.*'])('rejects leading-zero wildcard pattern %p', (pattern) => {
expect(() =>
matchProperty({ key: 'version', value: pattern, operator: 'semver_wildcard' }, { version: '1.2.3' })
).toThrow(InconclusiveMatchError)
})

it('still accepts literal zero components', () => {
expect(matchProperty({ key: 'version', value: '0.1.0', operator: 'semver_eq' }, { version: '0.1.0' })).toBe(true)
expect(matchProperty({ key: 'version', value: '1.0.0', operator: 'semver_eq' }, { version: '1.0.0' })).toBe(true)
expect(matchProperty({ key: 'version', value: '0.0.0', operator: 'semver_eq' }, { version: '0.0.0' })).toBe(true)
})

it('handles 4-part versions', () => {
expect(matchProperty({ key: 'version', value: '1.2.3.4', operator: 'semver_eq' }, { version: '1.2.3' })).toBe(
true
Expand Down
34 changes: 23 additions & 11 deletions packages/node/src/extensions/feature-flags/feature-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1329,6 +1329,20 @@ function isValidRegex(regex: string): boolean {

type SemverTuple = [number, number, number]

/**
* Parse a single numeric identifier from a semver string.
* Per semver 2.0.0 §2, numeric identifiers MUST NOT include leading zeros.
*/
function parseSemverNumericIdentifier(part: string, raw: string): number {
if (!/^\d+$/.test(part)) {
throw new InconclusiveMatchError(`Invalid semver: ${raw}`)
}
if (part.length > 1 && part[0] === '0') {
throw new InconclusiveMatchError(`Invalid semver: ${raw}`)
}
return parseInt(part, 10)
}

/**
* Parse a version string into a [major, minor, patch] tuple.
* - Strips leading/trailing whitespace
Expand All @@ -1354,10 +1368,7 @@ function parseSemver(value: string): SemverTuple {
if (part === undefined || part === '') {
return 0
}
if (!/^\d+$/.test(part)) {
throw new InconclusiveMatchError(`Invalid semver: ${value}`)
}
return parseInt(part, 10)
return parseSemverNumericIdentifier(part, value)
}

const major = parsePart(parts[0])
Expand Down Expand Up @@ -1428,10 +1439,14 @@ function computeWildcardBounds(value: string): { lower: SemverTuple; upper: Semv
}

const parts = cleanedText.split('.')
const major = parseInt(parts[0], 10)
if (isNaN(major)) {
throw new InconclusiveMatchError(`Invalid wildcard semver: ${value}`)
const parseWildcardPart = (part: string): number => {
try {
return parseSemverNumericIdentifier(part, value)
} catch {
throw new InconclusiveMatchError(`Invalid wildcard semver: ${value}`)
}
}
const major = parseWildcardPart(parts[0])

let lower: SemverTuple
let upper: SemverTuple
Expand All @@ -1442,10 +1457,7 @@ function computeWildcardBounds(value: string): { lower: SemverTuple; upper: Semv
upper = [major + 1, 0, 0]
} else {
// X.Y.* pattern
const minor = parseInt(parts[1], 10)
if (isNaN(minor)) {
throw new InconclusiveMatchError(`Invalid wildcard semver: ${value}`)
}
const minor = parseWildcardPart(parts[1])
lower = [major, minor, 0]
upper = [major, minor + 1, 0]
}
Expand Down
Loading