full-security-regression.test.ts 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
  2. import { createCsrfOriginGuard } from "../../src/lib/security/csrf-origin-guard";
  3. import { LoginAbusePolicy } from "../../src/lib/security/login-abuse-policy";
  4. import {
  5. buildSecurityHeaders,
  6. DEFAULT_SECURITY_HEADERS_CONFIG,
  7. } from "../../src/lib/security/security-headers";
  8. const mockCookieSet = vi.hoisted(() => vi.fn());
  9. const mockCookies = vi.hoisted(() => vi.fn());
  10. const mockGetRedisClient = vi.hoisted(() => vi.fn());
  11. vi.mock("next/headers", () => ({
  12. cookies: mockCookies,
  13. headers: vi.fn().mockResolvedValue(new Headers()),
  14. }));
  15. vi.mock("@/lib/config/config", () => ({
  16. config: {
  17. auth: {
  18. adminToken: "test-admin-token",
  19. },
  20. },
  21. }));
  22. vi.mock("@/repository/key", () => ({
  23. findKeyList: vi.fn(),
  24. validateApiKeyAndGetUser: vi.fn(),
  25. }));
  26. vi.mock("@/lib/redis", () => ({
  27. getRedisClient: mockGetRedisClient,
  28. }));
  29. const ORIGINAL_SESSION_TOKEN_MODE = process.env.SESSION_TOKEN_MODE;
  30. const ORIGINAL_ENABLE_SECURE_COOKIES = process.env.ENABLE_SECURE_COOKIES;
  31. function restoreAuthEnv() {
  32. if (ORIGINAL_SESSION_TOKEN_MODE === undefined) {
  33. delete process.env.SESSION_TOKEN_MODE;
  34. } else {
  35. process.env.SESSION_TOKEN_MODE = ORIGINAL_SESSION_TOKEN_MODE;
  36. }
  37. if (ORIGINAL_ENABLE_SECURE_COOKIES === undefined) {
  38. delete process.env.ENABLE_SECURE_COOKIES;
  39. } else {
  40. process.env.ENABLE_SECURE_COOKIES = ORIGINAL_ENABLE_SECURE_COOKIES;
  41. }
  42. }
  43. function setupCookieStoreMock() {
  44. mockCookieSet.mockClear();
  45. mockCookies.mockResolvedValue({
  46. set: mockCookieSet,
  47. get: vi.fn(),
  48. delete: vi.fn(),
  49. });
  50. }
  51. class FakeRedisClient {
  52. status: "ready" = "ready";
  53. private readonly values = new Map<string, string>();
  54. async setex(key: string, _ttl: number, value: string): Promise<"OK"> {
  55. this.values.set(key, value);
  56. return "OK";
  57. }
  58. async get(key: string): Promise<string | null> {
  59. return this.values.get(key) ?? null;
  60. }
  61. async del(key: string): Promise<number> {
  62. return this.values.delete(key) ? 1 : 0;
  63. }
  64. }
  65. describe("Full Security Regression Suite", () => {
  66. beforeEach(() => {
  67. setupCookieStoreMock();
  68. });
  69. afterEach(() => {
  70. restoreAuthEnv();
  71. vi.useRealTimers();
  72. vi.clearAllMocks();
  73. vi.resetModules();
  74. });
  75. describe("Session Contract", () => {
  76. it("SESSION_TOKEN_MODE defaults to opaque", async () => {
  77. delete process.env.SESSION_TOKEN_MODE;
  78. vi.resetModules();
  79. const { getSessionTokenMode } = await import("../../src/lib/auth");
  80. expect(getSessionTokenMode()).toBe("opaque");
  81. });
  82. it("OpaqueSessionContract has required fields", async () => {
  83. vi.resetModules();
  84. const { isOpaqueSessionContract } = await import("../../src/lib/auth");
  85. const contract = {
  86. sessionId: "sid_opaque_session_123",
  87. keyFingerprint: "sha256:abc123",
  88. createdAt: 1_700_000_000,
  89. expiresAt: 1_700_000_300,
  90. userId: 42,
  91. userRole: "admin",
  92. };
  93. expect(isOpaqueSessionContract(contract)).toBe(true);
  94. const missingUserRole = { ...contract } as Partial<typeof contract>;
  95. delete missingUserRole.userRole;
  96. expect(isOpaqueSessionContract(missingUserRole)).toBe(false);
  97. });
  98. });
  99. describe("Session Store", () => {
  100. it("create returns valid session data", async () => {
  101. const redis = new FakeRedisClient();
  102. mockGetRedisClient.mockReturnValue(redis);
  103. const { RedisSessionStore } = await import(
  104. "../../src/lib/auth-session-store/redis-session-store"
  105. );
  106. const store = new RedisSessionStore();
  107. const created = await store.create({
  108. keyFingerprint: "sha256:fp-1",
  109. userId: 101,
  110. userRole: "user",
  111. });
  112. expect(created.sessionId).toMatch(/^sid_[0-9a-f-]{36}$/i);
  113. expect(created.keyFingerprint).toBe("sha256:fp-1");
  114. expect(created.userId).toBe(101);
  115. expect(created.userRole).toBe("user");
  116. expect(created.expiresAt).toBeGreaterThan(created.createdAt);
  117. await expect(store.read(created.sessionId)).resolves.toEqual(created);
  118. });
  119. it("read returns null for non-existent session", async () => {
  120. const redis = new FakeRedisClient();
  121. mockGetRedisClient.mockReturnValue(redis);
  122. const { RedisSessionStore } = await import(
  123. "../../src/lib/auth-session-store/redis-session-store"
  124. );
  125. const store = new RedisSessionStore();
  126. await expect(store.read("missing-session")).resolves.toBeNull();
  127. });
  128. });
  129. describe("Cookie Hardening", () => {
  130. it("auth cookie is HttpOnly", async () => {
  131. process.env.ENABLE_SECURE_COOKIES = "true";
  132. vi.resetModules();
  133. const { AUTH_COOKIE_NAME, setAuthCookie } = await import("../../src/lib/auth");
  134. await setAuthCookie("test-key");
  135. expect(mockCookieSet).toHaveBeenCalledTimes(1);
  136. const [name, value, options] = mockCookieSet.mock.calls[0];
  137. expect(name).toBe(AUTH_COOKIE_NAME);
  138. expect(value).toBe("test-key");
  139. expect(options.httpOnly).toBe(true);
  140. });
  141. it("auth cookie secure flag matches env", async () => {
  142. const cases = [
  143. { envValue: "true", expected: true },
  144. { envValue: "false", expected: false },
  145. ] as const;
  146. for (const testCase of cases) {
  147. mockCookieSet.mockClear();
  148. process.env.ENABLE_SECURE_COOKIES = testCase.envValue;
  149. vi.resetModules();
  150. const { setAuthCookie } = await import("../../src/lib/auth");
  151. await setAuthCookie("env-test");
  152. const [, , options] = mockCookieSet.mock.calls[0];
  153. expect(options.secure).toBe(testCase.expected);
  154. }
  155. });
  156. });
  157. describe("Anti-Bruteforce", () => {
  158. it("blocks after threshold", () => {
  159. vi.useFakeTimers();
  160. vi.setSystemTime(new Date("2026-02-18T10:00:00.000Z"));
  161. const policy = new LoginAbusePolicy({ maxAttemptsPerIp: 2, lockoutSeconds: 60 });
  162. const ip = "198.51.100.10";
  163. policy.recordFailure(ip);
  164. policy.recordFailure(ip);
  165. const decision = policy.check(ip);
  166. expect(decision.allowed).toBe(false);
  167. expect(decision.reason).toBe("ip_rate_limited");
  168. expect(decision.retryAfterSeconds).toBeGreaterThan(0);
  169. });
  170. it("resets on success", () => {
  171. vi.useFakeTimers();
  172. vi.setSystemTime(new Date("2026-02-18T10:00:00.000Z"));
  173. const policy = new LoginAbusePolicy({ maxAttemptsPerIp: 2, lockoutSeconds: 60 });
  174. const ip = "198.51.100.11";
  175. policy.recordFailure(ip);
  176. policy.recordFailure(ip);
  177. expect(policy.check(ip).allowed).toBe(false);
  178. policy.recordSuccess(ip);
  179. expect(policy.check(ip)).toEqual({ allowed: true });
  180. });
  181. });
  182. describe("CSRF Guard", () => {
  183. it("allows same-origin", () => {
  184. const guard = createCsrfOriginGuard({
  185. allowedOrigins: ["https://safe.example.com"],
  186. allowSameOrigin: true,
  187. enforceInDevelopment: true,
  188. });
  189. const result = guard.check({
  190. headers: new Headers({
  191. "sec-fetch-site": "same-origin",
  192. }),
  193. });
  194. expect(result).toEqual({ allowed: true });
  195. });
  196. it("blocks cross-origin", () => {
  197. const guard = createCsrfOriginGuard({
  198. allowedOrigins: ["https://safe.example.com"],
  199. allowSameOrigin: true,
  200. enforceInDevelopment: true,
  201. });
  202. const result = guard.check({
  203. headers: new Headers({
  204. "sec-fetch-site": "cross-site",
  205. origin: "https://evil.example.com",
  206. }),
  207. });
  208. expect(result.allowed).toBe(false);
  209. expect(result.reason).toBe("Origin https://evil.example.com not in allowlist");
  210. });
  211. });
  212. describe("Security Headers", () => {
  213. it("includes all required headers", () => {
  214. const headers = buildSecurityHeaders();
  215. expect(headers["X-Content-Type-Options"]).toBe("nosniff");
  216. expect(headers["X-Frame-Options"]).toBe(DEFAULT_SECURITY_HEADERS_CONFIG.frameOptions);
  217. expect(headers["Referrer-Policy"]).toBe("strict-origin-when-cross-origin");
  218. expect(headers["X-DNS-Prefetch-Control"]).toBe("off");
  219. expect(headers["Content-Security-Policy-Report-Only"]).toContain("default-src 'self'");
  220. });
  221. it("CSP report-only by default", () => {
  222. expect(DEFAULT_SECURITY_HEADERS_CONFIG.cspMode).toBe("report-only");
  223. const headers = buildSecurityHeaders();
  224. expect(headers["Content-Security-Policy-Report-Only"]).toContain("default-src 'self'");
  225. expect(headers["Content-Security-Policy"]).toBeUndefined();
  226. });
  227. });
  228. });