-
Notifications
You must be signed in to change notification settings - Fork 960
fix(semver): remove exponential backtracking from the prerelease pattern #3075
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
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
|
|
||
| // 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([]); | ||
| }); | ||
| }); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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; themeasure()helper below has the same wall-clock dependency. Useprocess.cpuUsage()—as the repository already does for bounded-work regression tests—or another deterministic mechanism that excludes scheduler pauses.Useful? React with 👍 / 👎.