Three bugs in vinext's alpha, Cloudflare's AI-built Next.js replacement
Contents
vinext is Cloudflare’s reimplementation of the Next.js API surface on top of Vite. It runs an existing Next.js app with the source mostly unchanged and deploys to Cloudflare Workers with one command. It was built by one engineer directing an AI model, in about a week, for roughly $1,100 in tokens, and Cloudflare labels it experimental. The three bugs described here affected the early alpha at commit e9fdc7b. I reported them on February 28, and Cloudflare fixed them soon afterward, so they do not affect current vinext releases, including the recent beta.
I came across it through Hacktron’s writeup, which had already turned up a batch of bugs. I found three that were still open: an authentication bypass that lets an unauthenticated request past any middleware path matcher, a middleware header-sanitization bypass, and a reflected XSS in the head serializer. Each one comes down to a Next.js behavior that the reimplementation did not reproduce.
An authentication bypass through the i18n locale prefix
This is the strongest of the three, and it needs nothing exotic: an app configured for i18n with a couple of locales, and auth middleware that protects a section of the site with a path matcher.
// next.config.js
export default {
i18n: { locales: ["en", "fr", "de"], defaultLocale: "en" },
};
// middleware.ts
export function middleware(request: NextRequest) {
const authCookie = request.cookies.get("auth");
if (!authCookie) {
return new NextResponse(JSON.stringify({ error: "BLOCKED" }), { status: 403 });
}
return NextResponse.next();
}
export const config = { matcher: ["/dashboard/:path*"] };
vinext looks at the URL differently in two places. Deciding whether to run the middleware, it checks the matcher against the pathname with the locale prefix still on it (packages/vinext/src/server/middleware.ts):
const decodedPathname = decodeURIComponent(url.pathname);
const normalizedPathname = normalizePath(decodedPathname);
if (!matchesMiddleware(normalizedPathname, matcher)) {
return { continue: true }; // middleware is skipped entirely
}
Resolving the actual route, it does the opposite and strips the locale first (dev-server.ts, and index.ts in the same order):
const parsed = extractLocaleFromUrl(url, i18nConfig);
const localeStrippedUrl = parsed.url;
// ...
const match = matchRoute(localeStrippedUrl, routes);
So /fr/dashboard is tested against /dashboard/:path* with the /fr still attached, doesn’t match, and the middleware is skipped, so the auth check never runs. The router then strips the /fr and serves /dashboard. The matcher and the route resolver disagree about the path, and the request falls through the gap.
GET /dashboard HTTP/1.1
Host: localhost:3001The matcher sees /dashboard, so the authentication middleware runs and
returns 403 Forbidden.
GET /fr/dashboard HTTP/1.1
Host: localhost:3001The matcher sees /fr/dashboard, which does not match
/dashboard/:path*, so vinext skips the middleware. The router then removes
/fr, resolves /dashboard, and returns the page with 200 OK.
Every locale is its own way in, nested paths like /de/dashboard/data too, on both dev and production builds.
Next.js strips the locale before evaluating the matcher, so the matcher sees /dashboard and the check fires. The fix on vinext is to do the same, strip the locale before the matcher runs while still handing the middleware the full URL so it can make locale-aware decisions. Cloudflare fixed this in commit f7c0d7c (2026-03-10).
Any route behind such a matcher is reachable unauthenticated by prefixing a locale, and both i18n and matcher-based auth are standard.
Headers that survive middleware sanitization
Next.js middleware can hand a route handler a fresh set of request headers through NextResponse.next({ request: { headers } }). Applications use this as an allowlist: put exactly the identity or trust headers the handler should see into a Headers object, and everything else the client sent is meant to be gone downstream.
// middleware.ts
export function middleware(request: NextRequest) {
const cleanHeaders = new Headers();
cleanHeaders.set("x-authenticated-user", "anonymous");
cleanHeaders.set("x-role", "viewer");
return NextResponse.next({ request: { headers: cleanHeaders } });
}
export const config = { matcher: ["/api/:path*"] };
Next.js enforces that with an internal header, x-middleware-override-headers, which lists the names the middleware provided; the receiving side drops every original header not on the list. vinext never emits it (the string isn’t anywhere in the codebase). Instead it passes each header through under an x-middleware-request-* prefix (packages/vinext/src/shims/server.ts):
static next(init?: MiddlewareResponseInit): NextResponse {
const headers = new Headers(init?.headers);
headers.set("x-middleware-next", "1");
if (init?.request?.headers) {
for (const [key, value] of init.request.headers.entries()) {
headers.set(`x-middleware-request-${key}`, value);
}
}
// x-middleware-override-headers is never set
// ...
}
and copies them back onto the request with set() (prod-server.ts):
const mwReqPrefix = "x-middleware-request-";
for (const key of Object.keys(middlewareHeaders)) {
if (key.startsWith(mwReqPrefix)) {
const realName = key.slice(mwReqPrefix.length);
webRequest.headers.set(realName, middlewareHeaders[key]);
// originals not in the override set are never removed
}
}
set() overwrites a header the middleware named but never removes the ones it didn’t, so the header set turns from an allowlist into a merge on top of the client’s headers.
GET /api/whoami HTTP/1.1
Host: localhost:3004
x-role: adminThe middleware explicitly sets x-role, so the handler sees viewer
instead of the client-supplied admin. Named headers behave as expected.
GET /api/whoami HTTP/1.1
Host: localhost:3004
x-internal-service: trueThe middleware does not include x-internal-service, but vinext never
removes the original header. The handler still receives
x-internal-service: true.
This matters where an app treats that header set as a trust boundary, say a handler that trusts x-internal-service: true because middleware was meant to be the only thing that could set it. Plain cookie-based auth in middleware isn’t affected. Where the pattern is used, an attacker can forge the markers the middleware was supposed to own. The fix is to emit x-middleware-override-headers and drop originals not on the list. Cloudflare fixed this in commit f466a5e (2026-03-10).
Reflected XSS through unescaped attribute names in the head serializer
A common Pages Router pattern for dynamic meta tags spreads an object of key/value pairs onto a meta element inside next/head. When the object is router.query, the keys come straight from the URL:
// pages/search.tsx
import Head from "next/head";
import { useRouter } from "next/router";
export default function SearchPage() {
const router = useRouter();
return (
<Head>
<meta {...(router.query as Record<string, string>)} />
<title>Search</title>
</Head>
);
}
vinext renders next/head to HTML on the server, and its serializer escapes attribute values but writes the attribute names verbatim (reactElementToHTML() in packages/vinext/src/shims/head.ts):
} else if (typeof value === "string") {
attrs.push(`${key}="${escapeAttr(value)}"`);
} else if (typeof value === "boolean" && value) {
attrs.push(key);
}} else if (typeof value === "string") {
if (!isSafeAttrName(key)) continue;
attrs.push(`${key}="${escapeAttr(value)}"`);
} else if (typeof value === "boolean" && value) {
if (!isSafeAttrName(key)) continue;
attrs.push(key);
}The alpha escaped value but interpolated key directly. The patch rejects
unsafe names before serialization and applies the same check to boolean
attributes.
Spread router.query and its keys become attribute names, so a key can close the attribute and the tag and inject markup after it. The other half is that vinext fills router.query from the request URL during server rendering, so the attacker-controlled keys are in the very first response, before any client code runs.
GET /search?x%22/%3E%3Cscript%3Ealert(1)%3C/script%3E%3Cmeta%20a%3D%22=1 HTTP/1.1
Host: localhost:3005URL decoding turns the query parameter name into
x"/><script>alert(1)</script><meta a=".
x"/><script>alert(1)</script><meta a="Pages Router SSR places the key in router.query. Spreading router.query
onto <meta> turns the attacker-controlled key into an attribute name.
<head>
<meta x"/><script>alert(1)</script><meta a="="1" data-vinext-head="true" />
<title data-vinext-head="true">Search</title>
</head>The serializer writes the attribute name verbatim. The injected <script>
is present in the initial response and executes when the page loads.
The same code is safe on Next.js, for two reasons that both fail to hold here: it leaves router.query empty during SSR for a statically optimized page, so nothing attacker-controlled is prerendered, and when the values do show up during hydration the browser’s setAttribute() rejects a name containing ", >, or /. vinext does neither. An earlier vinext fix, commit 6abbb2a (“dangerous URI schemes in Link and Form”), is a different sink and doesn’t cover this. The fix is to reject invalid attribute names during head serialization, or to leave router.query empty during prerendering like Next.js. Cloudflare fixed this in commit f77068c (2026-03-20).
router.query is the obvious source, but the same holds for keys taken from CMS fields, database records, or API responses whose field names aren’t fully trusted.
The three findings
| Finding | Severity |
|---|---|
| authentication bypass via i18n locale prefix on middleware matchers | Medium |
middleware header set merged instead of enforced as an allowlist (missing x-middleware-override-headers) |
Low |
reflected XSS via unescaped attribute names in the next/head SSR serializer |
Low |
Of the three, the locale bypass was the most serious: i18n plus a path-matcher auth middleware is a common setup, and it needed nothing but a locale prefix. The header and XSS issues were more conditional, each needing the app to use middleware or head rendering in a particular way.
Thanks to Cloudflare’s security team for taking the reports through their public bug bounty and for the go-ahead on writing this up.
References
- vinext: github.com/cloudflare/vinext
- Cloudflare on building vinext: blog.cloudflare.com/vinext
- Hacktron’s vinext audit: hacktron.ai/blog/hacking-cloudflare-vinext