security-headers-integration.test.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. import { beforeEach, describe, expect, it, vi } from "vitest";
  2. import { NextRequest } from "next/server";
  3. import { applyCors } from "../../src/app/v1/_lib/cors";
  4. const mockValidateKey = vi.hoisted(() => vi.fn());
  5. const mockSetAuthCookie = vi.hoisted(() => vi.fn());
  6. const mockGetSessionTokenMode = vi.hoisted(() => vi.fn());
  7. const mockGetLoginRedirectTarget = vi.hoisted(() => vi.fn());
  8. const mockClearAuthCookie = vi.hoisted(() => vi.fn());
  9. const mockGetAuthCookie = vi.hoisted(() => vi.fn());
  10. const mockGetTranslations = vi.hoisted(() => vi.fn());
  11. const mockGetEnvConfig = vi.hoisted(() => vi.fn());
  12. const mockLogger = vi.hoisted(() => ({
  13. warn: vi.fn(),
  14. error: vi.fn(),
  15. info: vi.fn(),
  16. debug: vi.fn(),
  17. }));
  18. vi.mock("@/lib/auth", () => ({
  19. validateKey: mockValidateKey,
  20. setAuthCookie: mockSetAuthCookie,
  21. getSessionTokenMode: mockGetSessionTokenMode,
  22. getLoginRedirectTarget: mockGetLoginRedirectTarget,
  23. clearAuthCookie: mockClearAuthCookie,
  24. getAuthCookie: mockGetAuthCookie,
  25. withNoStoreHeaders: <T>(response: T): T => {
  26. (response as Response).headers.set("Cache-Control", "no-store, no-cache, must-revalidate");
  27. (response as Response).headers.set("Pragma", "no-cache");
  28. return response;
  29. },
  30. }));
  31. vi.mock("next-intl/server", () => ({
  32. getTranslations: mockGetTranslations,
  33. }));
  34. vi.mock("@/lib/config/env.schema", () => ({
  35. getEnvConfig: mockGetEnvConfig,
  36. }));
  37. vi.mock("@/lib/logger", () => ({
  38. logger: mockLogger,
  39. }));
  40. type LoginPostHandler = (request: NextRequest) => Promise<Response>;
  41. type LogoutPostHandler = (request: NextRequest) => Promise<Response>;
  42. function makeLoginRequest(body: unknown): NextRequest {
  43. return new NextRequest("http://localhost/api/auth/login", {
  44. method: "POST",
  45. headers: { "Content-Type": "application/json" },
  46. body: JSON.stringify(body),
  47. });
  48. }
  49. function makeLogoutRequest(): NextRequest {
  50. return new NextRequest("http://localhost/api/auth/logout", {
  51. method: "POST",
  52. });
  53. }
  54. function expectSharedSecurityHeaders(response: Response) {
  55. expect(response.headers.get("X-Frame-Options")).toBe("DENY");
  56. expect(response.headers.get("Referrer-Policy")).toBe("strict-origin-when-cross-origin");
  57. expect(response.headers.get("X-DNS-Prefetch-Control")).toBe("off");
  58. }
  59. const fakeSession = {
  60. user: {
  61. id: 1,
  62. name: "Test User",
  63. description: "desc",
  64. role: "user" as const,
  65. },
  66. key: {
  67. canLoginWebUi: true,
  68. },
  69. };
  70. describe("security headers auth route integration", () => {
  71. let loginPost: LoginPostHandler;
  72. let logoutPost: LogoutPostHandler;
  73. beforeEach(async () => {
  74. vi.resetModules();
  75. vi.clearAllMocks();
  76. const t = vi.fn((messageKey: string) => `translated:${messageKey}`);
  77. mockGetTranslations.mockResolvedValue(t);
  78. mockValidateKey.mockResolvedValue(fakeSession);
  79. mockSetAuthCookie.mockResolvedValue(undefined);
  80. mockGetSessionTokenMode.mockReturnValue("legacy");
  81. mockGetLoginRedirectTarget.mockReturnValue("/dashboard");
  82. mockClearAuthCookie.mockResolvedValue(undefined);
  83. mockGetAuthCookie.mockResolvedValue(undefined);
  84. mockGetEnvConfig.mockReturnValue({ ENABLE_SECURE_COOKIES: false });
  85. const loginRoute = await import("../../src/app/api/auth/login/route");
  86. loginPost = loginRoute.POST;
  87. const logoutRoute = await import("../../src/app/api/auth/logout/route");
  88. logoutPost = logoutRoute.POST;
  89. });
  90. it("login success response includes security headers", async () => {
  91. const res = await loginPost(makeLoginRequest({ key: "valid-key" }));
  92. expect(res.status).toBe(200);
  93. expectSharedSecurityHeaders(res);
  94. expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
  95. });
  96. it("login error response includes security headers", async () => {
  97. const res = await loginPost(makeLoginRequest({}));
  98. expect(res.status).toBe(400);
  99. expectSharedSecurityHeaders(res);
  100. expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
  101. });
  102. it("logout response includes security headers", async () => {
  103. const res = await logoutPost(makeLogoutRequest());
  104. expect(res.status).toBe(200);
  105. expectSharedSecurityHeaders(res);
  106. expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
  107. });
  108. it("CSP is applied in report-only mode by default", async () => {
  109. const res = await loginPost(makeLoginRequest({ key: "valid-key" }));
  110. expect(res.headers.get("Content-Security-Policy-Report-Only")).toContain("default-src 'self'");
  111. expect(res.headers.get("Content-Security-Policy")).toBeNull();
  112. });
  113. it("HSTS is present when ENABLE_SECURE_COOKIES=true", async () => {
  114. mockGetEnvConfig.mockReturnValue({ ENABLE_SECURE_COOKIES: true });
  115. const res = await loginPost(makeLoginRequest({ key: "valid-key" }));
  116. expect(res.headers.get("Strict-Transport-Security")).toBe(
  117. "max-age=31536000; includeSubDomains"
  118. );
  119. });
  120. it("HSTS is absent when ENABLE_SECURE_COOKIES=false", async () => {
  121. mockGetEnvConfig.mockReturnValue({ ENABLE_SECURE_COOKIES: false });
  122. const res = await logoutPost(makeLogoutRequest());
  123. expect(res.headers.get("Strict-Transport-Security")).toBeNull();
  124. });
  125. it("X-Content-Type-Options is always nosniff", async () => {
  126. mockGetEnvConfig.mockReturnValue({ ENABLE_SECURE_COOKIES: true });
  127. const secureRes = await loginPost(makeLoginRequest({ key: "valid-key" }));
  128. mockGetEnvConfig.mockReturnValue({ ENABLE_SECURE_COOKIES: false });
  129. const errorRes = await loginPost(makeLoginRequest({}));
  130. const logoutRes = await logoutPost(makeLogoutRequest());
  131. expect(secureRes.headers.get("X-Content-Type-Options")).toBe("nosniff");
  132. expect(errorRes.headers.get("X-Content-Type-Options")).toBe("nosniff");
  133. expect(logoutRes.headers.get("X-Content-Type-Options")).toBe("nosniff");
  134. });
  135. it("security headers remain compatible with existing CORS headers", async () => {
  136. const res = await loginPost(makeLoginRequest({ key: "valid-key" }));
  137. const corsRes = applyCors(res, {
  138. origin: "https://client.example.com",
  139. requestHeaders: "content-type,x-api-key",
  140. });
  141. // Without allowCredentials, origin is NOT reflected — stays as wildcard
  142. expect(corsRes.headers.get("Access-Control-Allow-Origin")).toBe("*");
  143. expect(corsRes.headers.get("Access-Control-Allow-Credentials")).toBeNull();
  144. expect(corsRes.headers.get("Access-Control-Allow-Headers")).toBe("content-type,x-api-key");
  145. expect(corsRes.headers.get("Content-Security-Policy-Report-Only")).toContain(
  146. "default-src 'self'"
  147. );
  148. expect(corsRes.headers.get("X-Content-Type-Options")).toBe("nosniff");
  149. });
  150. it("CORS reflects origin only when allowCredentials is explicitly set", async () => {
  151. const res = await loginPost(makeLoginRequest({ key: "valid-key" }));
  152. const corsRes = applyCors(res, {
  153. origin: "https://trusted.example.com",
  154. requestHeaders: "content-type",
  155. allowCredentials: true,
  156. });
  157. expect(corsRes.headers.get("Access-Control-Allow-Origin")).toBe("https://trusted.example.com");
  158. expect(corsRes.headers.get("Access-Control-Allow-Credentials")).toBe("true");
  159. });
  160. });