mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
1bba737dc4
* refactor: extract shared request handling into config-matchers and request-pipeline Deduplicate ~250 lines of inline request-handling logic from the generated App Router RSC entry (app-dev-server.ts) and Pages Router server entry (index.ts) by importing from the canonical config-matchers.ts and a new request-pipeline.ts module. Changes: - Create server/request-pipeline.ts with shared utilities: guardProtocolRelativeUrl, stripBasePath, normalizeTrailingSlash, validateCsrfOrigin, isOriginAllowed, validateImageUrl, processMiddlewareHeaders - app-dev-server.ts: replace 14 inline __-prefixed functions with imports from config-matchers.ts (matchRedirect, matchRewrite, matchHeaders, isExternalUrl, proxyExternalRequest, sanitizeDestination, requestContextFromRequest) and request-pipeline.ts (validateCsrfOrigin, validateImageUrl) - index.ts: replace inline parseCookies, sanitizeDestinationLocal, and matchConfigPattern with imports/re-exports from config-matchers.ts; replace manual RequestContext construction with requestContextFromRequest() - Update tests to reference imported function names instead of old inline __-prefixed names Security fixes: - matchConfigPattern: eliminates chained .replace() divergence (CodeQL flagged) in favor of config-matchers single-pass tokenizer - proxyExternalRequest: uses AbortController+setTimeout (broader runtime support) instead of AbortSignal.timeout() - escapeHeaderSource: uses proper config-matchers implementation instead of simplified chained .replace() Closes #254 t status * fix: address bonk review - remove dead applyTo param, dangling comment * fix: remove dangling comments (parseCookies stub, sanitizeDestination stub) * fix: remove bare block around __tsRedirect, add multiple trailing slash test * fix: stricter /api prefix check for trailing slash normalization (matches only /api or /api/), add tests for edge cases * test: update entry-template snapshots after merging main --------- Co-authored-by: James <james@eli.cx>
263 lines
10 KiB
TypeScript
263 lines
10 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
guardProtocolRelativeUrl,
|
|
stripBasePath,
|
|
normalizeTrailingSlash,
|
|
validateCsrfOrigin,
|
|
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("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("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");
|
|
});
|
|
});
|
|
|
|
// ── 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("allows requests with Origin: null", () => {
|
|
expect(validateCsrfOrigin(makeRequest({ host: "localhost:3000", origin: "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("allows requests with no Host header", () => {
|
|
// Can't construct a Request without host easily, but we can test the
|
|
// empty-host fallback by providing an origin but no host
|
|
const req = new Request("http://localhost:3000/api/action", {
|
|
headers: { origin: "http://localhost:3000" },
|
|
});
|
|
// When host is missing, the function returns null (allows)
|
|
expect(validateCsrfOrigin(req)).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ── 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");
|
|
});
|
|
});
|