Two OpenNext advisories this month describe the same failure from two directions: Next.js middleware making a security decision, and the adapter that runs it on Cloudflare or AWS losing that decision. CVE-2026-100409, which reached GitHub's reviewed database on 15 October, lets an encoded path skip middleware and still get the protected page from cache. GHSA-2mvv-9g7g-pfcg, published on 13 October, lets a request header the middleware deleted reach the page anyway. Both are fixed, but the second has no CVE yet, and the first sat unreviewed for three months.
This teardown is for teams that deploy Next.js with OpenNext, on Cloudflare Workers through @opennextjs/cloudflare or on AWS through @opennextjs/aws. It explains both bugs from the fix commits, reproduces the before and after logic, and gives you a script that checks a project for the combination that makes each one exploitable.
The short answer
Upgrade @opennextjs/cloudflare to 1.20.11 or later, which pins @opennextjs/aws 4.1.10 and so carries both fixes. The current release is 1.20.12. If you deploy with @opennextjs/aws directly, upgrade to 4.1.10 or later. CVE-2026-100409 affects @opennextjs/cloudflare from 0.3.0 up to 1.20.1, and only matters if you turned on enableCacheInterception and rely on middleware for access control. GHSA-2mvv-9g7g-pfcg affects @opennextjs/aws 4.1.9 and earlier, and only matters if your middleware deletes a request header that something downstream trusts. In both cases, enforce authorization in the route itself, not only in middleware.
Bug one: encoded paths and cache interception
Cache interception is an optional OpenNext feature, off by default, that serves a prerendered page straight from the incremental cache without running the full Next.js server. It sits after middleware in the request path. The bug was that the two steps read the path differently.
Middleware matching tested the request's pathname as received, still percent-encoded. Cache interception decoded it before picking a cache entry. So for an app with middleware on /admin/:path* and a cached /admin page, a request for /%61dmin (where %61 is a) didn't match the middleware, then decoded to /admin and got the cached page. The advisory shows exactly this request returning the protected content with x-opennext-cache: HIT.
A second variant used a malformed segment. The cache code decoded each path segment separately and, if one failed, kept it raw, so /%61dmin/%ZZ became /admin/%ZZ, a partly decoded key that could still select a cache entry.
The fix, in @opennextjs/aws pull request #1199 (merged on 20 July 2026), changed both sides to match Next.js:
// middleware.ts: also try the decoded pathname, as Next.js does
const hasMatch = middleMatch.some(
(r) =>
r.test(normalizedPath) ||
(decodedPath !== undefined && r.test(decodedPath)),
);
// cacheInterceptor.ts: decoding is all or nothing; a malformed path skips the cache
try {
localizedPath = decodePathParams(localizedPath) || "/";
} catch {
return event;
}
The new comment in the cache interceptor states the principle: "Partially decoding a malformed path could select a different cache route than the one evaluated by middleware." @opennextjs/aws 4.1.0 shipped the fix on 21 July, and @opennextjs/cloudflare 1.20.2 pinned it the same day. The Cloudflare repository published its advisory on 21 July too. The CVE record and GitHub's reviewed entry came on 15 October, so a scanner reading GitHub's database had nothing to flag for almost three months.
Bug two: deleted headers come back
The common Next.js pattern for stripping a client-supplied header looks like this:
const headers = new Headers(request.headers);
headers.delete("x-user-role");
return NextResponse.next({ request: { headers } });
Next.js passes the result back as a set of x-middleware-request-* headers plus an x-middleware-override-headers list naming every header to keep. Anything not on the list was deleted. The October fix's commit message spells out what OpenNext did instead: it "merged the overrides on top of the original headers instead, so a header deleted by the middleware was still visible to the page." Added and changed headers worked. Deletions didn't.
The advisory rates it high, CVSS 3.1 score 7.4, with high attack complexity because it needs an application-specific setup: middleware that deletes a header, and a page, route or backend that treats the header's absence as meaningful. It credits GitHub user Nicolapps. The fix shipped in @opennextjs/aws 4.1.10 on 13 October, and @opennextjs/cloudflare 1.20.11 pinned that version the same day. On 16 October, the advisory had no CVE and wasn't in GitHub's reviewed database, so scanners that rely on it won't report it yet.
Reproducing the logic
This script copies the lines each fix changed from OpenNext's routing code, reduced to plain functions, and runs both versions against the advisory's examples. It has no dependencies and makes no network calls.
// Compares OpenNext's routing logic before and after the July and October fixes.
// The functions mirror packages/open-next/src/core/routing in opennextjs-aws
// (MIT), reduced to the lines that changed. No network, no dependencies.
// A simplified compiled matcher for `matcher: "/admin/:path*"`.
const matcher = /^\/admin(?:\/.*)?$/;
// Cache key decoding (cacheInterceptor.ts)
const decodeOld = (p) =>
p.split("/").map((s) => { try { return decodeURIComponent(s); } catch { return s; } }).join("/");
const decodeNew = (p) => p.split("/").map((s) => decodeURIComponent(s)).join("/");
// Middleware matching (middleware.ts)
const matchOld = (p) => matcher.test(p);
const matchNew = (p) => {
let decoded;
try { decoded = decodeURIComponent(p); } catch {}
return matcher.test(p) || (decoded !== undefined && matcher.test(decoded));
};
console.log("1. Encoded paths, middleware matcher /admin/:path*, cached /admin page");
for (const path of ["/admin", "/%61dmin", "/%61dmin/%ZZ"]) {
let newKey;
try { newKey = decodeNew(path); } catch { newKey = "(rejected, cache skipped)"; }
console.log(` ${path.padEnd(14)} before: middleware ${matchOld(path) ? "runs" : "SKIPPED"}, cache key ${decodeOld(path)}`);
console.log(` ${"".padEnd(14)} after: middleware ${matchNew(path) ? "runs" : "SKIPPED"}, cache key ${newKey}`);
}
// Request headers after NextResponse.next({ request: { headers } })
const original = { host: "app.example", "x-user-role": "admin", cookie: "s=1" };
// Middleware deleted x-user-role. Next.js reports the headers to keep:
const middlewareResponse = {
"x-middleware-override-headers": "host,cookie",
"x-middleware-request-host": "app.example",
"x-middleware-request-cookie": "s=1",
};
const reqHeaders = Object.fromEntries(
Object.entries(middlewareResponse)
.filter(([k]) => k.startsWith("x-middleware-request-"))
.map(([k, v]) => [k.slice("x-middleware-request-".length), v]));
const headersOld = { ...original, ...reqHeaders };
const keep = new Set(middlewareResponse["x-middleware-override-headers"].split(",").map((k) => k.trim()));
const headersNew = Object.fromEntries(
[...Object.entries(original), ...Object.entries(reqHeaders)].filter(([k]) => keep.has(k)));
console.log("\n2. Client sends x-user-role: admin, middleware deletes it");
console.log(` before: page sees x-user-role = ${headersOld["x-user-role"] ?? "(absent)"}`);
console.log(` after: page sees x-user-role = ${headersNew["x-user-role"] ?? "(absent)"}`);
$ node opennext_logic.mjs
1. Encoded paths, middleware matcher /admin/:path*, cached /admin page
/admin before: middleware runs, cache key /admin
after: middleware runs, cache key /admin
/%61dmin before: middleware SKIPPED, cache key /admin
after: middleware runs, cache key /admin
/%61dmin/%ZZ before: middleware SKIPPED, cache key /admin/%ZZ
after: middleware SKIPPED, cache key (rejected, cache skipped)
2. Client sends x-user-role: admin, middleware deletes it
before: page sees x-user-role = admin
after: page sees x-user-role = (absent)
The matcher here is simplified, and the real code does more around it, so treat this as an illustration of the two mismatches rather than a test of a deployment. For the malformed path, the fixed middleware still doesn't run, because there's no valid decoded path to match. What changed is that cache interception now steps aside instead of guessing, and the request continues through normal routing.
Check your project
This script reads your lockfile (package-lock.json, pnpm-lock.yaml, yarn.lock or bun.lock), your open-next.config.*, and any middleware.* or proxy.* file. It reports the installed OpenNext versions, whether cache interception is on, and lines where middleware deletes a header, then says which bug, if any, has the conditions to apply. It reads files only and uses the Python standard library.
#!/usr/bin/env python3
"""Check a Next.js project deployed with OpenNext for two middleware bypasses.
CVE-2026-100409 @opennextjs/cloudflare < 1.20.2 with enableCacheInterception
GHSA-2mvv-9g7g-pfcg @opennextjs/aws < 4.1.10 when middleware deletes request headers
(@opennextjs/cloudflare pins aws 4.1.10 from 1.20.11)
Reads the lockfile, open-next.config.* and middleware.* or proxy.* files.
Reads files only. Standard library only.
python3 opennext_check.py path/to/project
"""
import json, os, re, sys
FIXED = {"@opennextjs/cloudflare": (1, 20, 2), "@opennextjs/aws": (4, 1, 10)}
SKIP = {"node_modules", ".git", ".next", ".open-next", "dist"}
def ver(s):
m = re.match(r"(\d+)\.(\d+)\.(\d+)", s or "")
return tuple(map(int, m.groups())) if m else None
def installed(root):
"""Versions of the two packages from package-lock.json, pnpm-lock.yaml, yarn.lock or bun.lock."""
found = {}
lock = os.path.join(root, "package-lock.json")
if os.path.exists(lock):
for path, meta in json.load(open(lock)).get("packages", {}).items():
name = path.rsplit("node_modules/", 1)[-1]
if name in FIXED and meta.get("version"):
found.setdefault(name, set()).add(meta["version"])
for fname in ("pnpm-lock.yaml", "yarn.lock", "bun.lock"):
p = os.path.join(root, fname)
if not os.path.exists(p):
continue
text = open(p, errors="replace").read()
for name in FIXED:
esc = re.escape(name)
for m in re.finditer(rf"['\"/]?{esc}@(?:npm:)?(\d+\.\d+\.\d+)", text):
found.setdefault(name, set()).add(m.group(1))
for m in re.finditer(rf"^\"?{esc}@[^\n]*\n\s+version:? \"?(\d+\.\d+\.\d+)", text, re.M):
found.setdefault(name, set()).add(m.group(1))
return found
def source_files(root, names):
for dirpath, dirs, files in os.walk(root):
dirs[:] = [d for d in dirs if d not in SKIP]
for f in files:
stem, ext = os.path.splitext(f)
if stem in names and ext in (".ts", ".js", ".mjs", ".cjs", ".mts"):
yield os.path.join(dirpath, f)
def main():
root = sys.argv[1] if len(sys.argv) > 1 else "."
exposed = 0
versions = installed(root)
if not versions:
print("No @opennextjs/cloudflare or @opennextjs/aws found in a lockfile.")
old = set()
for name, vs in sorted(versions.items()):
for v in sorted(vs, key=ver):
if ver(v) < FIXED[name]:
old.add(name)
print(f"{name}@{v}: {'VULNERABLE, upgrade' if ver(v) < FIXED[name] else 'patched'}")
cache_on = []
for f in source_files(root, {"open-next.config"}):
if re.search(r"enableCacheInterception\s*:\s*true", open(f, errors="replace").read()):
cache_on.append(f)
print(f"{os.path.relpath(f, root)}: enableCacheInterception is on")
deletes = []
for f in source_files(root, {"middleware", "proxy"}):
text = open(f, errors="replace").read()
if "NextResponse.next" not in text:
continue
for n, line in enumerate(text.splitlines(), 1):
if re.search(r"\.delete\(\s*['\"`]", line):
deletes.append(f)
print(f"{os.path.relpath(f, root)}:{n}: deletes a header: {line.strip()}")
if "@opennextjs/cloudflare" in old and cache_on:
print("EXPOSED CVE-2026-100409: protected cached routes can be fetched with an encoded path")
exposed = 1
if "@opennextjs/aws" in old and deletes:
print("EXPOSED GHSA-2mvv-9g7g-pfcg: headers your middleware deletes still reach the app")
exposed = 1
if not exposed:
print("No exposed combination found.")
return exposed
if __name__ == "__main__":
sys.exit(main())
On a test project with @opennextjs/cloudflare 1.20.1, cache interception on and the header-deleting middleware above:
$ python3 opennext_check.py site
@opennextjs/aws@4.0.2: VULNERABLE, upgrade
@opennextjs/cloudflare@1.20.1: VULNERABLE, upgrade
open-next.config.ts: enableCacheInterception is on
src/middleware.ts:5: deletes a header: headers.delete("x-user-role"); // never trust the client's value
EXPOSED CVE-2026-100409: protected cached routes can be fetched with an encoded path
EXPOSED GHSA-2mvv-9g7g-pfcg: headers your middleware deletes still reach the app
And the same project after upgrading to 1.20.12:
$ python3 opennext_check.py site
@opennextjs/aws@4.1.11: patched
@opennextjs/cloudflare@1.20.12: patched
open-next.config.ts: enableCacheInterception is on
src/middleware.ts:5: deletes a header: headers.delete("x-user-role"); // never trust the client's value
No exposed combination found.
The header check is a text search for .delete(" in files that call NextResponse.next, so it can miss a deletion done another way. Read your middleware too.
What to do
- Upgrade
@opennextjs/cloudflareto 1.20.11 or later, or@opennextjs/awsto 4.1.10 or later. Check the lockfile, notpackage.json, because Cloudflare's package pins an exact@opennextjs/awsversion. - If you can't upgrade yet, turn off
enableCacheInterception, and overwrite sensitive headers with a trusted value instead of deleting them, which is the workaround GHSA-2mvv-9g7g-pfcg recommends. - Move authorization into the route. Both workarounds in the CVE-2026-100409 advisory end in the same place: check access in the protected route handler or page, so a middleware miss isn't a breach.
- Reject encoded paths for protected routes at a trusted proxy if you need defense in depth. The advisory warns that adding encoded matcher variants is "not a complete workaround", since a path has many equivalent encodings.
- Don't wait for your scanner. Neither advisory was in GitHub's reviewed database on the day its fix shipped. Watch the repositories' own security advisory pages for the adapters you deploy with.
We covered the Next.js side of this pattern in our teardown of CVE-2025-29927, where a single request header skipped middleware entirely.
The fair view
Neither bug was easy to reach. The cache bug needed a non-default setting, a cached protected page and middleware as the only gate. The header bug needed middleware that deletes a header and code that trusts its absence. OpenNext's maintainers fixed both quickly, explained them clearly, and published workarounds.
But both bugs come from the same place. An adapter reimplements part of Next.js's request handling, and any difference between the reimplementation and the original is somewhere a security decision can fall through. Middleware works best as a routing step that can carry checks, not as the only gate. An app that makes it the only gate inherits every one of those differences, in every runtime it's deployed to.