Like someone photocopying your hotel key card in the split-second the front desk is shredding it
CVE-2026-117499 is a TOCTOU (time-of-check-time-of-use) race condition in the @fastify/session npm package, all versions prior to 11.1.6. When rolling sessions are enabled (the default), a request that restores a session from the store *before* a concurrent Session#destroy() call completes can write its own stale copy *after* the destroy finishes, effectively resurrecting the session ID with its authenticated payload. The attacker must already possess the victim's valid, signed session cookie — no cookie theft or signature forgery is part of this bug — and must keep a request in flight across the exact window when the victim logs out. The fixed version (11.1.6) introduces a per-session generation counter and a conditional store write (set-if-generation-matches) that rejects stale saves.
The vendor (OpenJS Foundation / Fastify maintainers, CNA: GitHub) rated this LOW at CVSS 3.7 with vector AV:N/AC:H/PR:L/UI:R/S:U/C:L/I:L/A:N. That rating is honest and accurate. The attack complexity is genuinely high — winning the race requires sub-request-cycle timing precision against an async store — and the prerequisites (possession of a valid signed cookie *plus* victim-initiated logout during the attacker's in-flight request) stack up to a very narrow exploitation window. The impact caps at reviving one session; there is no privilege escalation, no RCE, and no data exfiltration beyond what the session already authorized. This is a correctness bug with security implications, not a weaponizable exploit.
4 steps from start to impact.
Obtain victim's valid signed session cookie
- Victim's valid signed session cookie in attacker's possession
- Target application uses
@fastify/session< 11.1.6 with default rolling sessions
- HttpOnly + Secure + SameSite cookie flags block most XSS and MITM cookie theft vectors
- Modern browsers enforce SameSite=Lax by default, preventing cross-site cookie leakage
- If the attacker already has the victim's session cookie, they already have session access — the incremental value of surviving a logout is marginal
Initiate a long-running request with the stolen cookie
- Network access to the Fastify application
- An endpoint that takes long enough to keep the request alive during logout
- Request timeouts (typically 30-60s) limit the race window
- Load balancers and reverse proxies may terminate idle connections
- Rate limiting may prevent sustained concurrent requests from a single session
Victim initiates logout (destroy)
Session#destroy(), which deletes the session from the backing store. However, the attacker's in-flight request already has a restored copy of the session in memory.- Victim must actively log out during the attacker's in-flight request window
- UI:R — this is not attacker-controlled timing
- The attacker cannot force or predict when the victim will log out
- The window between session restore and response send is typically milliseconds to low seconds
- If the victim changes their password or the app rotates session secrets, the cookie becomes invalid regardless
Stale session save overwrites the destroy
store.set() to refresh the session TTL. Because the store entry was already deleted by destroy(), this set() call *recreates* the session ID with its original authenticated data. The attacker can now continue using the cookie as if logout never happened.- Rolling sessions enabled (default in @fastify/session)
- The session store's
set()does not check for generation/version conflicts (all stores before the 11.1.6 conditional write protocol)
- Session stores with built-in optimistic locking or CAS (compare-and-swap) semantics would reject the stale write — though no default store shipped with this protection before the fix
- If the application uses
@fastify/secure-session(signed cookies without server-side store), this bug does not apply
- WAF rules — the exploit uses a normal, validly-signed cookie in a normal HTTP request. There is no malicious payload, injection, or anomalous header for a WAF to match on.
- Rate limiting — the attacker only needs one well-timed request, not a flood. Rate limits do not narrow the race window.
- Switching to
@fastify/secure-session— while this plugin uses client-side signed cookies without a server store (eliminating the race), it is a different session model with different trade-offs (no server-side revocation at all), not a drop-in migration.
The supporting signals.
| In-the-wild exploitation | None reported. Not listed in CISA KEV. No campaigns, threat actor reporting, or incident references found. |
|---|---|
| Proof-of-concept | No public PoC. No repositories on GitHub named after CVE-2026-117499. Not indexed on pocindex.io. No ExploitDB, Nuclei template, or Metasploit module. The race condition is conceptually simple but requires precise request timing to reproduce reliably. |
| EPSS | Not yet scored (CVE published 2026-10-27; EPSS typically lags 24-48 hours). Expected to be very low (<1st percentile) given AC:H, PR:L, UI:R, no PoC, and no exploitation history. |
| CVSS vector | CVSS:3.1/AV:N/AC:H/PR:L/UI:R/S:U/C:L/I:L/A:N — Network-reachable but requires high attack complexity (race condition timing), low privileges (valid session cookie), and user interaction (victim must log out). Impact limited to low confidentiality and integrity; no availability impact. Scope unchanged. |
| Affected versions | @fastify/session all versions < 11.1.6. This includes the entire 10.x and earlier lines. Only affects server-side session stores with rolling sessions enabled (the default). |
| Fixed version | @fastify/session 11.1.6. Fix adds a per-session generation marker and a conditional store.set() that rejects writes from stale generations. Custom session stores must implement the new conditional write interface. |
| Exposure footprint | ~208K weekly npm downloads. Fastify is the #2 Node.js web framework behind Express. However, not all Fastify apps use @fastify/session (many use JWT or @fastify/secure-session instead), and the vulnerable path requires rolling sessions + server-side store + concurrent logout. |
| Disclosure date | 2026-10-27 (today). Published via GitHub Security Advisory GHSA-2hw7-jxfh-4rm3. |
| Reporter | mcollina (Matteo Collina, Fastify co-creator and Node.js TSC member). Self-reported by the maintainer team — indicates the issue was found during internal code review, not external exploitation. |
| Related prior CVE | CVE-2024-35220 — a prior insufficient session expiration bug in @fastify/session (versions < 10.9.0). Different root cause but same component lineage. |
Sources.
Why this verdict
- Cookie prerequisite is a full prior compromise: The attacker must already possess the victim's valid, signed session cookie. This means they already have session-level access — the race condition only lets them *keep* it past a logout, which is marginal incremental value over what they already have.
- AC:H is genuine, not inflated: Winning this race requires the attacker's request to be restored from the store *before* destroy completes and to save *after* destroy completes — a window measured in single-digit milliseconds on typical async stores. This is not a click-and-pwn.
- UI:R is attacker-uncontrollable: The victim must log out at exactly the right moment. The attacker cannot force or reliably predict this. Combined with AC:H, the probability of successful exploitation in a single attempt is extremely low.
- Impact ceiling is one session: Even successful exploitation only revives one previously-authenticated session. There is no privilege escalation, no lateral movement, no RCE. C:L/I:L/A:N accurately reflects the damage.
- Role multiplier:
@fastify/sessionis application-tier session middleware for Node.js web apps. (a) *Low-value role:* dev projects, hobby apps — majority of installs, no meaningful blast radius. (b) *Typical role:* line-of-business Fastify APIs with session auth — impact is one user's session survives logout, blast radius is single-user. (c) *High-value role:* a Fastify app acting as an OAuth/OIDC provider or admin portal — theoretically the chain succeeds, but the outcome is still 'one admin session survives logout,' not domain takeover or fleet compromise. The blast radius is user-session-scoped, never host/domain/fleet-scoped. No high-value-role floor applies because the worst-case outcome does not reach domain takeover, fleet compromise, mass data egress, or supply-chain pivot. - No exploitation evidence, no PoC, no KEV: Zero real-world signal. Self-reported by the maintainer during code review. This is a proactive fix, not incident response.
Why not higher?
Upgrading to MEDIUM would require either broader impact (e.g., privilege escalation, data exfiltration) or lower attack complexity. This bug has neither. The attacker already needs what the bug gives them (session access), the race window is genuinely narrow, the victim must cooperate by logging out at the right moment, and the impact is capped at session-level persistence with no escalation path. Even in the most sensitive deployment (admin portal, OAuth provider), the outcome is one session surviving one logout — not systemic compromise.
Why not lower?
The bug is real, not theoretical — rolling sessions are the default, the race is architecturally inherent in async store operations, and the fix required a protocol change (generation markers + conditional writes). There is no workaround. In regulated environments (healthcare, finance), session revocation guarantees are compliance requirements (NIST 800-63B §7.1), so even a low-probability bypass has audit significance. IGNORE would understate the correctness impact.
Crowdsourced verification payload.
Run this on any host where the target Node.js application is deployed, or in your CI pipeline against package.json / package-lock.json. Requires node (or jq for lockfile parsing). No elevated privileges needed. Example: bash check_cve_2026_117499.sh /path/to/your/app
#!/usr/bin/env bash
# check_cve_2026_117499.sh — Detect CVE-2026-117499 in @fastify/session
# Usage: bash check_cve_2026_117499.sh [/path/to/node/app]
# Exit codes: 0 = PATCHED, 1 = VULNERABLE, 2 = UNKNOWN
set -euo pipefail
APP_DIR="${1:-.}"
PKG_NAME="@fastify/session"
FIXED_VERSION="11.1.6"
# Utility: compare semver (returns 0 if $1 >= $2)
version_gte() {
printf '%s\n%s' "$1" "$2" | sort -t. -k1,1n -k2,2n -k3,3n -C 2>/dev/null
# sort -C exits 0 if already sorted (i.e., $2 <= $1)
# We reverse: check if $2 sorts before or equal to $1
local sorted
sorted=$(printf '%s\n%s' "$2" "$1" | sort -t. -k1,1n -k2,2n -k3,3n | head -1)
[ "$sorted" = "$2" ]
}
echo "[*] Checking for CVE-2026-117499 (@fastify/session < $FIXED_VERSION)"
echo "[*] App directory: $APP_DIR"
# Method 1: Check package-lock.json
if [ -f "$APP_DIR/package-lock.json" ]; then
INSTALLED=$(jq -r '.packages["node_modules/@fastify/session"].version // .dependencies["@fastify/session"].version // empty' "$APP_DIR/package-lock.json" 2>/dev/null || true)
if [ -n "$INSTALLED" ]; then
echo "[*] Found @fastify/session $INSTALLED in package-lock.json"
if version_gte "$INSTALLED" "$FIXED_VERSION"; then
echo "PATCHED — @fastify/session $INSTALLED >= $FIXED_VERSION"
exit 0
else
echo "VULNERABLE — @fastify/session $INSTALLED < $FIXED_VERSION"
exit 1
fi
fi
fi
# Method 2: Check node_modules directly
if [ -f "$APP_DIR/node_modules/@fastify/session/package.json" ]; then
INSTALLED=$(jq -r '.version' "$APP_DIR/node_modules/@fastify/session/package.json" 2>/dev/null || true)
if [ -n "$INSTALLED" ]; then
echo "[*] Found @fastify/session $INSTALLED in node_modules"
if version_gte "$INSTALLED" "$FIXED_VERSION"; then
echo "PATCHED — @fastify/session $INSTALLED >= $FIXED_VERSION"
exit 0
else
echo "VULNERABLE — @fastify/session $INSTALLED < $FIXED_VERSION"
exit 1
fi
fi
fi
# Method 3: npm ls
if command -v npm &>/dev/null; then
INSTALLED=$(cd "$APP_DIR" && npm ls @fastify/session --json 2>/dev/null | jq -r '.. | .version? // empty' | head -1 || true)
if [ -n "$INSTALLED" ]; then
echo "[*] Found @fastify/session $INSTALLED via npm ls"
if version_gte "$INSTALLED" "$FIXED_VERSION"; then
echo "PATCHED — @fastify/session $INSTALLED >= $FIXED_VERSION"
exit 0
else
echo "VULNERABLE — @fastify/session $INSTALLED < $FIXED_VERSION"
exit 1
fi
fi
fi
echo "UNKNOWN — @fastify/session not found in $APP_DIR"
exit 2