Repository: vercel/next.js
Pull request: fix isCsrfOriginAllowed handling for localhost

`isCsrfOriginAllowed` assumes that the origin has a TLD. This means that when evaluating `foo.localhost` against `*.localhost`, it's immediately marked as an invalid origin, as it triggers the logic that returns false when wildcards are used below the domain level. This fixes that logic to account for localhost which won't have a TLD. 

x-ref: https://github.com/vercel/next.js/pull/77395#issuecomment-2757252420

Changed files:
filename: packages/next/src/server/app-render/csrf-protection.test.ts
patch: @@ -14,6 +14,39 @@ describe('isCsrfOriginAllowed', () => {
     )
   })
 
+  it("should correctly handle origins that don't have a TLD (eg for localhost)", () => {
+    // Single level wildcard
+    expect(isCsrfOriginAllowed('subdomain.localhost', ['*.localhost'])).toBe(
+      true
+    )
+    expect(isCsrfOriginAllowed('localhost', ['*.localhost'])).toBe(false)
+
+    // Multi-level wildcard
+    expect(isCsrfOriginAllowed('subdomain.localhost', ['**.localhost'])).toBe(
+      true
+    )
+    expect(isCsrfOriginAllowed('a.b.localhost', ['**.localhost'])).toBe(true)
+    expect(isCsrfOriginAllowed('localhost', ['**.localhost'])).toBe(false)
+
+    // Exact match
+    expect(isCsrfOriginAllowed('localhost', ['localhost'])).toBe(true)
+    expect(isCsrfOriginAllowed('subdomain.localhost', ['localhost'])).toBe(
+      false
+    )
+
+    // Multiple patterns
+    expect(
+      isCsrfOriginAllowed('subdomain.localhost', ['localhost', '*.localhost'])
+    ).toBe(true)
+    expect(
+      isCsrfOriginAllowed('a.b.localhost', [
+        'localhost',
+        '*.localhost',
+        '**.localhost',
+      ])
+    ).toBe(true)
+  })
+
   it('should return false when allowedOrigins contains originDomain with non-matching pattern', () => {
     expect(isCsrfOriginAllowed('asdf.vercel.com', ['*.vercel.app'])).toBe(false)
     expect(isCsrfOriginAllowed('asdf.jkl.vercel.com', ['*.vercel.com'])).toBe(
@@ -39,4 +72,9 @@ describe('isCsrfOriginAllowed', () => {
   it('should return false when allowedOrigins is empty string', () => {
     expect(isCsrfOriginAllowed('vercel.com', [''])).toBe(false)
   })
+
+  it('wildcards are only supported below the domain level', () => {
+    expect(isCsrfOriginAllowed('vercel.com', ['*'])).toBe(false)
+    expect(isCsrfOriginAllowed('vercel.com', ['**'])).toBe(false)
+  })
 })

filename: packages/next/src/server/app-render/csrf-protection.ts
patch: @@ -17,25 +17,13 @@ function matchWildcardDomain(domain: string, pattern: string) {
     return false
   }
 
-  let depth = 0
-  while (patternParts.length && depth++ < 2) {
-    const patternPart = patternParts.pop()
-    const domainPart = domainParts.pop()
-
-    switch (patternPart) {
-      case '':
-      case '*':
-      case '**': {
-        // invalid pattern. pattern segments must be non empty
-        // Additionally wildcards are only supported below the domain level
-        return false
-      }
-      default: {
-        if (domainPart !== patternPart) {
-          return false
-        }
-      }
-    }
+  // Prevent wildcards from matching entire domains (e.g. '**' or '*.com')
+  // This ensures wildcards can only match subdomains, not the main domain
+  if (
+    patternParts.length === 1 &&
+    (patternParts[0] === '*' || patternParts[0] === '**')
+  ) {
+    return false
   }
 
   while (patternParts.length) {