From a091e1f74de9180610eb5b25372267717ffc5d68 Mon Sep 17 00:00:00 2001 From: Steve Faulkner Date: Fri, 10 Apr 2026 22:30:11 -0500 Subject: [PATCH] fix: use segment-based domain matching for CSRF origin wildcard patterns The wildcard origin matching in isOriginAllowed() used endsWith() for suffix matching, so a pattern like *.example.com would match any origin whose hostname ended with .example.com, including attacker-controlled domains like evil.example.com.attacker.com. Replace with segment-by-segment domain matching ported from Next.js: - * matches exactly one DNS label - ** matches one or more DNS labels - wildcards are blocked from matching entire domains - matching is case-insensitive per RFC 1035 Ported from Next.js: packages/next/src/server/app-render/csrf-protection.ts https://github.com/vercel/next.js/blob/canary/packages/next/src/server/app-render/csrf-protection.ts Tests ported from Next.js csrf-protection.test.ts plus an explicit regression test for the attacker-controlled suffix domain case. --- .../vinext/src/server/request-pipeline.ts | 53 +++++++++++-- tests/shims.test.ts | 76 +++++++++++++++++++ 2 files changed, 123 insertions(+), 6 deletions(-) diff --git a/packages/vinext/src/server/request-pipeline.ts b/packages/vinext/src/server/request-pipeline.ts index 1121f425b..c11680098 100644 --- a/packages/vinext/src/server/request-pipeline.ts +++ b/packages/vinext/src/server/request-pipeline.ts @@ -244,13 +244,54 @@ export async function validateServerActionPayload( * Check if an origin matches any pattern in the allowed origins list. * Supports wildcard subdomains (e.g. `*.example.com`). */ -function isOriginAllowed(origin: string, allowed: string[]): boolean { +/** + * Segment-by-segment domain matching for wildcard origin patterns. + * `*` matches exactly one DNS label; `**` matches one or more labels. + * + * Ported from Next.js: packages/next/src/server/app-render/csrf-protection.ts + * https://github.com/vercel/next.js/blob/canary/packages/next/src/server/app-render/csrf-protection.ts + */ +function matchWildcardDomain(domain: string, pattern: string): boolean { + const normalizedDomain = domain.replace(/[A-Z]/g, (c) => c.toLowerCase()); + const normalizedPattern = pattern.replace(/[A-Z]/g, (c) => c.toLowerCase()); + + const domainParts = normalizedDomain.split("."); + const patternParts = normalizedPattern.split("."); + + if (patternParts.length < 1) return false; + if (domainParts.length < patternParts.length) return false; + + // Prevent wildcards from matching entire domains (e.g. '**' or '*.com') + if (patternParts.length === 1 && (patternParts[0] === "*" || patternParts[0] === "**")) { + return false; + } + + while (patternParts.length) { + const patternPart = patternParts.pop(); + const domainPart = domainParts.pop(); + + switch (patternPart) { + case "": + return false; + case "*": + if (domainPart) continue; + else return false; + case "**": + if (patternParts.length > 0) return false; + return domainPart !== undefined; + default: + if (patternPart !== domainPart) return false; + } + } + + return domainParts.length === 0; +} + +export function isOriginAllowed(origin: string, allowed: string[]): boolean { for (const pattern of allowed) { - if (pattern.startsWith("*.")) { - // Wildcard: *.example.com matches sub.example.com, a.b.example.com - const suffix = pattern.slice(1); // ".example.com" - if (origin === pattern.slice(2) || origin.endsWith(suffix)) return true; - } else if (origin === pattern) { + if (pattern.includes("*")) { + if (matchWildcardDomain(origin, pattern)) return true; + } else if (origin.toLowerCase() === pattern.toLowerCase()) { return true; } } diff --git a/tests/shims.test.ts b/tests/shims.test.ts index dd8e18d53..9ffefbc2f 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -12152,3 +12152,79 @@ describe("checkHasConditions value anchoring", () => { expect(result).toBe(true); }); }); + +// ── CSRF origin wildcard matching ───────────────────────────────────────── +// Ported from Next.js: packages/next/src/server/app-render/csrf-protection.test.ts +// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/app-render/csrf-protection.test.ts + +describe("isOriginAllowed", () => { + let isOriginAllowed: (origin: string, allowed: string[]) => boolean; + + beforeEach(async () => { + const mod = await import("../packages/vinext/src/server/request-pipeline.js"); + isOriginAllowed = mod.isOriginAllowed; + }); + + it("exact match", () => { + expect(isOriginAllowed("vercel.com", ["vercel.com"])).toBe(true); + expect(isOriginAllowed("www.vercel.com", ["www.vercel.com"])).toBe(true); + }); + + it("single-level wildcard matches one subdomain", () => { + expect(isOriginAllowed("asdf.vercel.com", ["*.vercel.com"])).toBe(true); + }); + + it("single-level wildcard does NOT match multiple subdomains", () => { + expect(isOriginAllowed("asdf.jkl.vercel.com", ["*.vercel.com"])).toBe(false); + }); + + it("double wildcard matches one or more subdomains", () => { + expect(isOriginAllowed("asdf.vercel.com", ["**.vercel.com"])).toBe(true); + expect(isOriginAllowed("asdf.jkl.vercel.com", ["**.vercel.com"])).toBe(true); + }); + + it("does not match different TLD", () => { + expect(isOriginAllowed("asdf.vercel.com", ["*.vercel.app"])).toBe(false); + expect(isOriginAllowed("asdf.jkl.vercel.app", ["**.vercel.com"])).toBe(false); + }); + + it("does not match unrelated domain", () => { + expect(isOriginAllowed("vercel.com", ["nextjs.org"])).toBe(false); + }); + + it("returns false for undefined/empty allowed list", () => { + expect(isOriginAllowed("vercel.com", [])).toBe(false); + }); + + it("returns false for empty string pattern", () => { + expect(isOriginAllowed("vercel.com", [""])).toBe(false); + }); + + it("wildcards only match below the domain level", () => { + expect(isOriginAllowed("vercel.com", ["*"])).toBe(false); + expect(isOriginAllowed("vercel.com", ["**"])).toBe(false); + }); + + it("matches case-insensitively (RFC 1035)", () => { + expect(isOriginAllowed("sub.VERCEL.com", ["*.vercel.com"])).toBe(true); + expect(isOriginAllowed("SUB.vercel.COM", ["*.vercel.com"])).toBe(true); + expect(isOriginAllowed("VERCEL.COM", ["vercel.com"])).toBe(true); + expect(isOriginAllowed("vercel.com", ["VERCEL.COM"])).toBe(true); + }); + + it("localhost patterns", () => { + expect(isOriginAllowed("subdomain.localhost", ["*.localhost"])).toBe(true); + expect(isOriginAllowed("localhost", ["*.localhost"])).toBe(false); + expect(isOriginAllowed("subdomain.localhost", ["**.localhost"])).toBe(true); + expect(isOriginAllowed("a.b.localhost", ["**.localhost"])).toBe(true); + expect(isOriginAllowed("localhost", ["**.localhost"])).toBe(false); + expect(isOriginAllowed("localhost", ["localhost"])).toBe(true); + }); + + it("does NOT match attacker-controlled suffix domains", () => { + // This was the original vulnerability: endsWith(".example.com") matching + // evil.example.com.attacker.com + expect(isOriginAllowed("evil.example.com.attacker.com", ["*.example.com"])).toBe(false); + expect(isOriginAllowed("evil.example.com.attacker.com", ["**.example.com"])).toBe(false); + }); +});