The bouncer checks your fake ID, says it's legit, then hands the doorman the original fake anyway
CVE-2026-18504 is a schema validation bypass in Fastify < 5.12.1 that occurs only when a route defines a root-level primitive body schema (e.g., a bare {type:"integer",minimum:1,maximum:10}) with default Ajv type coercion enabled. In that narrow scenario, Ajv coerces a JSON string like "10" to the integer 10 for validation purposes and the schema check passes, but request.body retains the original string "10". The handler therefore receives a value whose *type* was never validated, even though the *coerced equivalent* passed. Object and array body schemas — which cover the vast majority of real Fastify routes — are unaffected because Ajv mutates their members in place. Affected versions span all Fastify releases prior to 5.12.1; the fix landed in commit 1ef8a60 by mcollina.
The vendor's MEDIUM 5.4 rating uses AC:L (low attack complexity), which overstates reality. Triggering this bug requires a route that (a) accepts a bare primitive body, (b) relies on Ajv coercion rather than explicit parsing, and (c) makes a downstream security decision — access-control, payment bound, rate limit — that is sensitive to the type of the value. Each of those conditions is independently uncommon; together they describe a niche coding pattern. The PR:L (authenticated) prerequisite already limits the attacker population, and impact is capped at Low confidentiality + Low integrity with no availability effect. No exploitation has been observed in the wild, no PoC exists in public, and EPSS sits at the 0.31 percentile. The vendor severity is defensible on paper but generous in practice.
3 steps from start to impact.
Identify a Fastify route with a root-level primitive schema
- Target runs Fastify < 5.12.1
- Route uses a root-level primitive body schema
- Default Ajv type coercion is enabled (it is by default)
- Root-level primitive schemas are a rare anti-pattern; most Fastify routes accept objects
- Attacker must discover the specific schema shape — blind probing yields generic 400s
Authenticate and send a type-mismatched body
"10" for an integer schema with minimum:1, maximum:10. Ajv coerces "10" → 10, validates it, and returns success, but request.body stays as the string "10". No special tooling is required; a single curl call suffices.- Valid authentication credentials for the target application
- Knowledge of the expected primitive type and constraints
- Requires an authenticated session — unauthenticated attackers are blocked
- The string value still represents the same *semantic* number; the difference is type, not magnitude
Exploit the type confusion in handler logic
===), a typeof branch, or passes the value to a downstream API that treats strings differently from numbers — the attacker may bypass a business-logic constraint. The actual impact is entirely application-dependent: in most handlers, the string "10" and integer 10 behave identically in JavaScript's loosely-typed runtime.- The route handler makes a security-relevant decision based on the value's JavaScript type
- That decision leads to unauthorized data disclosure or state change
- JavaScript's loose typing means
"10" == 10is true; only strict checks (===) ortypeofguards are affected - Most handlers call
.toString(), store to a database, or forward as JSON — all of which are type-agnostic - The impact is bounded by the handler's own privilege scope, not by the framework
1ef8a60 passes parentData and parentDataProperty to Ajv so coerced root primitives are written back to request.body. As a LOW verdict, the noisgate remediation SLA allows up to 365 days, but the fix is a minor semver bump with no breaking changes — deploy it in your next dependency update cycle.{type:"integer",minimum:1,maximum:10} to {type:"object",properties:{value:{type:"integer",minimum:1,maximum:10}},required:["value"]} and read request.body.value. Object properties are coerced in place, so the mismatch never occurs. This is the vendor-recommended workaround and eliminates the vulnerability without upgrading.if (typeof request.body !== 'number') return reply.code(400).send({error:'type mismatch'}). This catches the uncoerced value before any security-relevant logic runs. A tactical fix for teams that cannot change schemas or upgrade immediately.- WAF body-schema validation — WAFs inspect HTTP payloads but do not understand Fastify's internal Ajv coercion pipeline. A WAF would see valid JSON and pass it through; the type mismatch happens after the WAF layer.
- Disabling Ajv coercion globally (
ajv: {coerceTypes: false}) — This removes the coercion behavior entirely but will break any route that depends on automatic string-to-number conversion from query parameters, headers, or URL params. This is a high-blast-radius config change that trades one class of bugs for widespread regressions. - Rate limiting or IP blocking — This vulnerability is not a volume-based attack. A single well-crafted request is sufficient. Rate limits provide no meaningful protection.
The supporting signals.
| In-the-Wild Exploitation | None observed. Not listed in CISA KEV. No GreyNoise or Shodan tags. No campaign reporting from Recorded Future, Mandiant, or CrowdStrike. |
|---|---|
| Proof-of-Concept Availability | No public PoC. pocindex.io returns no results. No GitHub repos named after the CVE. No ExploitDB, Nuclei template, or Metasploit module. The GHSA advisory includes a conceptual description but no weaponized exploit code. |
| EPSS Score | 0.00309 (0.31%) — bottom decile. Predicts negligible exploitation probability within 30 days. |
| CISA KEV Status | Not listed. No federal mandate to patch. |
| CVSS Vector | CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:N — Network-accessible but requires authentication. Low impact across both confidentiality and integrity. No availability impact. Scope unchanged. |
| Affected Versions | All Fastify versions < 5.12.1 (npm). The Fastify 4.x line is also affected if still in use. Object and array body schemas are not affected. |
| Fixed Version | Fastify 5.12.1 (commit 1ef8a60). Current latest is 5.12.5 as of 2026-09-16. |
| Installed Base | Fastify has ~15M weekly npm downloads and ~5,928 dependent packages. However, the vulnerable pattern (root-level primitive schemas) is uncommon — most projects use object schemas. |
| Disclosure Date | 2026-08-18 via GitHub Security Advisory GHSA-w2qp-rph6-63g4. |
| Reporting Researcher | velgusgus599 (reporter). Fix developed by mcollina (Matteo Collina, Fastify lead maintainer), reviewed by UlisesGascon. |
Sources.
Why this verdict
- Root-primitive prerequisite is a severe narrowing factor. The vulnerability only fires when a route defines a root-level primitive body schema — not an object, not an array. Fastify's docs, tutorials, and ecosystem plugins all default to object schemas. Conservatively <5% of Fastify routes use bare primitives, making the reachable attack surface a small fraction of the installed base.
- Application-specific impact caps the blast radius. Even when the type mismatch occurs, the handler receives a string whose *semantic content* matches the schema-validated value. In JavaScript's loosely-typed runtime,
"10" == 10istrue. Only handlers that perform strict type checks (===,typeof) AND use that check for a security decision are exploitable. This compounds on the already-narrow primitive-schema prerequisite. - PR:L limits attacker population further. The CVSS vector requires Low privileges — the attacker must already hold valid credentials. Unauthenticated exploitation is not possible.
- No exploitation evidence or tooling exists. Zero PoCs, zero KEV listing, EPSS at 0.31%, no GreyNoise/Shodan scanning activity, no campaign reporting. The bug has been public for 51 days with no weaponization.
- Role multiplier: Fastify is a general-purpose Node.js web framework. (a) *Low-value role* — dev servers, prototypes: many installs, no security consequence. (b) *Typical role* — API microservices: the main use case; impact is app-specific and bounded by handler logic. (c) *High-value role* — an auth or payment API built on Fastify could theoretically be affected, but these services universally use object body schemas (e.g.,
{"username":"…","password":"…"}), making the primitive-schema prerequisite a near-zero match. Fastify is not canonically a high-value-role component (it is not an IdP, DC, hypervisor, CA, PAM, or network edge). No plausible high-value-role chain produces domain/fleet/supply-chain impact. No floor applies.
Why not higher?
Upgrading to MEDIUM would require either (a) a realistic path to meaningful impact without the compounding application-specific prerequisites, or (b) evidence of exploitation or PoC availability lowering the attacker skill bar. Neither exists. The type mismatch bug only produces security-relevant consequences in a narrow coding pattern that sits well outside Fastify's mainstream usage. The vendor's own 5.4 MEDIUM is the ceiling, not the floor.
Why not lower?
Dropping to IGNORE would understate the risk for the small population of apps that *do* use root-level primitive schemas with security-relevant type checks. The vulnerability is real, network-accessible, and low-complexity *if* the prerequisite pattern is present. Fastify's large installed base (~15M weekly downloads) means even a 1-2% hit rate could represent thousands of deployable instances. A LOW rating correctly signals 'patch in your normal cadence, don't escalate.'
Crowdsourced verification payload.
Run on any host or CI runner with node and access to the project's node_modules. Execute from the project root directory: bash check_cve_2026_18504.sh. No elevated privileges required.
#!/usr/bin/env bash
# check_cve_2026_18504.sh
# Checks whether the installed fastify version is vulnerable to CVE-2026-18504
# (schema validation bypass via root primitive coercion mismatch)
# Exit codes: 0 = PATCHED, 1 = VULNERABLE, 2 = UNKNOWN
set -euo pipefail
FIXED_MAJOR=5
FIXED_MINOR=12
FIXED_PATCH=1
# Try to get the installed fastify version
if command -v node >/dev/null 2>&1; then
VERSION=$(node -e "try{console.log(require('fastify/package.json').version)}catch(e){console.log('NOT_FOUND')}" 2>/dev/null || echo "NOT_FOUND")
else
echo "UNKNOWN — node not found on PATH"
exit 2
fi
if [ "$VERSION" = "NOT_FOUND" ]; then
echo "UNKNOWN — fastify not installed in this project"
exit 2
fi
# Parse semver (strip any pre-release suffix)
CLEAN=$(echo "$VERSION" | sed 's/-.*//')
IFS='.' read -r MAJOR MINOR PATCH <<< "$CLEAN"
# Validate parsed values
if ! [[ "$MAJOR" =~ ^[0-9]+$ ]] || ! [[ "$MINOR" =~ ^[0-9]+$ ]] || ! [[ "$PATCH" =~ ^[0-9]+$ ]]; then
echo "UNKNOWN — could not parse fastify version: $VERSION"
exit 2
fi
# Version comparison
if [ "$MAJOR" -gt "$FIXED_MAJOR" ]; then
echo "PATCHED — fastify $VERSION (>= $FIXED_MAJOR.$FIXED_MINOR.$FIXED_PATCH)"
exit 0
elif [ "$MAJOR" -eq "$FIXED_MAJOR" ] && [ "$MINOR" -gt "$FIXED_MINOR" ]; then
echo "PATCHED — fastify $VERSION (>= $FIXED_MAJOR.$FIXED_MINOR.$FIXED_PATCH)"
exit 0
elif [ "$MAJOR" -eq "$FIXED_MAJOR" ] && [ "$MINOR" -eq "$FIXED_MINOR" ] && [ "$PATCH" -ge "$FIXED_PATCH" ]; then
echo "PATCHED — fastify $VERSION (>= $FIXED_MAJOR.$FIXED_MINOR.$FIXED_PATCH)"
exit 0
else
echo "VULNERABLE — fastify $VERSION (fixed in $FIXED_MAJOR.$FIXED_MINOR.$FIXED_PATCH)"
exit 1
fi