security.test.ts 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. /**
  2. * Tests for security hardening: input validation schemas and path traversal prevention.
  3. */
  4. import { describe, it, expect } from "vitest";
  5. import { resolve, isAbsolute } from "path";
  6. import { homedir } from "os";
  7. import { existsSync } from "fs";
  8. import { z } from "zod";
  9. // Re-define the schemas here to test them in isolation (they're module-scoped in index.ts)
  10. const MESSAGE_ID_SCHEMA = z.string().regex(/^\d+$/, "Message ID must be numeric");
  11. const BATCH_IDS_SCHEMA = z
  12. .array(MESSAGE_ID_SCHEMA)
  13. .min(1, "At least one message ID is required")
  14. .max(100, "Cannot process more than 100 messages in a single batch");
  15. const DATE_FILTER_SCHEMA = z
  16. .string()
  17. .regex(
  18. /^[a-zA-Z0-9 ,/\-:]+$/,
  19. "Date must contain only alphanumeric characters, spaces, commas, slashes, hyphens, and colons"
  20. )
  21. .refine((val) => !isNaN(new Date(val).getTime()), {
  22. message: "Date string must be a valid date (e.g., 'January 1, 2026' or '2026-03-15')",
  23. })
  24. .optional();
  25. describe("MESSAGE_ID_SCHEMA", () => {
  26. it("accepts valid numeric IDs", () => {
  27. expect(MESSAGE_ID_SCHEMA.parse("12345")).toBe("12345");
  28. expect(MESSAGE_ID_SCHEMA.parse("0")).toBe("0");
  29. expect(MESSAGE_ID_SCHEMA.parse("999999999")).toBe("999999999");
  30. });
  31. it("rejects non-numeric IDs", () => {
  32. expect(() => MESSAGE_ID_SCHEMA.parse("abc")).toThrow();
  33. expect(() => MESSAGE_ID_SCHEMA.parse("123abc")).toThrow();
  34. expect(() => MESSAGE_ID_SCHEMA.parse("")).toThrow();
  35. });
  36. it("rejects AppleScript injection attempts", () => {
  37. expect(() => MESSAGE_ID_SCHEMA.parse('1" & do shell script "rm -rf /')).toThrow();
  38. expect(() => MESSAGE_ID_SCHEMA.parse("1; drop table")).toThrow();
  39. expect(() => MESSAGE_ID_SCHEMA.parse("-1")).toThrow();
  40. });
  41. it("rejects template-style IDs (tmpl_N format)", () => {
  42. expect(() => MESSAGE_ID_SCHEMA.parse("tmpl_1")).toThrow();
  43. });
  44. });
  45. describe("BATCH_IDS_SCHEMA", () => {
  46. it("accepts valid batch of numeric IDs", () => {
  47. const result = BATCH_IDS_SCHEMA.parse(["1", "2", "3"]);
  48. expect(result).toEqual(["1", "2", "3"]);
  49. });
  50. it("rejects empty array", () => {
  51. expect(() => BATCH_IDS_SCHEMA.parse([])).toThrow("At least one message ID");
  52. });
  53. it("rejects more than 100 IDs", () => {
  54. const ids = Array.from({ length: 101 }, (_, i) => String(i));
  55. expect(() => BATCH_IDS_SCHEMA.parse(ids)).toThrow("Cannot process more than 100");
  56. });
  57. it("accepts exactly 100 IDs", () => {
  58. const ids = Array.from({ length: 100 }, (_, i) => String(i));
  59. expect(BATCH_IDS_SCHEMA.parse(ids)).toHaveLength(100);
  60. });
  61. it("rejects batch containing non-numeric IDs", () => {
  62. expect(() => BATCH_IDS_SCHEMA.parse(["123", "abc", "456"])).toThrow();
  63. });
  64. });
  65. describe("DATE_FILTER_SCHEMA", () => {
  66. it("accepts valid date strings", () => {
  67. expect(DATE_FILTER_SCHEMA.parse("January 1, 2026")).toBe("January 1, 2026");
  68. expect(DATE_FILTER_SCHEMA.parse("March 15, 2026")).toBe("March 15, 2026");
  69. expect(DATE_FILTER_SCHEMA.parse("2026-03-15")).toBe("2026-03-15");
  70. expect(DATE_FILTER_SCHEMA.parse("3/15/2026")).toBe("3/15/2026");
  71. });
  72. it("accepts undefined (optional)", () => {
  73. expect(DATE_FILTER_SCHEMA.parse(undefined)).toBeUndefined();
  74. });
  75. it("rejects strings with quotes (AppleScript injection)", () => {
  76. expect(() => DATE_FILTER_SCHEMA.parse('"January 1" & do shell script "evil"')).toThrow();
  77. });
  78. it("rejects strings with backslashes", () => {
  79. expect(() => DATE_FILTER_SCHEMA.parse("January\\1")).toThrow();
  80. });
  81. it("rejects strings with parentheses", () => {
  82. expect(() => DATE_FILTER_SCHEMA.parse("date(2026)")).toThrow();
  83. });
  84. it("rejects non-parseable date strings", () => {
  85. expect(() => DATE_FILTER_SCHEMA.parse("31")).toThrow();
  86. expect(() => DATE_FILTER_SCHEMA.parse("abc")).toThrow();
  87. expect(() => DATE_FILTER_SCHEMA.parse("1234567890")).toThrow();
  88. });
  89. });
  90. describe("saveAttachment input validation", () => {
  91. // Test the validation logic that lives in appleMailManager.saveAttachment
  92. // by checking the same regex/logic used there
  93. const isInvalidAttachmentName = (name: string): boolean => {
  94. return /[/\\\0]/.test(name) || name.includes("..");
  95. };
  96. it("blocks forward slash in attachment name", () => {
  97. expect(isInvalidAttachmentName("../../etc/passwd")).toBe(true);
  98. expect(isInvalidAttachmentName("path/to/file.txt")).toBe(true);
  99. });
  100. it("blocks backslash in attachment name", () => {
  101. expect(isInvalidAttachmentName("file\\name.txt")).toBe(true);
  102. });
  103. it("blocks null bytes in attachment name", () => {
  104. expect(isInvalidAttachmentName("file\0name.txt")).toBe(true);
  105. });
  106. it("blocks directory traversal in attachment name", () => {
  107. expect(isInvalidAttachmentName("..")).toBe(true);
  108. expect(isInvalidAttachmentName("../secret")).toBe(true);
  109. });
  110. it("allows normal attachment names", () => {
  111. expect(isInvalidAttachmentName("report.pdf")).toBe(false);
  112. expect(isInvalidAttachmentName("Q1 Budget (Final).xlsx")).toBe(false);
  113. expect(isInvalidAttachmentName("résumé.docx")).toBe(false);
  114. });
  115. const isAllowedPath = (savePath: string): boolean => {
  116. const resolvedPath = resolve(savePath);
  117. const allowedPrefixes = [homedir(), "/tmp", "/private/tmp", "/Volumes"];
  118. return allowedPrefixes.some((prefix: string) => resolvedPath.startsWith(prefix));
  119. };
  120. it("allows paths under home directory", () => {
  121. expect(isAllowedPath(`${homedir()}/Downloads`)).toBe(true);
  122. });
  123. it("allows /tmp", () => {
  124. expect(isAllowedPath("/tmp")).toBe(true);
  125. expect(isAllowedPath("/tmp/attachments")).toBe(true);
  126. });
  127. it("blocks traversal out of allowed directories", () => {
  128. expect(isAllowedPath("/tmp/../../etc")).toBe(false);
  129. });
  130. it("blocks /etc directly", () => {
  131. expect(isAllowedPath("/etc")).toBe(false);
  132. });
  133. it("blocks /usr paths", () => {
  134. expect(isAllowedPath("/usr/local")).toBe(false);
  135. });
  136. });
  137. describe("buildAttachmentCommands validation", () => {
  138. // Mirror the logic from appleMailManager.ts buildAttachmentCommands()
  139. // to test the validation in isolation without needing real files.
  140. function escapeForAppleScript(text: string): string {
  141. if (!text) return "";
  142. return text.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
  143. }
  144. function buildAttachmentCommands(attachments?: string[]): string {
  145. if (!attachments || attachments.length === 0) return "";
  146. for (const filePath of attachments) {
  147. if (!isAbsolute(filePath)) {
  148. throw new Error(`Attachment path must be absolute: "${filePath}"`);
  149. }
  150. if (!existsSync(filePath)) {
  151. throw new Error(`Attachment file not found: "${filePath}"`);
  152. }
  153. }
  154. let commands = "";
  155. for (const filePath of attachments) {
  156. const safePath = escapeForAppleScript(filePath);
  157. commands += `make new attachment with properties {file name:POSIX file "${safePath}"} at after the last paragraph\n`;
  158. }
  159. return commands;
  160. }
  161. it("returns empty string for undefined", () => {
  162. expect(buildAttachmentCommands(undefined)).toBe("");
  163. });
  164. it("returns empty string for empty array", () => {
  165. expect(buildAttachmentCommands([])).toBe("");
  166. });
  167. it("rejects relative paths", () => {
  168. expect(() => buildAttachmentCommands(["relative/path.pdf"])).toThrow("must be absolute");
  169. });
  170. it("rejects paths starting with ./", () => {
  171. expect(() => buildAttachmentCommands(["./file.pdf"])).toThrow("must be absolute");
  172. });
  173. it("rejects nonexistent files", () => {
  174. expect(() => buildAttachmentCommands(["/nonexistent/file.pdf"])).toThrow("not found");
  175. });
  176. it("generates correct AppleScript for valid files", () => {
  177. // Use a file we know exists
  178. const testFile = "/usr/bin/env";
  179. const result = buildAttachmentCommands([testFile]);
  180. expect(result).toContain("make new attachment");
  181. expect(result).toContain("POSIX file");
  182. expect(result).toContain(testFile);
  183. });
  184. it("escapes double quotes in file paths", () => {
  185. // Test the escaping function directly since we can't easily mock existsSync
  186. const escaped = escapeForAppleScript('/Users/test/file "name".pdf');
  187. expect(escaped).toContain('file \\"name\\"');
  188. });
  189. it("handles multiple attachments", () => {
  190. const testFile = "/usr/bin/env";
  191. const result = buildAttachmentCommands([testFile, testFile]);
  192. const matches = result.match(/make new attachment/g);
  193. expect(matches).toHaveLength(2);
  194. });
  195. // Schema-level test: attachment array cap
  196. const ATTACHMENTS_SCHEMA = z
  197. .array(z.string())
  198. .max(20, "Cannot attach more than 20 files")
  199. .optional();
  200. it("rejects more than 20 attachments at schema level", () => {
  201. const paths = Array.from({ length: 21 }, (_, i) => `/tmp/file${i}.pdf`);
  202. expect(() => ATTACHMENTS_SCHEMA.parse(paths)).toThrow("Cannot attach more than 20");
  203. });
  204. it("accepts exactly 20 attachments at schema level", () => {
  205. const paths = Array.from({ length: 20 }, (_, i) => `/tmp/file${i}.pdf`);
  206. expect(ATTACHMENTS_SCHEMA.parse(paths)).toHaveLength(20);
  207. });
  208. });