Просмотр исходного кода

fix: add origin-vs-host fallback for CSRF guard (#846)

The CSRF guard relied solely on sec-fetch-site header to determine
same-origin requests. When this header is absent (reverse proxy
stripping, older browsers), legitimate HTTP deployment login requests
were rejected with CSRF_REJECTED.

Add standard origin-vs-host comparison as a fallback, supporting both
Host and X-Forwarded-Host headers for reverse proxy scenarios.
ding113 1 месяц назад
Родитель
Сommit
42a20b12d7
2 измененных файлов с 142 добавлено и 0 удалено
  1. 36 0
      src/lib/security/csrf-origin-guard.ts
  2. 106 0
      tests/security/csrf-origin-guard.test.ts

+ 36 - 0
src/lib/security/csrf-origin-guard.ts

@@ -24,6 +24,32 @@ function isDevelopmentRuntime(): boolean {
   return process.env.NODE_ENV === "development";
 }
 
+/**
+ * Extract the effective host from request headers.
+ * Prefers X-Forwarded-Host (reverse proxy) then falls back to Host.
+ */
+function resolveEffectiveHost(request: CsrfGuardRequest): string | null {
+  const forwarded = request.headers.get("x-forwarded-host")?.trim().toLowerCase();
+  if (forwarded) {
+    const first = forwarded.split(",")[0]?.trim();
+    if (first) return first;
+  }
+  return request.headers.get("host")?.trim().toLowerCase() ?? null;
+}
+
+/**
+ * Compare Origin header against Host header (standard CSRF fallback).
+ * Extracts host:port from the Origin URL and compares with the request host.
+ */
+function isOriginMatchingHost(origin: string, host: string): boolean {
+  try {
+    const url = new URL(origin);
+    return url.host === host;
+  } catch {
+    return false;
+  }
+}
+
 export function createCsrfOriginGuard(config: CsrfGuardConfig) {
   const allowSameOrigin = config.allowSameOrigin ?? true;
   const enforceInDevelopment = config.enforceInDevelopment ?? false;
@@ -56,6 +82,16 @@ export function createCsrfOriginGuard(config: CsrfGuardConfig) {
         return { allowed: true };
       }
 
+      // Fallback: compare Origin against Host header (standard CSRF technique).
+      // Handles cases where sec-fetch-site is absent (reverse proxy stripping,
+      // older browsers) but the request is genuinely same-origin.
+      if (allowSameOrigin) {
+        const host = resolveEffectiveHost(request);
+        if (host && isOriginMatchingHost(origin, host)) {
+          return { allowed: true };
+        }
+      }
+
       if (allowedOrigins.has(origin)) {
         return { allowed: true };
       }

+ 106 - 0
tests/security/csrf-origin-guard.test.ts

@@ -130,4 +130,110 @@ describe("createCsrfOriginGuard", () => {
     expect(result.allowed).toBe(true);
     expect(result.reason).toBe("csrf_guard_bypassed_in_development");
   });
+
+  describe("origin-vs-host fallback", () => {
+    it("allows request when origin matches host header (no sec-fetch-site)", () => {
+      const guard = createCsrfOriginGuard({
+        allowedOrigins: [],
+        allowSameOrigin: true,
+        enforceInDevelopment: true,
+      });
+
+      const result = guard.check(
+        createRequest({
+          origin: "http://192.168.1.100:13500",
+          host: "192.168.1.100:13500",
+        })
+      );
+
+      expect(result).toEqual({ allowed: true });
+    });
+
+    it("allows request when origin matches x-forwarded-host (reverse proxy)", () => {
+      const guard = createCsrfOriginGuard({
+        allowedOrigins: [],
+        allowSameOrigin: true,
+        enforceInDevelopment: true,
+      });
+
+      const result = guard.check(
+        createRequest({
+          origin: "http://myapp.example.com",
+          host: "localhost:13500",
+          "x-forwarded-host": "myapp.example.com",
+        })
+      );
+
+      expect(result).toEqual({ allowed: true });
+    });
+
+    it("uses first value from comma-separated x-forwarded-host", () => {
+      const guard = createCsrfOriginGuard({
+        allowedOrigins: [],
+        allowSameOrigin: true,
+        enforceInDevelopment: true,
+      });
+
+      const result = guard.check(
+        createRequest({
+          origin: "http://front.example.com",
+          host: "internal:3000",
+          "x-forwarded-host": "front.example.com, proxy.internal",
+        })
+      );
+
+      expect(result).toEqual({ allowed: true });
+    });
+
+    it("rejects request when origin does not match host", () => {
+      const guard = createCsrfOriginGuard({
+        allowedOrigins: [],
+        allowSameOrigin: true,
+        enforceInDevelopment: true,
+      });
+
+      const result = guard.check(
+        createRequest({
+          origin: "http://evil.example.com",
+          host: "myapp.example.com:13500",
+        })
+      );
+
+      expect(result.allowed).toBe(false);
+    });
+
+    it("skips host fallback when allowSameOrigin is false", () => {
+      const guard = createCsrfOriginGuard({
+        allowedOrigins: [],
+        allowSameOrigin: false,
+        enforceInDevelopment: true,
+      });
+
+      const result = guard.check(
+        createRequest({
+          origin: "http://192.168.1.100:13500",
+          host: "192.168.1.100:13500",
+        })
+      );
+
+      expect(result.allowed).toBe(false);
+    });
+
+    it("handles https origin matching host without explicit port", () => {
+      const guard = createCsrfOriginGuard({
+        allowedOrigins: [],
+        allowSameOrigin: true,
+        enforceInDevelopment: true,
+      });
+
+      const result = guard.check(
+        createRequest({
+          origin: "https://example.com",
+          host: "example.com",
+        })
+      );
+
+      expect(result).toEqual({ allowed: true });
+    });
+  });
 });