{"schema":"threatline.record/2","record":{"id":"CVE-2026-107721","aliases":["GHSA-687g-22h4-j4w4"],"summary":"fast-jwt clockTolerance: Infinity silently bypasses both exp and nbf validation (and persists in the verifier cache)","details":"## Summary\n\n`createVerifier({ clockTolerance: Infinity })` silently bypasses both `exp` (expiry) AND `nbf` (not-before) validation. Any expired or not-yet-active token is accepted as valid. The same primitive also corrupts the verifier's internal cache so cached entries inherit infinite validity — they remain valid past a later developer-removed Infinity config until LRU eviction.\n\n## Vulnerable code\n\n`src/verifier.js:531-533` — option validation only rejects negative values, not Infinity:\n\n```js\nif (clockTolerance && (typeof clockTolerance !== 'number' || clockTolerance < 0)) {\n  throw new TokenError(TokenError.codes.invalidOption, 'The clockTolerance option must be a positive number.')\n}\n```\n\n`Infinity` passes (it's a number, not less than 0, truthy).\n\n`src/verifier.js:583-602` — clockTolerance flows into the date-claim validators:\n\n```js\nif (!ignoreNotBefore) {\n  validators.push({ ..., modifier: -clockTolerance })   // → -Infinity\n}\nif (!ignoreExpiration) {\n  validators.push({ ..., modifier: +clockTolerance })   // → Infinity\n}\n```\n\n`src/verifier.js:198-205` — applies modifier additively, producing always-pass comparisons:\n\n```js\nfunction validateClaimDateValue(value, modifier, now, greater, errorCode, errorVerb) {\n  const adjusted = value * 1000 + (modifier || 0)   // → ±Infinity\n  const valid = greater ? now >= adjusted : now <= adjusted   // → always true\n  ...\n}\n```\n\n## Empirical PoC\n\n```js\nconst { createSigner, createVerifier } = require('fast-jwt')\nconst secret = 'test-secret-with-enough-length-to-pass'\n\nconst sign = createSigner({ key: secret, algorithm: 'HS256' })\nconst expiredToken = sign({\n  sub: 'alice',\n  iat: Math.floor(Date.now()/1000) - 3600,\n  exp: Math.floor(Date.now()/1000) - 1800,  // expired 30 min ago\n})\n\nconst v1 = createVerifier({ key: secret })\ntry { v1(expiredToken) } catch (e) { console.log('baseline rejects:', e.message) }\n// → \"The token has expired at ...\"\n\nconst v2 = createVerifier({ key: secret, clockTolerance: Infinity })\nconsole.log('bypass:', v2(expiredToken))\n// → { sub: 'alice', iat: ..., exp: ... }  ← expired token accepted as valid\n\n// Not-yet-active token (nbf in 1 day) — same bypass\nconst futureToken = sign({\n  sub: 'bob',\n  iat: Math.floor(Date.now()/1000),\n  nbf: Math.floor(Date.now()/1000) + 86400,\n})\nconsole.log('bypass nbf:', v2(futureToken))\n// → { sub: 'bob', ... }  ← not-yet-active token accepted as valid\n```\n\nVerified against `fast-jwt@HEAD` on 2026-06-03 (commit pulled today).\n\n## Cache side-effect\n\nThe verifier's LRU cache uses `clockTolerance` to compute the cache entry's expiry window:\n\n`src/verifier.js:121-134`:\n```js\ncacheValue[1] = ... payload.nbf * 1000 - clockTolerance : 0                   // → -Infinity\ncacheValue[2] = payload.exp * 1000 + clockTolerance                            // → Infinity\nconst maxTTL = clockTimestamp + clockTolerance + cacheTTL                      // → Infinity\n```\n\nWith `clockTolerance: Infinity`, cache entries are stored with `[min=-Infinity, max=Infinity]`. The cache-hit check (`min === 0 || now < min || now <= max`) always passes for cached tokens.\n\n**Consequence**: a developer who briefly sets `clockTolerance: Infinity` (e.g., during debug) and then removes it will find that any verifications performed during the debug window remain cached as valid until LRU eviction (default 1000 entries).\n\n## Asymmetric hardening — the smoking-gun shape\n\n`src/signer.js:98, 104` CORRECTLY uses `Number.isFinite()` to reject Infinity for `expiresIn` and `notBefore`:\n\n```js\nexpiresIn != null && Number.isFinite(expiresIn)\n  ? Math.floor((iat + expiresIn) / 1000)\n  : ...\n```\n\nThe verifier's `clockTolerance` validation doesn't apply the same guard. The same `< 0` check pattern is also applied to `clockTimestamp` (line 527-529) and `cacheTTL` (line 535-537) — both also accept `Infinity`.\n\nThis is the kind of asymmetry that often indicates a missed hardening pass: the sign-side was hardened against Infinity but the verify-side wasn't.\n\n## Threat model\n\nThe bug requires the developer to (mis)configure `clockTolerance: Infinity`. Realistic ways this happens:\n\n1. **Developer using Infinity as a sentinel** for \"disable expiry\": common JS idiom; many libraries accept Infinity as \"no limit.\" fast-jwt's signer treats Infinity as invalid (Number.isFinite false) but the verifier silently accepts it.\n2. **JSON / env-var misconfig**: config file or env var sets `clockTolerance` to `\"Infinity\"` (string); `Number(\"Infinity\") === Infinity`. Surprising via JSON.parse + Number cast or even `JSON.parse('{\"clockTolerance\": null}')` if the codec accepts null → Infinity.\n3. **Test config bleed**: integration tests use Infinity to make tokens never expire during long-running tests; the config bleeds into production.\n\nFor a JWT library, silently disabling token expiry on a \"looks like a non-negative number\" input is a security boundary failure.\n\n## Suggested fix\n\nSingle-line addition: use `Number.isFinite()` consistent with signer.js:\n\n```js\nif (clockTolerance && (typeof clockTolerance !== 'number' || !Number.isFinite(clockTolerance) || clockTolerance < 0)) {\n  throw new TokenError(TokenError.codes.invalidOption, 'The clockTolerance option must be a finite, non-negative number.')\n}\n```\n\nSame fix for `clockTimestamp` (line 527-529) and `cacheTTL` (line 535-537) for consistency.\n\nOptional defense-in-depth: cap `clockTolerance` to a reasonable upper bound (e.g., 5 minutes = 300000 ms) with a `process.emitWarning` above that. Most legitimate use cases need <60s tolerance.\n\n## Affected versions\n\nAll versions since clockTolerance was first introduced in PR #193 (v1.5.1). Current `main` HEAD on 2026-06-03 is affected.\n\n## Reporter\n\nAndrew Ridings (independent security researcher). Happy to coordinate disclosure timing and follow up with any clarifications. Email: ridingsa@gmail.com","categories":["Libraries & packages"],"product":"fast-jwt","vendor":"nearform","ecosystem":"npm","affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/advisories/GHSA-687g-22h4-j4w4","source_modified_at":"2026-10-08T22:02:03Z","fetched_at":"2026-10-23T18:42:43.628194Z","package":{"ecosystem":"npm","name":"fast-jwt"},"range":"<= 6.2.4"},{"source":"CVE List","source_url":"https://www.cve.org/CVERecord?id=CVE-2026-107721","source_modified_at":"2026-10-09T15:13:11.814Z","fetched_at":"2026-10-23T18:45:31.882637Z","product":"fast-jwt","vendor":"nearform","versions":[{"version":"< 6.3.0","status":"affected"}],"defaultStatus":null},{"source":"OSV","source_url":"https://osv.dev/vulnerability/GHSA-687g-22h4-j4w4","source_modified_at":"2026-10-08T22:15:05.893519907Z","fetched_at":"2026-10-23T18:45:31.882637Z","package":{"name":"fast-jwt","ecosystem":"npm","purl":"pkg:npm/fast-jwt"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"6.3.0"}]}],"versions":[]}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/advisories/GHSA-687g-22h4-j4w4","source_modified_at":"2026-10-08T22:02:03Z","fetched_at":"2026-10-23T18:42:43.628194Z","package":{"ecosystem":"npm","name":"fast-jwt"},"version":"6.3.0"},{"source":"OSV","source_url":"https://osv.dev/vulnerability/GHSA-687g-22h4-j4w4","source_modified_at":"2026-10-08T22:15:05.893519907Z","fetched_at":"2026-10-23T18:45:31.882637Z","package":{"name":"fast-jwt","ecosystem":"npm","purl":"pkg:npm/fast-jwt"},"version":"6.3.0"}],"cvss":5.9,"severity":"Medium","kev":{"listed":false},"epss":{"probability":0.00334,"percentile":0.25622,"score_date":"2026-10-23"},"published_at":"2026-10-08T21:45:57.545Z","modified_at":"2026-10-09T15:13:11.814Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/advisories/GHSA-687g-22h4-j4w4","fetched_at":"2026-10-23T18:42:43.628194Z","source_modified_at":"2026-10-08T22:02:03Z"},{"source":"CVE List","url":"https://www.cve.org/CVERecord?id=CVE-2026-107721","fetched_at":"2026-10-23T18:45:31.882637Z","source_modified_at":"2026-10-09T15:13:11.814Z"},{"source":"OSV","url":"https://osv.dev/vulnerability/GHSA-687g-22h4-j4w4","fetched_at":"2026-10-23T18:45:31.882637Z","source_modified_at":"2026-10-08T22:15:05.893519907Z"},{"source":"EPSS","url":"https://api.first.org/data/v1/epss?cve=CVE-2026-107721","fetched_at":"2026-10-23T18:42:43.628194Z","source_modified_at":"2026-10-23"}],"sources":["GitHub Advisory Database","CVE List","OSV","EPSS"],"references":[{"source":"CVE List","url":"https://github.com/nearform/fast-jwt/security/advisories/GHSA-687g-22h4-j4w4","tags":["x_refsource_CONFIRM"]},{"source":"CVE List","url":"https://github.com/nearform/fast-jwt/commit/c0fb5b88b4c88ff2bdd194ca2c713599ad9e38b6","tags":["x_refsource_MISC"]},{"source":"CVE List","url":"https://github.com/nearform/fast-jwt/releases/tag/v6.3.0","tags":["x_refsource_MISC"]},{"url":"https://github.com/advisories/GHSA-687g-22h4-j4w4","source":"GitHub Advisory Database"}],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"cve_state":"PUBLISHED","ssvc":{"exploitation":"none","automatable":"no","technical_impact":"total","assessed_at":"2026-10-09T15:00:04.372564Z"},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":"ecosystem","confidence":"heuristic","reviewed":false},"change_timestamp":"2026-10-09T15:13:11.814Z","content_hash":"0f8619753c953660b75b977bb8a39bab9ee298c40dc72716ecd1a69838b060d8"}}
