Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions src/build/vite/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,19 +245,28 @@ export async function configureViteDevServer(ctx: NitroPluginContext, server: Vi
const fetchDest = req.headers["sec-fetch-dest"];
const accept = req.headers["accept"];
const ext = req.url!.split(/[?#]/, 1)[0].match(/\.([a-z0-9]+)$/i)?.[1];
const isNitroRoute = !!nitro.routing.routes.match(
req.method || "",
new URL(withBase(req.url!, nitro.options.baseURL), "http://localhost").pathname
);
const reqPathname = new URL(withBase(req.url!, nitro.options.baseURL), "http://localhost").pathname;
const nitroRouteMatch = nitro.routing.routes.match(req.method || "", reqPathname);
const isNitroRoute = !!nitroRouteMatch;
// Sec-Fetch-* is only sent on "potentially trustworthy" origins, so on plain-HTTP non-loopback (e.g. http://10.0.0.x) it's absent and a splat Nitro route may swallow browser asset loads (#4234). When the header is missing, treat known asset extensions without `text/html` in Accept as asset loads and let Vite handle them.
const isAssetByDest =
typeof fetchDest === "string" && !/^(document|iframe|frame|empty)$/.test(fetchDest);
const isAssetByExt = !!ext && ASSET_EXT_RE.test(ext);
const acceptsHTML = typeof accept === "string" && /\btext\/html\b/.test(accept);
const treatAsAsset = isAssetByDest || (!fetchDest && isAssetByExt && !acceptsHTML);
res.setHeader("vary", "sec-fetch-dest, accept");
// An explicit Nitro route reaches Nitro even when the request is tagged as an asset (e.g. `<img src="/api/image">` with `sec-fetch-dest: image`, #4241), UNLESS the URL also has an asset-like extension — in that case Vite stays the definitive handler so a splat doesn't swallow `<script src=".../entry-client.ts">` (#4234).
const nitroWins = isNitroRoute && !(isAssetByExt && treatAsAsset);
// An explicit Nitro route reaches Nitro even when the request is tagged as an asset (#4241).
// The asset-extension guard only applies to root-level wildcards (/**) to prevent a renderer
// or user catch-all from swallowing Vite's own <script>/<link> serves (#4234). Specific
// prefixed routes (e.g. /api/photos/**) are never wildcards on Vite-managed paths (#4252).
const matchedRoutePattern = Array.isArray(nitroRouteMatch)
? nitroRouteMatch[0]?.route
: nitroRouteMatch?.route;
const isRootWildcard =
!matchedRoutePattern ||
matchedRoutePattern === "/**" ||
matchedRoutePattern.startsWith("/**:");
const nitroWins = isNitroRoute && (!isRootWildcard || !(isAssetByExt && treatAsAsset));
Comment thread
harshagarwalnyu marked this conversation as resolved.
// Fallback for unknown URLs: extensionless, non-asset requests default to Nitro (page navigation, SSR catch-all).
const documentFallback = !ext && !treatAsAsset;
const routeToNitro =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default (event: any) => "root-wildcard:" + event.context.params!.path;
45 changes: 40 additions & 5 deletions test/vite/baseurl-dotted-param.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,49 @@ describe("vite:baseURL dotted params", { sequential: true }, () => {
expect(response.status).toBe(200);
expect(await response.text()).toBe("image");
});

// Browsers omit Sec-Fetch-* on plain-HTTP non-loopback origins (e.g. http://10.0.0.x:3000). Without that signal, a splat Nitro route would swallow `<script src=".../entry-client.ts">` requests. Accept + asset extension is used as a fallback to keep asset loads routed to Vite.
test("does not misroute asset loads to splat Nitro routes when sec-fetch-dest is absent", async () => {
// Browsers omit Sec-Fetch-* on plain-HTTP non-loopback origins (e.g. http://10.0.0.x:3000).
// Specific prefixed routes (e.g. /api/proxy/**) are never considered wildcards that
// swallow Vite assets, so they should now reach Nitro even with an asset extension
// and even if sec-fetch-dest is present.
test("prefixed splat routes win over Vite assets even with asset extensions and sec-fetch-dest", async () => {
for (const fetchDest of ["script", "style", undefined]) {
const headers: Record<string, string> = { accept: "*/*" };
if (fetchDest) {
headers["sec-fetch-dest"] = fetchDest;
}
const response = await fetch(`${serverURL}/subdir/api/proxy/entry-client.ts`, {
headers: { accept: "*/*" },
headers,
redirect: "manual",
});
expect(response.status, `fetchDest: ${fetchDest}`).toBe(200);
expect(await response.text(), `fetchDest: ${fetchDest}`).toBe("entry-client.ts");
}
});

// #4234 protection: A root-level `/**` catch-all MUST NOT swallow Vite assets.
test("root-level wildcards do not swallow Vite assets (protects #4234)", async () => {
for (const fetchDest of ["script", "style", undefined]) {
const headers: Record<string, string> = { accept: "*/*" };
if (fetchDest) {
headers["sec-fetch-dest"] = fetchDest;
}
const response = await fetch(`${serverURL}/subdir/entry-client.ts`, {
headers,
redirect: "manual",
});
expect(await response.text()).not.toBe("entry-client.ts");
// We expect Vite to handle this, which in this fixture's setup returns 404 (not a real file).
expect(response.status, `fetchDest: ${fetchDest}`).toBe(404);
}
});


test("root-level wildcards *do* swallow Vite assets when NOT an asset extension", async () => {
// This route matches the root /** catch-all in the fixture.
const response = await fetch(`${serverURL}/subdir/some-page`, {
redirect: "manual",
});
expect(response.status).toBe(200);
expect(await response.text()).toBe("root-wildcard:some-page");
});

// The extension extraction must look at the path only — a `.png` in the query string (e.g. `?file=bar.png`) must not flag the request as an asset and divert it to Vite.
Expand Down