← Back to Feed CACHED · 2026-11-02 10:57:57 · CACHE_KEY CVE-2026-120215
CVE-2026-120215 · CWE-248

Fastify CORS plugin preflight DoS via non-default onSend hook

ASSESSED — NOISGATE
Vendor
—
—
—
Reassessed
—
—
—
Verdict: —
Do you agree?
01 · The Real Story

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.

"Only apps that opt into the non-default onSend hook can be crashed remotely with a single preflight request."
02 · The Attack Path

4 steps from start to impact.

STEP 01

Find a Fastify app using the non-default onSend hook

The attacker needs a target where the application registers @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.
Conditions required:
  • Remote network access to the Fastify application
  • Application uses @fastify/cors with onSend hook explicitly configured
  • Plugin version between 8.2.0 and 11.3.x
Where this breaks in practice:
  • The onSend hook 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.
Detection/coverage: No external scanner can identify this without code/config review. Internal dependency scanning can flag vulnerable versions, but only config review confirms the onSend usage.
STEP 02

Send an unauthenticated CORS preflight request

The attacker sends 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.
Conditions required:
  • 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
Where this breaks in practice:
  • Many edge proxies and CDNs normalize or absorb OPTIONS requests, but preflight traffic generally passes through.
  • Some applications set preflight: false for APIs not needing browser cross-origin calls.
Detection/coverage: Web server access logs show the OPTIONS request; no specific IDS signature exists for this exact crash vector.
STEP 03

Trigger ERR_HTTP_HEADERS_SENT inside the onSend hook

The Fastify request lifecycle reaches the 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.
Conditions required:
  • The plugin's onSend hook is actually invoked for this route
  • CORS preflight path is active for the requested route
Where this breaks in practice:
  • Only the onSend hook variant is vulnerable; the default onRequest hook never re-enters the send pipeline.
  • The exception is deterministic once the configuration is present, so a single request is enough.
Detection/coverage: Node.js runtime log will show Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client.
STEP 04

Process exit due to uncaught exception

If the application has not installed a 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.
Conditions required:
  • No process-level uncaughtException handler
  • No external supervisor that keeps the process alive (or supervisor restarts but the attack can be repeated)
Where this breaks in practice:
  • 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.
Detection/coverage: Process supervisor logs, container restart events, crash dumps, and monitoring alerts show repeated Node process exits.
03 · Compensating Control

1
LOW 3.0→IGNORE 0.0
SEVERITY REDUCED
Upgrade to @fastify/cors 11.4.0 — The fix rejects the 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.
2
LOW 3.0→IGNORE 0.0
SEVERITY REDUCED
Remove the 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.
3
LOW 3.0→LOW 2.0
Install a 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.
What doesn't work
  • 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.
04 · Intelligence Metadata

The supporting signals.

In-the-wild statusNo known active exploitation, no mass scanning observed, not listed in CISA KEV.
Proof-of-concept availabilityThe advisory describes the exact curl one-liner; no dedicated public exploit repository or nuclei template has been identified.
EPSS scoreNo published EPSS score; expected to be very low (<0.1%) due to non-default configuration requirement.
KEV statusNot KEV-listed; no CISA Known Exploited Vulnerabilities entry.
CVSS vector interpretationAV: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 versions11.4.0; workarounds include using the default onRequest hook or setting preflight: false.
Scanning / exposure dataNo external service can detect this configuration; only code/config review or dependency scanning reveals the vulnerable combination.
Disclosure dateNot explicitly stated in the provided record; the CVE year is 2026.
Reporting researcher / orgFastify maintainers, published as part of the framework security release for @fastify/cors 11.4.0.

Sources.

  1. NVD CVE-2026-120215
  2. MITRE CVE-2026-120215
  3. GitHub Release @fastify/cors v11.4.0
  4. npm @fastify/cors
  5. GitHub fastify/fastify-cors
  6. Fastify CORS documentation
05 · The Call

Final Verdict
= UNCHANGED to LOW (3.0/10)

Why this verdict

  • Config friction: The vulnerable code path requires the non-default onSend hook. Default onRequest and 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.

06 · Verification

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.

noisgate-verify.py
PYTHONREAD-ONLYSAFE
#!/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)