Browse Source

fix(security): validate CSP report-uri to prevent directive injection

ding113 2 weeks ago
parent
commit
535a2907f6
2 changed files with 57 additions and 3 deletions
  1. 18 3
      src/lib/security/security-headers.ts
  2. 39 0
      tests/security/security-headers.test.ts

+ 18 - 3
src/lib/security/security-headers.ts

@@ -13,6 +13,19 @@ export const DEFAULT_SECURITY_HEADERS_CONFIG: SecurityHeadersConfig = {
   frameOptions: "DENY",
 };
 
+function isValidCspReportUri(uri: string): boolean {
+  const trimmed = uri.trim();
+  if (!trimmed || trimmed.includes(";") || trimmed.includes(",") || /\s/.test(trimmed)) {
+    return false;
+  }
+  try {
+    new URL(trimmed);
+    return true;
+  } catch {
+    return false;
+  }
+}
+
 const DEFAULT_CSP_VALUE =
   "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' " +
   "'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; font-src 'self' data:; " +
@@ -39,9 +52,11 @@ export function buildSecurityHeaders(
         ? "Content-Security-Policy-Report-Only"
         : "Content-Security-Policy";
 
-    headers[headerName] = merged.cspReportUri
-      ? `${DEFAULT_CSP_VALUE}; report-uri ${merged.cspReportUri}`
-      : DEFAULT_CSP_VALUE;
+    if (merged.cspReportUri && isValidCspReportUri(merged.cspReportUri)) {
+      headers[headerName] = `${DEFAULT_CSP_VALUE}; report-uri ${merged.cspReportUri}`;
+    } else {
+      headers[headerName] = DEFAULT_CSP_VALUE;
+    }
   }
 
   return headers;

+ 39 - 0
tests/security/security-headers.test.ts

@@ -69,4 +69,43 @@ describe("buildSecurityHeaders", () => {
     expect(denyHeaders["X-Frame-Options"]).toBe("DENY");
     expect(sameOriginHeaders["X-Frame-Options"]).toBe("SAMEORIGIN");
   });
+
+  test("cspReportUri with valid URL appends report-uri directive", () => {
+    const headers = buildSecurityHeaders({
+      cspMode: "report-only",
+      cspReportUri: "https://csp.example.com/report",
+    });
+
+    expect(headers["Content-Security-Policy-Report-Only"]).toContain(
+      "; report-uri https://csp.example.com/report"
+    );
+  });
+
+  test("cspReportUri with semicolons is rejected to prevent directive injection", () => {
+    const headers = buildSecurityHeaders({
+      cspMode: "enforce",
+      cspReportUri: "https://evil.com; script-src 'unsafe-eval'",
+    });
+
+    expect(headers["Content-Security-Policy"]).not.toContain("report-uri");
+    expect(headers["Content-Security-Policy"]).not.toContain("evil.com");
+  });
+
+  test("cspReportUri with non-URL value is rejected", () => {
+    const headers = buildSecurityHeaders({
+      cspMode: "enforce",
+      cspReportUri: "not a url",
+    });
+
+    expect(headers["Content-Security-Policy"]).not.toContain("report-uri");
+  });
+
+  test("cspReportUri with empty string is rejected", () => {
+    const headers = buildSecurityHeaders({
+      cspMode: "enforce",
+      cspReportUri: "",
+    });
+
+    expect(headers["Content-Security-Policy"]).not.toContain("report-uri");
+  });
 });