A hair-trigger on a rarely used CORS hook crashes the waiter mid-order
@fastify/cors versions 8.2.0 through 11.3.x can be configured with a non-default onSend hook. When a CORS preflight request arrives — an OPTIONS with Origin and Access-Control-Request-Method headers — the plugin calls reply.send from inside the onSend hook, re-enters the send pipeline, and throws an uncaught ERR_HTTP_HEADERS_SENT because the response headers have already been written. Without a process-level uncaughtException handler, Node.js terminates the process.
Vendor severity is LOW (3.7), and that is realistic. The crash is availability-only, requires the non-default onSend option, and many deployments run the default onRequest hook or use process supervisors that restart the instance. The fix in 11.4.0 removes onSend as an accepted hook value and rejects the configuration at registration time.
4 steps from start to impact.
Find a Fastify app using the non-default onSend hook
@fastify/cors with hook: 'onSend' or a hook list that includes onSend. This is not the default; the plugin defaults to onRequest, which is unaffected. The vulnerable versions are 8.2.0 through 11.3.x.- Remote network access to the Fastify application
- Application uses @fastify/cors with
onSendhook explicitly configured - Plugin version between 8.2.0 and 11.3.x
- The
onSendhook is non-default and rarely used for CORS; most deployments never change the hook. - Configuration is application-specific and not exposed in HTTP fingerprints or banners.
Send an unauthenticated CORS preflight request
OPTIONS /path HTTP/1.1 with Origin: https://example.com and Access-Control-Request-Method: GET. Because the CORS plugin handles preflight, these headers must be present for the vulnerable code path to execute.- Target endpoint accepts OPTIONS and is not stripped by a reverse proxy or WAF
- Preflight is not disabled (
preflight: false) - Attacker can reach the HTTP service unauthenticated
- Many edge proxies and CDNs normalize or absorb OPTIONS requests, but preflight traffic generally passes through.
- Some applications set
preflight: falsefor APIs not needing browser cross-origin calls.
Trigger ERR_HTTP_HEADERS_SENT inside the onSend hook
onSend hook, where the preflight handling calls reply.send. That call re-enters the send pipeline, attempts a second write of response headers, and raises ERR_HTTP_HEADERS_SENT.- The plugin's
onSendhook is actually invoked for this route - CORS preflight path is active for the requested route
- Only the
onSendhook variant is vulnerable; the defaultonRequesthook never re-enters the send pipeline. - The exception is deterministic once the configuration is present, so a single request is enough.
Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client.Process exit due to uncaught exception
process.on('uncaughtException') handler, Node.js's default behavior is to print the stack trace and terminate the process. The service becomes unavailable until a supervisor restarts it.- No process-level
uncaughtExceptionhandler - No external supervisor that keeps the process alive (or supervisor restarts but the attack can be repeated)
- Production Node.js apps commonly run under systemd, Kubernetes, pm2, or Docker restart policies, which restart the process quickly.
- A single request causes only a brief outage, but repeated requests can cause a crash loop and sustained denial of service.
onSend hook at registration time, preventing the crash configuration entirely. Apply within the noisgate remediation SLA of 365 days for LOW severity; no mitigation SLA applies.onSend hook usage — Switch to the default onRequest hook or any other allowed hook. If CORS preflight handling is not needed for the app, set preflight: false. This eliminates the vulnerable code path without requiring an immediate package update.process.on('uncaughtException') handler with controlled exit strategy — A handler can catch ERR_HTTP_HEADERS_SENT and prevent immediate process death, but this is not a substitute for patching because the process may be in an inconsistent state. Use only as a stopgap and force an orderly shutdown/restart after logging.- WAF or CDN blocking OPTIONS requests is unreliable because legitimate CORS preflights use OPTIONS and must be allowed for browser clients.
- Rate limiting at the network layer can slow repeated crash attempts but does not fix the underlying crash; a single intermittent request can still restart the process if it matches the preflight pattern.
- Process supervisors alone do not eliminate the vulnerability; they only reduce the duration of each outage and still allow sustained crash-loop denial of service.
The supporting signals.
| In-the-wild status | No known active exploitation, no mass scanning observed, not listed in CISA KEV. |
|---|---|
| Proof-of-concept availability | The advisory describes the exact curl one-liner; no dedicated public exploit repository or nuclei template has been identified. |
| EPSS score | No published EPSS score; expected to be very low (<0.1%) due to non-default configuration requirement. |
| KEV status | Not KEV-listed; no CISA Known Exploited Vulnerabilities entry. |
| CVSS vector interpretation | AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:L — network reachable, but high complexity because the non-default onSend hook must be configured; availability impact is process-local. |
| Affected versions | @fastify/cors >=8.2.0 <11.4.0 |
| Fixed versions | 11.4.0; workarounds include using the default onRequest hook or setting preflight: false. |
| Scanning / exposure data | No external service can detect this configuration; only code/config review or dependency scanning reveals the vulnerable combination. |
| Disclosure date | Not explicitly stated in the provided record; the CVE year is 2026. |
| Reporting researcher / org | Fastify maintainers, published as part of the framework security release for @fastify/cors 11.4.0. |
Sources.
Why this verdict
- Config friction: The vulnerable code path requires the non-default
onSendhook. DefaultonRequestand other hook values are not exploitable, so only a small subset of @fastify/cors deployments are exposed. - Blast radius: The impact is a crash of the Node.js process, not host compromise or data loss. Modern supervisors restart the process, limiting the single-request outage to seconds unless the attacker sustains a crash loop.
- Role multiplier: @fastify/cors is an application-tier middleware plugin, not a canonical high-value-role component like identity providers, hypervisors, backup systems, or network edge. The worst plausible outcome is an application outage, not domain/fleet/supply-chain takeover. Therefore no floor above LOW applies.
- Friction audit: The attack chain is unauthenticated remote, but each step adds a narrowing condition — non-default hook, un-disabled preflight, no uncaughtException handler, no effective supervisor protection. The reachable population is small.
Why not higher?
The vulnerability only crashes the application process; it does not allow code execution, data exfiltration, or privilege escalation. The non-default configuration and typical process supervisor behavior reduce real-world impact. No evidence of active exploitation or KEV listing exists.
Why not lower?
For an enterprise that has deliberately chosen the onSend hook for CORS on a single-instance service, a remote unauthenticated client can repeatedly crash the process and cause sustained denial of service. This is a real availability issue for affected deployments, so it should not be dismissed as IGNORE.
Crowdsourced verification payload.
Run from the project root or an auditor workstation with read access to the repository. Execute python3 check_fastify_cors.py; no special privileges are required. The script checks package.json and package-lock.json for the installed @fastify/cors version.
#!/usr/bin/env python3
import json, os, sys
def parse_version(v):
# strip common semver prefixes
for prefix in ('^', '~', '>', '<', '=', ' '):
v = v.replace(prefix, '')
return v.strip()
status = 'UNKNOWN'
version = None
# check package.json first
if os.path.exists('package.json'):
try:
with open('package.json') as f:
pkg = json.load(f)
deps = {}
deps.update(pkg.get('dependencies', {}))
deps.update(pkg.get('devDependencies', {}))
if '@fastify/cors' in deps:
version = parse_version(deps['@fastify/cors'])
except Exception as e:
print(f'Error reading package.json: {e}', file=sys.stderr)
# check package-lock.json if version not found or for exact resolution
if version is None and os.path.exists('package-lock.json'):
try:
with open('package-lock.json') as f:
lock = json.load(f)
pkgs = lock.get('packages', {})
for path, data in pkgs.items():
if path.endswith('node_modules/@fastify/cors') or path == 'node_modules/@fastify/cors':
if isinstance(data, dict) and 'version' in data:
version = parse_version(data['version'])
break
except Exception as e:
print(f'Error reading package-lock.json: {e}', file=sys.stderr)
# also check npm-shrinkwrap.json if still unknown
if version is None and os.path.exists('npm-shrinkwrap.json'):
try:
with open('npm-shrinkwrap.json') as f:
shrink = json.load(f)
pkgs = shrink.get('packages', {})
for path, data in pkgs.items():
if path.endswith('node_modules/@fastify/cors') or path == 'node_modules/@fastify/cors':
if isinstance(data, dict) and 'version' in data:
version = parse_version(data['version'])
break
except Exception as e:
print(f'Error reading npm-shrinkwrap.json: {e}', file=sys.stderr)
if version is None:
print('UNKNOWN')
sys.exit(2)
# basic semver comparison for affected range 8.2.0 <= v < 11.4.0
try:
parts = version.split('.')
major = int(parts[0]) if len(parts) > 0 else 0
minor = int(parts[1]) if len(parts) > 1 else 0
patch = int(parts[2]) if len(parts) > 2 else 0
except Exception:
print('UNKNOWN')
sys.exit(2)
affected = False
if major == 11:
if minor < 4:
affected = True
elif major == 10:
affected = True
elif major == 9:
affected = True
elif major == 8:
if minor == 2:
affected = True
elif minor > 2:
affected = True
# minor <2 (8.0/8.1) not affected per range
# major <8 or major >11 unaffected
if affected:
print(f'VULNERABLE: version {version} is in affected range 8.2.0 - 11.3.x')
sys.exit(1)
else:
print(f'PATCHED: version {version}')
sys.exit(0)