GHSA-687G-22H4-J4W4

Vulnerability from github – Published: 2026-10-08 22:02 – Updated: 2026-10-08 22:02
VLAI
Summary
fast-jwt clockTolerance: Infinity silently bypasses both exp and nbf validation (and persists in the verifier cache)
Details

Summary

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.

Vulnerable code

src/verifier.js:531-533 — option validation only rejects negative values, not Infinity:

if (clockTolerance && (typeof clockTolerance !== 'number' || clockTolerance < 0)) {
  throw new TokenError(TokenError.codes.invalidOption, 'The clockTolerance option must be a positive number.')
}

Infinity passes (it's a number, not less than 0, truthy).

src/verifier.js:583-602 — clockTolerance flows into the date-claim validators:

if (!ignoreNotBefore) {
  validators.push({ ..., modifier: -clockTolerance })   // → -Infinity
}
if (!ignoreExpiration) {
  validators.push({ ..., modifier: +clockTolerance })   // → Infinity
}

src/verifier.js:198-205 — applies modifier additively, producing always-pass comparisons:

function validateClaimDateValue(value, modifier, now, greater, errorCode, errorVerb) {
  const adjusted = value * 1000 + (modifier || 0)   // → ±Infinity
  const valid = greater ? now >= adjusted : now <= adjusted   // → always true
  ...
}

Empirical PoC

const { createSigner, createVerifier } = require('fast-jwt')
const secret = 'test-secret-with-enough-length-to-pass'

const sign = createSigner({ key: secret, algorithm: 'HS256' })
const expiredToken = sign({
  sub: 'alice',
  iat: Math.floor(Date.now()/1000) - 3600,
  exp: Math.floor(Date.now()/1000) - 1800,  // expired 30 min ago
})

const v1 = createVerifier({ key: secret })
try { v1(expiredToken) } catch (e) { console.log('baseline rejects:', e.message) }
// → "The token has expired at ..."

const v2 = createVerifier({ key: secret, clockTolerance: Infinity })
console.log('bypass:', v2(expiredToken))
// → { sub: 'alice', iat: ..., exp: ... }  ← expired token accepted as valid

// Not-yet-active token (nbf in 1 day) — same bypass
const futureToken = sign({
  sub: 'bob',
  iat: Math.floor(Date.now()/1000),
  nbf: Math.floor(Date.now()/1000) + 86400,
})
console.log('bypass nbf:', v2(futureToken))
// → { sub: 'bob', ... }  ← not-yet-active token accepted as valid

Verified against fast-jwt@HEAD on 2026-06-03 (commit pulled today).

Cache side-effect

The verifier's LRU cache uses clockTolerance to compute the cache entry's expiry window:

src/verifier.js:121-134:

cacheValue[1] = ... payload.nbf * 1000 - clockTolerance : 0                   // → -Infinity
cacheValue[2] = payload.exp * 1000 + clockTolerance                            // → Infinity
const maxTTL = clockTimestamp + clockTolerance + cacheTTL                      // → Infinity

With 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.

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).

Asymmetric hardening — the smoking-gun shape

src/signer.js:98, 104 CORRECTLY uses Number.isFinite() to reject Infinity for expiresIn and notBefore:

expiresIn != null && Number.isFinite(expiresIn)
  ? Math.floor((iat + expiresIn) / 1000)
  : ...

The 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.

This 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.

Threat model

The bug requires the developer to (mis)configure clockTolerance: Infinity. Realistic ways this happens:

  1. 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.
  2. 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.
  3. Test config bleed: integration tests use Infinity to make tokens never expire during long-running tests; the config bleeds into production.

For a JWT library, silently disabling token expiry on a "looks like a non-negative number" input is a security boundary failure.

Suggested fix

Single-line addition: use Number.isFinite() consistent with signer.js:

if (clockTolerance && (typeof clockTolerance !== 'number' || !Number.isFinite(clockTolerance) || clockTolerance < 0)) {
  throw new TokenError(TokenError.codes.invalidOption, 'The clockTolerance option must be a finite, non-negative number.')
}

Same fix for clockTimestamp (line 527-529) and cacheTTL (line 535-537) for consistency.

Optional 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.

Affected versions

All versions since clockTolerance was first introduced in PR #193 (v1.5.1). Current main HEAD on 2026-06-03 is affected.

Reporter

