Files
cloudflare__vinext/tests/request-pipeline.test.ts
T
Steve Faulkner f910e74045 fix(app-router): reject cyclic Flight payloads in server actions (#798)
* fix(app-router): reject cyclic Flight payloads in server actions

* test(app-router): update generated entry snapshots
2026-04-08 22:11:15 -05:00

377 lines
14 KiB
TypeScript

import { describe, it, expect } from "vite-plus/test";
import {
guardProtocolRelativeUrl,
hasBasePath,
stripBasePath,
normalizeTrailingSlash,
validateCsrfOrigin,
validateServerActionPayload,
validateImageUrl,
processMiddlewareHeaders,
} from "../packages/vinext/src/server/request-pipeline.js";
// ── guardProtocolRelativeUrl ────────────────────────────────────────────
describe("guardProtocolRelativeUrl", () => {
it("returns 404 for // protocol-relative paths", () => {
const res = guardProtocolRelativeUrl("//evil.com");
expect(res).not.toBeNull();
expect(res!.status).toBe(404);
});
it("returns 404 for backslash protocol-relative paths", () => {
const res = guardProtocolRelativeUrl("/\\evil.com");
expect(res).not.toBeNull();
expect(res!.status).toBe(404);
});
it("returns null for normal paths", () => {
expect(guardProtocolRelativeUrl("/about")).toBeNull();
expect(guardProtocolRelativeUrl("/")).toBeNull();
expect(guardProtocolRelativeUrl("/api/data")).toBeNull();
});
});
// ── stripBasePath ───────────────────────────────────────────────────────
describe("hasBasePath", () => {
it("matches exact basePath and basePath-prefixed descendants only", () => {
expect(hasBasePath("/app", "/app")).toBe(true);
expect(hasBasePath("/app/about", "/app")).toBe(true);
expect(hasBasePath("/application/about", "/app")).toBe(false);
expect(hasBasePath("/app2", "/app")).toBe(false);
});
it("handles nested basePath segments", () => {
expect(hasBasePath("/docs/v2", "/docs/v2")).toBe(true);
expect(hasBasePath("/docs/v2/guide", "/docs/v2")).toBe(true);
expect(hasBasePath("/docs/v20", "/docs/v2")).toBe(false);
});
});
describe("stripBasePath", () => {
it("strips basePath prefix from pathname", () => {
expect(stripBasePath("/docs/about", "/docs")).toBe("/about");
});
it("returns / when pathname equals basePath", () => {
expect(stripBasePath("/docs", "/docs")).toBe("/");
});
it("strips when the next character is a path separator", () => {
expect(stripBasePath("/docs/about/team", "/docs")).toBe("/about/team");
});
it("returns pathname unchanged when basePath is empty", () => {
expect(stripBasePath("/about", "")).toBe("/about");
});
it("returns pathname unchanged when it doesn't start with basePath", () => {
expect(stripBasePath("/other/page", "/docs")).toBe("/other/page");
});
it("does not strip when pathname only shares a string prefix with basePath", () => {
expect(stripBasePath("/application/about", "/app")).toBe("/application/about");
expect(stripBasePath("/app2", "/app")).toBe("/app2");
expect(stripBasePath("/apple", "/app")).toBe("/apple");
});
});
// ── normalizeTrailingSlash ──────────────────────────────────────────────
describe("normalizeTrailingSlash", () => {
it("redirects /about → /about/ when trailingSlash is true", () => {
const res = normalizeTrailingSlash("/about", "", true, "");
expect(res).not.toBeNull();
expect(res!.status).toBe(308);
expect(res!.headers.get("Location")).toBe("/about/");
});
it("redirects /about/ → /about when trailingSlash is false", () => {
const res = normalizeTrailingSlash("/about/", "", false, "");
expect(res).not.toBeNull();
expect(res!.status).toBe(308);
expect(res!.headers.get("Location")).toBe("/about");
});
it("preserves query string in redirect", () => {
const res = normalizeTrailingSlash("/about", "", true, "?foo=1");
expect(res!.headers.get("Location")).toBe("/about/?foo=1");
});
it("prepends basePath to redirect Location", () => {
const res = normalizeTrailingSlash("/about", "/docs", true, "");
expect(res!.headers.get("Location")).toBe("/docs/about/");
});
it("does not redirect the root path", () => {
expect(normalizeTrailingSlash("/", "", true, "")).toBeNull();
expect(normalizeTrailingSlash("/", "", false, "")).toBeNull();
});
it("does not redirect /api routes", () => {
expect(normalizeTrailingSlash("/api/data", "", true, "")).toBeNull();
});
it("does not redirect .rsc requests when trailingSlash is true", () => {
expect(normalizeTrailingSlash("/about.rsc", "", true, "")).toBeNull();
});
it("returns null when pathname already matches the trailingSlash setting", () => {
expect(normalizeTrailingSlash("/about/", "", true, "")).toBeNull();
expect(normalizeTrailingSlash("/about", "", false, "")).toBeNull();
});
it("strips multiple trailing slashes when trailingSlash is false", () => {
const res = normalizeTrailingSlash("/about///", "", false, "");
expect(res).not.toBeNull();
expect(res!.headers.get("Location")).toBe("/about");
});
it("does not redirect /api or /api/", () => {
expect(normalizeTrailingSlash("/api", "", true, "")).toBeNull();
expect(normalizeTrailingSlash("/api", "", false, "")).toBeNull();
expect(normalizeTrailingSlash("/api/", "", true, "")).toBeNull();
expect(normalizeTrailingSlash("/api/", "", false, "")).toBeNull();
});
it("redirects /api-docs when trailingSlash is true", () => {
const res = normalizeTrailingSlash("/api-docs", "", true, "");
expect(res).not.toBeNull();
expect(res!.status).toBe(308);
expect(res!.headers.get("Location")).toBe("/api-docs/");
});
it("redirects /api-docs/ when trailingSlash is false", () => {
const res = normalizeTrailingSlash("/api-docs/", "", false, "");
expect(res).not.toBeNull();
expect(res!.status).toBe(308);
expect(res!.headers.get("Location")).toBe("/api-docs");
});
});
// ── validateCsrfOrigin ──────────────────────────────────────────────────
describe("validateCsrfOrigin", () => {
function makeRequest(headers: Record<string, string>): Request {
return new Request("http://localhost:3000/api/action", { headers });
}
it("allows requests with no Origin header", () => {
expect(validateCsrfOrigin(makeRequest({ host: "localhost:3000" }))).toBeNull();
});
it("blocks requests with Origin: null (CSRF via sandboxed context)", () => {
const res = validateCsrfOrigin(makeRequest({ host: "localhost:3000", origin: "null" }));
expect(res).not.toBeNull();
expect(res!.status).toBe(403);
});
it("allows Origin: null when explicitly in allowedOrigins", () => {
expect(
validateCsrfOrigin(makeRequest({ host: "localhost:3000", origin: "null" }), ["null"]),
).toBeNull();
});
it("allows same-origin requests", () => {
const req = makeRequest({ host: "localhost:3000", origin: "http://localhost:3000" });
expect(validateCsrfOrigin(req)).toBeNull();
});
it("blocks cross-origin requests", () => {
const req = makeRequest({ host: "localhost:3000", origin: "http://evil.com" });
const res = validateCsrfOrigin(req);
expect(res).not.toBeNull();
expect(res!.status).toBe(403);
});
it("allows cross-origin requests when origin is in allowedOrigins", () => {
const req = makeRequest({ host: "localhost:3000", origin: "http://trusted.com" });
expect(validateCsrfOrigin(req, ["trusted.com"])).toBeNull();
});
it("supports wildcard subdomain patterns in allowedOrigins", () => {
const req = makeRequest({ host: "localhost:3000", origin: "http://sub.example.com" });
expect(validateCsrfOrigin(req, ["*.example.com"])).toBeNull();
});
it("rejects wildcard patterns that don't match", () => {
const req = makeRequest({ host: "localhost:3000", origin: "http://other.com" });
const res = validateCsrfOrigin(req, ["*.example.com"]);
expect(res).not.toBeNull();
expect(res!.status).toBe(403);
});
it("returns 403 for malformed Origin headers", () => {
const req = makeRequest({ host: "localhost:3000", origin: "not-a-url" });
const res = validateCsrfOrigin(req);
expect(res).not.toBeNull();
expect(res!.status).toBe(403);
});
it("falls back to request.url host when Host header is missing", () => {
const req = new Request("http://localhost:3000/api/action", {
headers: { origin: "http://localhost:3000" },
});
expect(validateCsrfOrigin(req)).toBeNull();
});
it("still blocks cross-origin requests when Host header is missing", () => {
const req = new Request("http://localhost:3000/api/action", {
headers: { origin: "http://evil.com" },
});
const res = validateCsrfOrigin(req);
expect(res).not.toBeNull();
expect(res!.status).toBe(403);
});
});
// ── validateServerActionPayload ─────────────────────────────────────────
describe("validateServerActionPayload", () => {
it("allows plain JSON action bodies with no Flight container references", async () => {
await expect(validateServerActionPayload('["hello",1]')).resolves.toBeNull();
});
it("allows valid Map backing-field payloads", async () => {
const body = new FormData();
body.set("0", '["$Q1"]');
body.set("1", '[["a",1],["b",2]]');
await expect(validateServerActionPayload(body)).resolves.toBeNull();
});
it("allows file-backed numeric fields when the backing graph is valid", async () => {
const body = new FormData();
body.set("0", new File(['["$Q1"]'], "root.txt", { type: "application/json" }));
body.set("1", new File(['[["a",1],["b",2]]'], "map.txt", { type: "application/json" }));
await expect(validateServerActionPayload(body)).resolves.toBeNull();
});
it("ignores normal user form fields", async () => {
const body = new FormData();
body.set("message", "$Q0 should stay user data");
await expect(validateServerActionPayload(body)).resolves.toBeNull();
});
it("rejects missing container backing fields", async () => {
const body = new FormData();
body.set("0", '["$Q1"]');
const res = await validateServerActionPayload(body);
expect(res).not.toBeNull();
expect(res!.status).toBe(400);
await expect(res!.text()).resolves.toBe("Invalid server action payload");
});
it("rejects self-referential root container payloads", async () => {
const body = new FormData();
body.set("0", '["$Q0","$Q0"]');
const res = await validateServerActionPayload(body);
expect(res).not.toBeNull();
expect(res!.status).toBe(400);
await expect(res!.text()).resolves.toBe("Invalid server action payload");
});
it("rejects self-referential file-backed root container payloads", async () => {
const body = new FormData();
body.set("0", new File(['["$Q0","$Q0"]'], "root.txt", { type: "application/json" }));
const res = await validateServerActionPayload(body);
expect(res).not.toBeNull();
expect(res!.status).toBe(400);
await expect(res!.text()).resolves.toBe("Invalid server action payload");
});
it("rejects cyclic container reference graphs across backing fields", async () => {
const body = new FormData();
body.set("0", '["$Q1"]');
body.set("1", '["$Q2"]');
body.set("2", '["$Q1"]');
const res = await validateServerActionPayload(body);
expect(res).not.toBeNull();
expect(res!.status).toBe(400);
await expect(res!.text()).resolves.toBe("Invalid server action payload");
});
});
// ── validateImageUrl ────────────────────────────────────────────────────
describe("validateImageUrl", () => {
const requestUrl = "http://localhost:3000/page";
it("returns the normalized image URL for valid relative paths", () => {
expect(validateImageUrl("/images/photo.png", requestUrl)).toBe("/images/photo.png");
});
it("returns 400 for missing url parameter", () => {
const res = validateImageUrl(null, requestUrl);
expect(res).toBeInstanceOf(Response);
expect((res as Response).status).toBe(400);
});
it("returns 400 for empty string", () => {
const res = validateImageUrl("", requestUrl);
expect(res).toBeInstanceOf(Response);
expect((res as Response).status).toBe(400);
});
it("returns 400 for absolute URLs", () => {
const res = validateImageUrl("http://evil.com/image.png", requestUrl);
expect(res).toBeInstanceOf(Response);
expect((res as Response).status).toBe(400);
});
it("returns 400 for protocol-relative URLs", () => {
const res = validateImageUrl("//evil.com/image.png", requestUrl);
expect(res).toBeInstanceOf(Response);
expect((res as Response).status).toBe(400);
});
it("normalizes backslashes and blocks protocol-relative variants", () => {
const res = validateImageUrl("/\\evil.com/image.png", requestUrl);
expect(res).toBeInstanceOf(Response);
expect((res as Response).status).toBe(400);
});
});
// ── processMiddlewareHeaders ────────────────────────────────────────────
describe("processMiddlewareHeaders", () => {
it("strips x-middleware-next header", () => {
const headers = new Headers({
"x-middleware-next": "1",
"content-type": "text/html",
});
processMiddlewareHeaders(headers);
expect(headers.has("x-middleware-next")).toBe(false);
expect(headers.get("content-type")).toBe("text/html");
});
it("strips x-middleware-request-* headers", () => {
const headers = new Headers({
"x-middleware-request-x-custom": "value",
"x-middleware-rewrite": "/new-path",
"content-type": "text/html",
});
processMiddlewareHeaders(headers);
expect(headers.has("x-middleware-request-x-custom")).toBe(false);
expect(headers.has("x-middleware-rewrite")).toBe(false);
expect(headers.get("content-type")).toBe("text/html");
});
it("is a no-op when no x-middleware-* headers are present", () => {
const headers = new Headers({
"content-type": "text/html",
"x-custom": "keep",
});
processMiddlewareHeaders(headers);
expect(headers.get("content-type")).toBe("text/html");
expect(headers.get("x-custom")).toBe("keep");
});
});