graphql-scalars PhoneNumber scalar is vulnerable to regular expression denial of service
CVE-2026-115887 · GHSA-x8rm-f576-gq67 · published 28 Oct 2026
How serious is it
- Severity: high, CVSS score 7.5 out of 10.
- No exploit prediction (EPSS) score yet.
- Not on the CISA list of exploited vulnerabilities.
- No public exploit in Exploit-DB.
Is your app affected by CVE-2026-115887?
Paste this prompt into Claude Code, Cursor or any AI coding tool: it runs a free Shippable scan of your project and fixes what it finds. Built with Lovable, Bolt or v0? Scan your live app. Or upload your package file.
Who is affected
graphql-scalars(npm) <= 2.0.0
How to check
- Run
npm ls graphql-scalarsin your project to see the installed version, including copies pulled in by other packages. - Or paste your lockfile into the checker: it lists every package with a known vulnerability, this one included.
- Or run
npx shippable-dev scan localhost:3000in your project folder. It reads your lockfile and flags this vulnerability along with every other known one.
How to fix
Copies a prompt for Claude Code, Cursor or Codex: it checks your lockfiles for this and upgrades it.
- Upgrade
graphql-scalarsto 2.0.1 or later, commit the lockfile and redeploy. - If a vulnerable version was live, check your logs and rotate the secrets that server could read.
The advisory
## Summary
The regular expression behind `GraphQLPhoneNumber` since 1.24.1 backtracks exponentially. A string made of a run of digits followed by one character that makes the match fail (for example `"1".repeat(30) + "x"`) blocks the JavaScript thread for seconds, and every additional digit doubles the time.
graphql-js calls `parseLiteral` while *validating* a document and `parseValue` while coercing variables, so the regex runs for every request that merely contains such a value in a `PhoneNumber` position, before execution and before any authentication or authorization step of the server. One small unauthenticated request is enough to stall a server process.
## Affected versions
- `graphql-scalars` >= 1.24.1, <= 2.0.0 (2.0.0 is the current `latest`; the default branch still has the same pattern).
- Not affected: 1.24.0 and the earlier versions I checked (1.22.4, 1.23.0), which use the linear `/^\+[1-9]\d{6,14}$/`.
- Introduced by #2719 (c7f58da16, "PhoneNumber scalar is not fully compatible with E.164 format so updated regex to support phone number with extension"), released as 1.24.1 on 2025-01-29.
The payload was ran against 1.22.4, 1.23.0, 1.24.0, 1.24.1, 1.24.2, 1.25.0, 1.26.0 and 2.0.0: the first three reject it instantly, the other five show the exponential growth.
## Root cause
`src/scalars/PhoneNumber.ts`:
```js
const PHONE_NUMBER_REGEX = /^\+?\d{1,3}(\-|\x20)?\(?\d+\)?((\-|\x20)?\d+)+$/;
```
In `\d+\)?((\-|\x20)?\d+)+$` the separator is optional, so a run of n digits can be split across the inner `\d+` and the outer `+` iterations in a number of ways that is exponential in n. When the overall match fails, for instance on a trailing non-digit, the engine tries all of them.
## Proof of concept
1. The scalar alone (`npm i graphql graphql-scalars@2.0.0`):
```js
import { GraphQLPhoneNumber } from 'graphql-scalars';
for (const ok of ['+41791234567', '079 123 45 67', '+1 (123) 456-7890', 'nope']) {
try { GraphQLPhoneNumber.parseValue(ok); } catch {}
}
for (const digits of [24, 26, 28, 30]) {
const start = performance.now();
try { GraphQLPhoneNumber.parseValue('1'.repeat(digits) + 'x'); } catch {}
console.log(`${digits} digits: ${((performance.now() - start) / 1000).toFixed(2)} s`);
}
```
Measured warm, i.e. after ordinary inputs have been validated like in a running server:
| digits | Node 22.19.0 | Node 26.10.0 |
|-------:|-------------:|-------------:|
| 24 | 0.10 s | 0.07 s |
| 26 | 0.41 s | 0.29 s |
| 28 | 1.63 s | 1.14 s |
| 30 | 6.59 s | 4.62 s |
The time doubles with every digit. The first execution of the regex in a fresh process is about 5x slower because V8 interprets a regexp on its first run (Node 22.19.0, 28 digits: 7.8 s).
2. Through graphql-js, no server and no authentication: a schema with a single `echo(phone: PhoneNumber)` field, 26 digits + `x`, Node 22.19.0, warm:
| | literal (`parseLiteral`, validation) | variable (`parseValue`, coercion) |
|---|---:|---:|
| graphql 16.14.2 | 0.41 s | 0.43 s |
| graphql 17.0.2 | 0.41 s | 0.83 s (evaluated twice) |
3. Also reproduced end to end against a GraphQL Yoga server: an anonymous HTTP request with a 27-digit value plus one invalid character stalled the whole process (a concurrent trivial `{ __typename }` request waited as long). A mutation that requires authentication was affected equally when called anonymously, because document validation runs before any authorization in resolvers or context.
## Impact
Denial of service. Any GraphQL server that exposes `PhoneNumber` on an argument, an input object field or a variable can be stalled by a request of about 30 characters; roughly 40 characters would keep a process busy for hours (extrapolated from the doubling, not measured). `serialize` uses the same regex, so unvalidated stored values returned through a `PhoneNumber` field are affected too. The actual severity depends on the scalar being applied to untrusted input, which is the typical case.
## Suggested fix
An equivalent pattern without ambiguous splits:
```js
const PHONE_NUMBER_REGEX = /^\+?\d{1,3}[- ]?\(?\d(?:\d*\))?(?:[- ]?\d)+$/;
```
It accepts exactly the same strings as the current one, so there is no behaviour change: with `N = (S?\d+)+ = (S?\d)+` a digit run is just repeated single-digit iterations, hence `\d+\)?N = \d(\d*\))?N`. Verification:
- exhaustive comparison of old and new pattern on every string up to length 9 over `1 + - space ( ) x`: 47,079,208 strings (1,832 accepted), 0 differences;
- every sequence of up to 6 tokens from `1 11 111 1111 11111 + - space ( ) x`: 1,948,717 strings (100,380 accepted), 0 differences;
- adversarial inputs (digit runs plus a failing character, digit/space and digit/dash alternations, parenthesis variants) scale linearly: 1,000,000 characters take about 10 ms, where the current pattern needs 0.3 s for 26.
Optionally also reject values above a sane length before matching; the grammar currently has no upper bound and E.164 allows at most 15 digits. Above roughly 10 million characters V8 aborts the match with a `RangeError` (stack) after about 25 ms instead of returning false.
A regression test that fails with the current implementation: `GraphQLPhoneNumber.parseValue('1'.repeat(40) + 'x')` must throw its validation error immediately; with the current pattern it does not return in any reasonable time.
## Workarounds for users until a fix is released
- Replace the scalar in the schema with one that uses the pattern above.
- Run Node with `--enable-experimental-regexp-engine-on-excessive-backtracks`, or call `v8.setFlagsFromString('--enable-experimental-regexp-engine-on-excessive-backtracks')` before the regex first runs. V8 then falls back to its linear-time engine after excessive backtracking; verified on Node 22.19, 24.15 and 26.10. The flag is not accepted in `NODE_OPTIONS`.
- Pinning `graphql-scalars` to <= 1.24.0 restores a linear pattern but changes which values are accepted (only `+` prefixed E.164).
## Coordination
We have not disc…
Check your whole app
Dependencies are only half of it. Free and read-only: before you push, run npx shippable-dev scan localhost:3000 in your project folder, no account needed. For your live app, you prove it's yours with a one-line tag first.
References
- github.com/graphql-hive/graphql-scalars/security/advisories/GHSA-x8rm-f576-gq67
- nvd.nist.gov/vuln/detail/CVE-2026-115887
- github.com/graphql-hive/graphql-scalars/pull/2719
- github.com/graphql-hive/graphql-scalars/commit/210fd2485641afa2452800c00144245c74361701
- github.com/graphql-hive/graphql-scalars/releases/tag/v2.0.1
- github.com/advisories/GHSA-x8rm-f576-gq67
- github.com/graphql-hive/graphql-scalars
Merged from GitHub Advisory Database, OSV. Last updated 29 Oct 2026. All vulnerabilities · RSS · JSON