client-guard.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. import { describe, expect, test, vi } from "vitest";
  2. import { ProxyClientGuard } from "@/app/v1/_lib/proxy/client-guard";
  3. import type { ProxySession } from "@/app/v1/_lib/proxy/session";
  4. const LEGACY_METADATA_USER_ID =
  5. "user_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_account__session_sess_legacy_123";
  6. const JSON_METADATA_USER_ID = JSON.stringify({
  7. device_id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
  8. account_uuid: "",
  9. session_id: "sess_json_123",
  10. });
  11. // Mock ProxyResponses
  12. vi.mock("@/app/v1/_lib/proxy/responses", () => ({
  13. ProxyResponses: {
  14. buildError: (status: number, message: string, code: string) =>
  15. new Response(JSON.stringify({ error: { message, type: code } }), { status }),
  16. },
  17. }));
  18. // Helper to create mock session
  19. function createMockSession(
  20. userAgent: string | undefined,
  21. allowedClients: string[] = [],
  22. blockedClients: string[] = []
  23. ): ProxySession {
  24. return {
  25. userAgent,
  26. headers: new Headers(),
  27. request: { message: {} },
  28. authState: {
  29. user: {
  30. allowedClients,
  31. blockedClients,
  32. },
  33. },
  34. } as unknown as ProxySession;
  35. }
  36. // Helper for fully-confirmed Claude Code CLI sessions (all 4 signals)
  37. function createClaudeCodeSession(
  38. userAgent: string,
  39. allowedClients: string[] = [],
  40. blockedClients: string[] = [],
  41. betaHeader = "claude-code-test",
  42. metadataUserId = LEGACY_METADATA_USER_ID
  43. ): ProxySession {
  44. const headers = new Headers();
  45. headers.set("x-app", "cli");
  46. headers.set("anthropic-beta", betaHeader);
  47. return {
  48. userAgent,
  49. headers,
  50. request: { message: { metadata: { user_id: metadataUserId } } },
  51. authState: {
  52. user: { allowedClients, blockedClients },
  53. },
  54. } as unknown as ProxySession;
  55. }
  56. describe("ProxyClientGuard", () => {
  57. describe("when authState is missing", () => {
  58. test("should allow request when authState is undefined", async () => {
  59. const session = { userAgent: "SomeClient/1.0" } as unknown as ProxySession;
  60. const result = await ProxyClientGuard.ensure(session);
  61. expect(result).toBeNull();
  62. });
  63. test("should allow request when authState.user is undefined", async () => {
  64. const session = {
  65. userAgent: "SomeClient/1.0",
  66. authState: {},
  67. } as unknown as ProxySession;
  68. const result = await ProxyClientGuard.ensure(session);
  69. expect(result).toBeNull();
  70. });
  71. });
  72. describe("when no restrictions configured", () => {
  73. test("should allow request when allowedClients is empty", async () => {
  74. const session = createMockSession("AnyClient/1.0", []);
  75. const result = await ProxyClientGuard.ensure(session);
  76. expect(result).toBeNull();
  77. });
  78. test("should allow request when allowedClients is undefined", async () => {
  79. const session = createMockSession("AnyClient/1.0", undefined as unknown as string[]);
  80. const result = await ProxyClientGuard.ensure(session);
  81. expect(result).toBeNull();
  82. });
  83. });
  84. describe("when both allowedClients and blockedClients are empty", () => {
  85. test("should allow request", async () => {
  86. const session = createMockSession("AnyClient/1.0", [], []);
  87. const result = await ProxyClientGuard.ensure(session);
  88. expect(result).toBeNull();
  89. });
  90. });
  91. describe("when restrictions are configured", () => {
  92. test("should reject when User-Agent is missing", async () => {
  93. const session = createMockSession(undefined, ["claude-cli"]);
  94. const result = await ProxyClientGuard.ensure(session);
  95. expect(result).not.toBeNull();
  96. expect(result?.status).toBe(400);
  97. });
  98. test("should reject when User-Agent is empty", async () => {
  99. const session = createMockSession("", ["claude-cli"]);
  100. const result = await ProxyClientGuard.ensure(session);
  101. expect(result).not.toBeNull();
  102. expect(result?.status).toBe(400);
  103. });
  104. test("should reject when User-Agent is whitespace only", async () => {
  105. const session = createMockSession(" ", ["claude-cli"]);
  106. const result = await ProxyClientGuard.ensure(session);
  107. expect(result).not.toBeNull();
  108. expect(result?.status).toBe(400);
  109. });
  110. });
  111. describe("pattern matching with hyphen/underscore normalization", () => {
  112. test("should match gemini-cli pattern against GeminiCLI User-Agent", async () => {
  113. const session = createMockSession("GeminiCLI/0.22.5/gemini-3-pro-preview (darwin; arm64)", [
  114. "gemini-cli",
  115. ]);
  116. const result = await ProxyClientGuard.ensure(session);
  117. expect(result).toBeNull();
  118. });
  119. test("should match claude-cli pattern against claude_cli User-Agent", async () => {
  120. const session = createMockSession("claude_cli/1.0", ["claude-cli"]);
  121. const result = await ProxyClientGuard.ensure(session);
  122. expect(result).toBeNull();
  123. });
  124. test("should match codex-cli pattern against codexcli User-Agent", async () => {
  125. const session = createMockSession("codexcli/2.0", ["codex-cli"]);
  126. const result = await ProxyClientGuard.ensure(session);
  127. expect(result).toBeNull();
  128. });
  129. test("should match factory-cli pattern against FactoryCLI User-Agent", async () => {
  130. const session = createMockSession("FactoryCLI/1.0", ["factory-cli"]);
  131. const result = await ProxyClientGuard.ensure(session);
  132. expect(result).toBeNull();
  133. });
  134. test("should be case-insensitive", async () => {
  135. const session = createMockSession("GEMINICLI/1.0", ["gemini-cli"]);
  136. const result = await ProxyClientGuard.ensure(session);
  137. expect(result).toBeNull();
  138. });
  139. });
  140. describe("pattern matching without normalization needed", () => {
  141. test("should match exact substring", async () => {
  142. const session = createMockSession("claude-cli/1.0.0", ["claude-cli"]);
  143. const result = await ProxyClientGuard.ensure(session);
  144. expect(result).toBeNull();
  145. });
  146. test("should match when User-Agent contains pattern as substring", async () => {
  147. const session = createMockSession("Mozilla/5.0 claude-cli/1.0 Compatible", ["claude-cli"]);
  148. const result = await ProxyClientGuard.ensure(session);
  149. expect(result).toBeNull();
  150. });
  151. });
  152. describe("multiple patterns", () => {
  153. test("should allow when one of multiple patterns matches", async () => {
  154. const session = createMockSession("GeminiCLI/1.0", ["claude-cli", "gemini-cli", "codex-cli"]);
  155. const result = await ProxyClientGuard.ensure(session);
  156. expect(result).toBeNull();
  157. });
  158. test("should reject when no patterns match", async () => {
  159. const session = createMockSession("UnknownClient/1.0", ["claude-cli", "gemini-cli"]);
  160. const result = await ProxyClientGuard.ensure(session);
  161. expect(result).not.toBeNull();
  162. expect(result?.status).toBe(400);
  163. });
  164. });
  165. describe("edge cases", () => {
  166. test("should handle pattern with multiple hyphens", async () => {
  167. const session = createMockSession("my-special-cli/1.0", ["my-special-cli"]);
  168. const result = await ProxyClientGuard.ensure(session);
  169. expect(result).toBeNull();
  170. });
  171. test("should handle pattern with underscores", async () => {
  172. const session = createMockSession("my_special_cli/1.0", ["my-special-cli"]);
  173. const result = await ProxyClientGuard.ensure(session);
  174. expect(result).toBeNull();
  175. });
  176. test("should handle mixed hyphen and underscore", async () => {
  177. const session = createMockSession("my_special-cli/1.0", ["my-special_cli"]);
  178. const result = await ProxyClientGuard.ensure(session);
  179. expect(result).toBeNull();
  180. });
  181. test("should reject when pattern normalizes to empty string", async () => {
  182. const session = createMockSession("AnyClient/1.0", ["-"]);
  183. const result = await ProxyClientGuard.ensure(session);
  184. expect(result).not.toBeNull();
  185. expect(result?.status).toBe(400);
  186. });
  187. test("should reject when pattern is only underscores", async () => {
  188. const session = createMockSession("AnyClient/1.0", ["___"]);
  189. const result = await ProxyClientGuard.ensure(session);
  190. expect(result).not.toBeNull();
  191. expect(result?.status).toBe(400);
  192. });
  193. test("should reject when pattern is only hyphens and underscores", async () => {
  194. const session = createMockSession("AnyClient/1.0", ["-_-_-"]);
  195. const result = await ProxyClientGuard.ensure(session);
  196. expect(result).not.toBeNull();
  197. expect(result?.status).toBe(400);
  198. });
  199. test("should reject when all patterns normalize to empty", async () => {
  200. const session = createMockSession("AnyClient/1.0", ["-", "_", "--"]);
  201. const result = await ProxyClientGuard.ensure(session);
  202. expect(result).not.toBeNull();
  203. expect(result?.status).toBe(400);
  204. });
  205. test("should allow when at least one pattern is valid after normalization", async () => {
  206. const session = createMockSession("ValidClient/1.0", ["-", "valid", "_"]);
  207. const result = await ProxyClientGuard.ensure(session);
  208. expect(result).toBeNull();
  209. });
  210. });
  211. describe("when blockedClients is configured", () => {
  212. test("should reject when client matches blocked pattern", async () => {
  213. const session = createMockSession("GeminiCLI/1.0", [], ["gemini-cli"]);
  214. const result = await ProxyClientGuard.ensure(session);
  215. expect(result).not.toBeNull();
  216. expect(result?.status).toBe(400);
  217. });
  218. test("should allow when client does not match blocked pattern", async () => {
  219. const session = createMockSession("CodexCLI/1.0", [], ["gemini-cli"]);
  220. const result = await ProxyClientGuard.ensure(session);
  221. expect(result).toBeNull();
  222. });
  223. test("should reject even when allowedClients matches", async () => {
  224. const session = createMockSession("gemini-cli/1.0", ["gemini-cli"], ["gemini-cli"]);
  225. const result = await ProxyClientGuard.ensure(session);
  226. expect(result).not.toBeNull();
  227. expect(result?.status).toBe(400);
  228. });
  229. });
  230. describe("when only blockedClients is configured (no allowedClients)", () => {
  231. test("should reject matching client", async () => {
  232. const session = createMockSession("codex-cli/2.0", [], ["codex-cli"]);
  233. const result = await ProxyClientGuard.ensure(session);
  234. expect(result).not.toBeNull();
  235. expect(result?.status).toBe(400);
  236. });
  237. test("should allow non-matching client", async () => {
  238. const session = createMockSession("claude-cli/1.0", [], ["codex-cli"]);
  239. const result = await ProxyClientGuard.ensure(session);
  240. expect(result).toBeNull();
  241. });
  242. });
  243. describe("claude-code builtin keyword with 4-signal detection", () => {
  244. test("should allow claude-cli/2.0.70 with non-claude-code beta header (the bug fix)", async () => {
  245. // CLI 2.0.70 sends interleaved-thinking-2025-05-14, not a claude-code-* beta
  246. const session = createClaudeCodeSession(
  247. "claude-cli/2.0.70 (external, cli)",
  248. ["claude-code"],
  249. [],
  250. "interleaved-thinking-2025-05-14"
  251. );
  252. const result = await ProxyClientGuard.ensure(session);
  253. expect(result).toBeNull();
  254. });
  255. test("should reject when metadata.user_id is missing (only 3-of-4 signals)", async () => {
  256. const headers = new Headers();
  257. headers.set("x-app", "cli");
  258. headers.set("anthropic-beta", "some-beta");
  259. const session = {
  260. userAgent: "claude-cli/2.0.70 (external, cli)",
  261. headers,
  262. request: { message: {} },
  263. authState: { user: { allowedClients: ["claude-code"], blockedClients: [] } },
  264. } as unknown as import("@/app/v1/_lib/proxy/session").ProxySession;
  265. const result = await ProxyClientGuard.ensure(session);
  266. expect(result).not.toBeNull();
  267. expect(result?.status).toBe(400);
  268. const body = await result!.json();
  269. expect(body.error.message).toContain("Client not allowed");
  270. expect(body.error.message).not.toContain("Signals");
  271. });
  272. test("should reject when anthropic-beta header is missing (only 3-of-4 signals)", async () => {
  273. const headers = new Headers();
  274. headers.set("x-app", "cli");
  275. const session = {
  276. userAgent: "claude-cli/2.0.70 (external, cli)",
  277. headers,
  278. request: { message: { metadata: { user_id: LEGACY_METADATA_USER_ID } } },
  279. authState: { user: { allowedClients: ["claude-code"], blockedClients: [] } },
  280. } as unknown as import("@/app/v1/_lib/proxy/session").ProxySession;
  281. const result = await ProxyClientGuard.ensure(session);
  282. expect(result).not.toBeNull();
  283. expect(result?.status).toBe(400);
  284. const body = await result!.json();
  285. expect(body.error.message).toContain("Client not allowed");
  286. expect(body.error.message).not.toContain("Signals");
  287. });
  288. test("should allow when all 4 signals present with claude-code allowlist", async () => {
  289. const session = createClaudeCodeSession("claude-cli/1.0.0 (external, cli)", ["claude-code"]);
  290. const result = await ProxyClientGuard.ensure(session);
  291. expect(result).toBeNull();
  292. });
  293. test("should allow JSON string metadata.user_id with claude-code allowlist", async () => {
  294. const session = createClaudeCodeSession(
  295. "claude-cli/2.1.78 (external, cli)",
  296. ["claude-code"],
  297. [],
  298. "interleaved-thinking-2025-05-14",
  299. JSON_METADATA_USER_ID
  300. );
  301. const result = await ProxyClientGuard.ensure(session);
  302. expect(result).toBeNull();
  303. });
  304. });
  305. });