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
35 changes: 31 additions & 4 deletions src/lib/strict-semver.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,31 @@
const STRICT_SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
// Core and build metadata are unambiguous and stay inline. The prerelease section does not:
// the semver.org pattern for one identifier is
// 0 | [1-9]\d* | [0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*
// whose three alternatives overlap, and wrapping that in `(?:\.…)*` gives a regex engine an
// exponential number of ways to split the same string. CodeQL flagged it (`js/redos`) and the
// cost is real, not theoretical: `0.0.0-0.` followed by repetitions of `--.` took **522ms for a
// single 125-character input** — inside the 128-char ceiling this module already enforced, and
// inside the 96-char one its only caller uses. A length cap does not fix superlinear blowup; it
// only decides where the curve is sampled.
//
// So the prerelease section is matched with one non-backtracking pass and its identifiers are
// validated individually. Each identifier is checked by an anchored regex with no repetition of
// an alternation, which is linear in the identifier's length.
const STRICT_SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;

const NUMERIC_IDENTIFIER_RE = /^(?:0|[1-9]\d*)$/;
const ALPHANUMERIC_IDENTIFIER_RE = /^[0-9A-Za-z-]+$/;

/**
* A prerelease identifier is either a numeric identifier with no leading zero, or an
* alphanumeric one that contains at least one non-digit. Empty identifiers are invalid,
* which is what rejects a trailing or doubled dot.
*/
function isPrereleaseIdentifier(part: string): boolean {
if (part.length === 0) return false;
if (NUMERIC_IDENTIFIER_RE.test(part)) return true;
return ALPHANUMERIC_IDENTIFIER_RE.test(part) && !/^\d+$/.test(part);
}

export interface StrictSemver {
readonly raw: string;
Expand All @@ -10,11 +37,11 @@ export function parseStrictSemver(value: unknown, maxLength = 128): StrictSemver
if (typeof value !== "string" || value.length === 0 || value.length > maxLength) return null;
const match = STRICT_SEMVER_RE.exec(value);
if (!match) return null;
const prereleaseParts = match[4] === undefined ? [] : match[4].split(".");
if (!prereleaseParts.every(isPrereleaseIdentifier)) return null;
return Object.freeze({
raw: value,
core: Object.freeze([BigInt(match[1]!), BigInt(match[2]!), BigInt(match[3]!)]) as readonly [bigint, bigint, bigint],
prerelease: Object.freeze(match[4]
? match[4].split(".").map(part => /^\d+$/.test(part) ? BigInt(part) : part)
: []),
prerelease: Object.freeze(prereleaseParts.map(part => /^\d+$/.test(part) ? BigInt(part) : part)),
});
}
107 changes: 107 additions & 0 deletions tests/strict-semver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { describe, expect, test } from "bun:test";

import { parseStrictSemver } from "../src/lib/strict-semver";

/**
* The prerelease section used to be matched by the semver.org pattern verbatim, whose three
* identifier alternatives overlap. Wrapped in a repetition, that gives a backtracking engine an
* exponential number of ways to split one string. CodeQL flagged it as `js/redos` and the cost
* was real rather than theoretical: a 125-character input took 522ms.
*
* The length ceiling did not help. It only chose where on the curve the input landed.
*/
describe("parseStrictSemver ReDoS resistance", () => {
test("the flagged attack shape stays linear at the length ceiling", () => {
// "0.0.0-0." followed by repetitions of "--." is the input CodeQL named.
const attack = ("0.0.0-0." + "--.".repeat(45)).slice(0, 128);
expect(attack.length).toBe(128);

const started = performance.now();
expect(parseStrictSemver(attack)).toBeNull();
const elapsed = performance.now() - started;
Comment on lines +19 to +21

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Measure parser work with CPU time

On a loaded CI worker, the process can be descheduled or paused for GC between these performance.now() calls, so a correct linear parse taking microseconds of CPU can still report more than 50 ms and fail the suite; the measure() helper below has the same wall-clock dependency. Use process.cpuUsage()—as the repository already does for bounded-work regression tests—or another deterministic mechanism that excludes scheduler pauses.

Useful? React with 👍 / 👎.


// The vulnerable pattern took ~522ms for this input. Anything in that region means the
// superlinear path is back; a linear parse lands three orders of magnitude below it.
expect(elapsed).toBeLessThan(50);
});

test("cost does not grow with the number of repetitions", () => {
const measure = (reps: number): number => {
const input = ("0.0.0-0." + "--.".repeat(reps)).slice(0, 128);
const started = performance.now();
parseStrictSemver(input);
return performance.now() - started;
};

// Under the old pattern, going from 20 to 39 repetitions moved 16ms to 524ms.
measure(20);
const short = measure(20);
const long = measure(39);
expect(short).toBeLessThan(50);
expect(long).toBeLessThan(50);
});

test("the length guard still rejects before any matching work", () => {
const huge = "0.0.0-0." + "--.".repeat(200);
expect(huge.length).toBeGreaterThan(128);
expect(parseStrictSemver(huge)).toBeNull();
expect(parseStrictSemver("1.0.0", 4)).toBeNull();
});
});

describe("parseStrictSemver grammar", () => {
test("accepts the semver.org examples", () => {
for (const valid of [
"0.0.0",
"1.2.3",
"10.20.30",
"1.0.0-alpha",
"1.0.0-alpha.1",
"1.0.0-0.3.7",
"1.0.0-x.7.z.92",
"1.0.0-alpha.beta",
"1.0.0--",
"1.0.0-a-b",
"2.38.0-preview.20260831",
"1.0.0-alpha+001",
"1.0.0+20130313144700",
"1.0.0-beta+exp.sha.5114f85",
"1.0.0+21AF26D3----117B344092BD",
]) {
expect(parseStrictSemver(valid)?.raw).toBe(valid);
}
});

test("rejects leading zeroes, empty identifiers and non-semver shapes", () => {
for (const invalid of [
"01.0.0",
"1.01.0",
"1.0.01",
"1.0",
"1.0.0.0",
"1.0.0-",
"1.0.0-.",
"1.0.0-01",
"1.0.0-00",
"1.0.0-a..b",
"1.0.0-a.",
"1.0.0-a.01",
"1.0.0+",
"v1.0.0",
"1.0.0-alpha_beta",
"",
]) {
expect(parseStrictSemver(invalid)).toBeNull();
}
});

test("splits the prerelease into numeric and alphanumeric identifiers", () => {
const parsed = parseStrictSemver("1.0.0-0.3.7-x");
expect(parsed?.core).toEqual([1n, 0n, 0n]);
expect(parsed?.prerelease).toEqual([0n, 3n, "7-x"]);
});

test("a version with no prerelease has an empty prerelease list", () => {
expect(parseStrictSemver("2.38.0")?.prerelease).toEqual([]);
});
});
Loading