import fs from "node:fs"; import { expect, test } from "@playwright/test"; import { getTestImagePath } from "./helpers"; // --------------------------------------------------------------------------- // Security tests: path traversal, XSS in filenames, rate limiting, // auth token handling, and unauthenticated access. // --------------------------------------------------------------------------- const API = process.env.API_URL || "http://localhost:13490"; async function getAuthToken(): Promise { const res = await fetch(`${API}/api/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: "admin", password: "admin" }), }); const data = await res.json(); return data.token; } function authHeaders(token: string): Record { return { Authorization: `Bearer ${token}` }; } function readTestImage(): { blob: Blob; buffer: Buffer } { const imagePath = getTestImagePath(); const buffer = fs.readFileSync(imagePath); return { blob: new Blob([buffer], { type: "image/png" }), buffer }; } test.describe("Security: Path traversal", () => { let _token: string; test.beforeAll(async () => { _token = await getAuthToken(); }); test("download rejects path traversal in jobId (..)", async () => { const res = await fetch(`${API}/api/v1/download/../../../etc/passwd/file.png`); // 400/404 in dev (Fastify only), 200 in production (SPA fallback for normalized path). // Either way, the actual file must never be leaked. const body = await res.text(); expect(body).not.toContain("root:"); }); test("download rejects path traversal in filename (..)", async () => { const res = await fetch(`${API}/api/v1/download/some-job-id/..%2F..%2F..%2Fetc%2Fpasswd`); // Server may return 400 (bad path), 404 (not found), or 401 (route mismatch) expect(res.status).not.toBe(200); const body = await res.text(); expect(body).not.toContain("root:"); }); test("download rejects null bytes in path", async () => { const res = await fetch(`${API}/api/v1/download/test-id/file.png%00.txt`); // Should be blocked - any non-200 is acceptable expect(res.status).not.toBe(200); }); test("download rejects backslash traversal", async () => { const res = await fetch(`${API}/api/v1/download/test-id/..\\..\\etc\\passwd`); // Backslash may cause URL routing to fail in various ways expect(res.status).not.toBe(200); }); test("download with non-existent jobId returns 404", async () => { const res = await fetch(`${API}/api/v1/download/00000000-0000-0000-0000-000000000000/file.png`); // Should be 404 (not found) but could be 400 if UUID validation exists expect([400, 404]).toContain(res.status); }); }); test.describe("Security: XSS in filenames", () => { let token: string; test.beforeAll(async () => { token = await getAuthToken(); }); test("upload with script tag in filename is sanitized", async () => { const { blob } = readTestImage(); const formData = new FormData(); formData.append("file", blob, ".png"); formData.append("settings", JSON.stringify({ width: 50, height: 50, fit: "contain" })); const res = await fetch(`${API}/api/v1/tools/resize`, { method: "POST", headers: authHeaders(token), body: formData, }); // The server should accept the file (with sanitized name) or reject it if (res.status === 200) { const data = await res.json(); // The download URL must be URL-encoded so HTML tags aren't rendered // The filename is preserved by basename() but URL-encoded in the response expect(data.downloadUrl).not.toContain("