diff --git a/.changeset/strict-semver-leading-zeros.md b/.changeset/strict-semver-leading-zeros.md new file mode 100644 index 0000000000..d27a7c8359 --- /dev/null +++ b/.changeset/strict-semver-leading-zeros.md @@ -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. diff --git a/packages/node/src/__tests__/feature-flags.spec.ts b/packages/node/src/__tests__/feature-flags.spec.ts index 0fb01fe15a..d8f3f3ff64 100644 --- a/packages/node/src/__tests__/feature-flags.spec.ts +++ b/packages/node/src/__tests__/feature-flags.spec.ts @@ -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', () => { @@ -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 diff --git a/packages/node/src/extensions/feature-flags/feature-flags.ts b/packages/node/src/extensions/feature-flags/feature-flags.ts index 9ffef50b43..a81ace14d8 100644 --- a/packages/node/src/extensions/feature-flags/feature-flags.ts +++ b/packages/node/src/extensions/feature-flags/feature-flags.ts @@ -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 @@ -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]) @@ -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 @@ -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] }