From ec2446705be3049bd9e33af2efa6f5a5c99a2af3 Mon Sep 17 00:00:00 2001 From: dylan Date: Wed, 20 May 2026 13:40:06 -0700 Subject: [PATCH 1/2] fix(node): reject semver values with leading zeros in local evaluation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 validated; invalid inputs surface as InconclusiveMatchError-equivalent so the condition does not match. --- .../node/src/__tests__/feature-flags.spec.ts | 45 ++++++++++++++++--- .../extensions/feature-flags/feature-flags.ts | 34 +++++++++----- 2 files changed, 62 insertions(+), 17 deletions(-) diff --git a/packages/node/src/__tests__/feature-flags.spec.ts b/packages/node/src/__tests__/feature-flags.spec.ts index 0fb01fe15a..79546c4d7e 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,10 +3645,37 @@ describe('semver operators', () => { ) }) - it('handles leading zeros', () => { - expect(matchProperty({ key: 'version', value: '01.02.03', operator: 'semver_eq' }, { version: '1.2.3' })).toBe( - true - ) + it('rejects leading zeros in override values across operators', () => { + // Per semver 2.0.0 §2, numeric identifiers MUST NOT include leading zeros. + for (const bad of ['01.2.3', '1.02.3', '1.2.03', '1.07.3', '001.2.3']) { + expect(() => + matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_eq' }, { version: bad }) + ).toThrow(InconclusiveMatchError) + } + }) + + it('rejects leading zeros in flag values across range operators', () => { + expect(() => + matchProperty({ key: 'version', value: '1.07.3', operator: 'semver_gt' }, { version: '1.8.0' }) + ).toThrow(InconclusiveMatchError) + expect(() => + matchProperty({ key: 'version', value: '01.2.3', operator: 'semver_caret' }, { version: '1.2.3' }) + ).toThrow(InconclusiveMatchError) + expect(() => + matchProperty({ key: 'version', value: '1.02.3', operator: 'semver_tilde' }, { version: '1.2.3' }) + ).toThrow(InconclusiveMatchError) + expect(() => + matchProperty({ key: 'version', value: '01.*', operator: 'semver_wildcard' }, { version: '1.2.3' }) + ).toThrow(InconclusiveMatchError) + expect(() => + matchProperty({ key: 'version', value: '1.07.*', operator: 'semver_wildcard' }, { version: '1.7.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', () => { 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] } From 4d80ef2e5b0ed5cc09349441b37f3ca5bf0cd193 Mon Sep 17 00:00:00 2001 From: dylan Date: Wed, 20 May 2026 14:14:46 -0700 Subject: [PATCH 2/2] fix(node): parametrize semver leading-zero tests + add changeset Address review feedback: the previous "across operators" test used only semver_eq, which is misleading. Converted to it.each so each comparison operator (semver_eq/neq/gt/gte/lt/lte/tilde/caret) and each wildcard pattern is exercised independently. Added the missing changeset for posthog-node. --- .changeset/strict-semver-leading-zeros.md | 5 +++ .../node/src/__tests__/feature-flags.spec.ts | 43 ++++++++++--------- 2 files changed, 27 insertions(+), 21 deletions(-) create mode 100644 .changeset/strict-semver-leading-zeros.md 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 79546c4d7e..d8f3f3ff64 100644 --- a/packages/node/src/__tests__/feature-flags.spec.ts +++ b/packages/node/src/__tests__/feature-flags.spec.ts @@ -3645,30 +3645,31 @@ describe('semver operators', () => { ) }) - it('rejects leading zeros in override values across operators', () => { - // Per semver 2.0.0 §2, numeric identifiers MUST NOT include leading zeros. - for (const bad of ['01.2.3', '1.02.3', '1.2.03', '1.07.3', '001.2.3']) { - expect(() => - matchProperty({ key: 'version', value: '1.2.3', operator: 'semver_eq' }, { version: bad }) - ).toThrow(InconclusiveMatchError) - } + // 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('rejects leading zeros in flag values across range operators', () => { - expect(() => - matchProperty({ key: 'version', value: '1.07.3', operator: 'semver_gt' }, { version: '1.8.0' }) - ).toThrow(InconclusiveMatchError) - expect(() => - matchProperty({ key: 'version', value: '01.2.3', operator: 'semver_caret' }, { version: '1.2.3' }) - ).toThrow(InconclusiveMatchError) - expect(() => - matchProperty({ key: 'version', value: '1.02.3', operator: 'semver_tilde' }, { version: '1.2.3' }) - ).toThrow(InconclusiveMatchError) - expect(() => - matchProperty({ key: 'version', value: '01.*', operator: 'semver_wildcard' }, { version: '1.2.3' }) - ).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: '1.07.*', operator: 'semver_wildcard' }, { version: '1.7.3' }) + matchProperty({ key: 'version', value: pattern, operator: 'semver_wildcard' }, { version: '1.2.3' }) ).toThrow(InconclusiveMatchError) })