Andrew Ridings (independent security researcher). Happy to coordinate disclosure timing and follow up with any clarifications. Email: ridingsa@gmail.com

Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 6.2.4"
      },
      "package": {
        "ecosystem": "npm",
        "name": "fast-jwt"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "6.3.0"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-107721"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-613",
      "CWE-682"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-10-08T22:02:02Z",
    "nvd_published_at": null,
    "severity": "MODERATE"
  },
  "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\u0027s internal cache so cached entries inherit infinite validity \u2014 they remain valid past a later developer-removed Infinity config until LRU eviction.\n\n## Vulnerable code\n\n`src/verifier.js:531-533` \u2014 option validation only rejects negative values, not Infinity:\n\n```js\nif (clockTolerance \u0026\u0026 (typeof clockTolerance !== \u0027number\u0027 || clockTolerance \u003c 0)) {\n  throw new TokenError(TokenError.codes.invalidOption, \u0027The clockTolerance option must be a positive number.\u0027)\n}\n```\n\n`Infinity` passes (it\u0027s a number, not less than 0, truthy).\n\n`src/verifier.js:583-602` \u2014 clockTolerance flows into the date-claim validators:\n\n```js\nif (!ignoreNotBefore) {\n  validators.push({ ..., modifier: -clockTolerance })   // \u2192 -Infinity\n}\nif (!ignoreExpiration) {\n  validators.push({ ..., modifier: +clockTolerance })   // \u2192 Infinity\n}\n```\n\n`src/verifier.js:198-205` \u2014 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)   // \u2192 \u00b1Infinity\n  const valid = greater ? now \u003e= adjusted : now \u003c= adjusted   // \u2192 always true\n  ...\n}\n```\n\n## Empirical PoC\n\n```js\nconst { createSigner, createVerifier } = require(\u0027fast-jwt\u0027)\nconst secret = \u0027test-secret-with-enough-length-to-pass\u0027\n\nconst sign = createSigner({ key: secret, algorithm: \u0027HS256\u0027 })\nconst expiredToken = sign({\n  sub: \u0027alice\u0027,\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(\u0027baseline rejects:\u0027, e.message) }\n// \u2192 \"The token has expired at ...\"\n\nconst v2 = createVerifier({ key: secret, clockTolerance: Infinity })\nconsole.log(\u0027bypass:\u0027, v2(expiredToken))\n// \u2192 { sub: \u0027alice\u0027, iat: ..., exp: ... }  \u2190 expired token accepted as valid\n\n// Not-yet-active token (nbf in 1 day) \u2014 same bypass\nconst futureToken = sign({\n  sub: \u0027bob\u0027,\n  iat: Math.floor(Date.now()/1000),\n  nbf: Math.floor(Date.now()/1000) + 86400,\n})\nconsole.log(\u0027bypass nbf:\u0027, v2(futureToken))\n// \u2192 { sub: \u0027bob\u0027, ... }  \u2190 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\u0027s LRU cache uses `clockTolerance` to compute the cache entry\u0027s expiry window:\n\n`src/verifier.js:121-134`:\n```js\ncacheValue[1] = ... payload.nbf * 1000 - clockTolerance : 0                   // \u2192 -Infinity\ncacheValue[2] = payload.exp * 1000 + clockTolerance                            // \u2192 Infinity\nconst maxTTL = clockTimestamp + clockTolerance + cacheTTL                      // \u2192 Infinity\n```\n\nWith `clockTolerance: Infinity`, cache entries are stored with `[min=-Infinity, max=Infinity]`. The cache-hit check (`min === 0 || now \u003c min || now \u003c= 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 \u2014 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 \u0026\u0026 Number.isFinite(expiresIn)\n  ? Math.floor((iat + expiresIn) / 1000)\n  : ...\n```\n\nThe verifier\u0027s `clockTolerance` validation doesn\u0027t apply the same guard. The same `\u003c 0` check pattern is also applied to `clockTimestamp` (line 527-529) and `cacheTTL` (line 535-537) \u2014 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\u0027t.\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\u0027s 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(\u0027{\"clockTolerance\": null}\u0027)` if the codec accepts null \u2192 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 \u0026\u0026 (typeof clockTolerance !== \u0027number\u0027 || !Number.isFinite(clockTolerance) || clockTolerance \u003c 0)) {\n  throw new TokenError(TokenError.codes.invalidOption, \u0027The clockTolerance option must be a finite, non-negative number.\u0027)\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 \u003c60s 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",
  "id": "GHSA-687g-22h4-j4w4",
  "modified": "2026-10-08T22:02:03Z",
  "published": "2026-10-08T22:02:02Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/nearform/fast-jwt/security/advisories/GHSA-687g-22h4-j4w4"
    },
    {
      "type": "WEB",
      "url": "https://github.com/nearform/fast-jwt/commit/c0fb5b88b4c88ff2bdd194ca2c713599ad9e38b6"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/nearform/fast-jwt"
    },
    {
      "type": "WEB",
      "url": "https://github.com/nearform/fast-jwt/releases/tag/v6.3.0"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:H/PR:H/UI:N/S:U/C:H/I:H/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "fast-jwt clockTolerance: Infinity silently bypasses both exp and nbf validation (and persists in the verifier cache)"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Loading…

Loading…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…