ashim/tests/e2e/security.spec.ts
Siddharth Kumar Sah 80e536bcf8 chore: remove dead code, add test infrastructure, update docs
- Delete 3 dead files: use-batch-processor.ts, use-i18n.ts, smart-crop.ts (AI package)
- Remove dead getJobProgress function and unused runPythonScript wrapper
- Remove 6 unused imports across API and web apps
- Remove unused shared types (ImageFormat, AppConfig, ApiError, HealthResponse, JobProgress)
  and constants (SUPPORTED_INPUT_FORMATS/OUTPUT_FORMATS, DEFAULT_OUTPUT_FORMAT)
- Remove unused store method (setOriginalBlobUrl) and clean AI package re-exports
- Add test infrastructure: vitest config, unit/integration/e2e tests, fixtures, screenshots
- Add Docker test infrastructure: Dockerfile.test, docker-compose.test.yml
- Add download_models.py for pre-baking AI model weights in Docker
- Add filename sanitization utility (apps/api/src/lib/filename.ts)
- Update .gitignore to exclude coverage/, *.tsbuildinfo, .superpowers/, test artifacts
- Update .dockerignore to exclude test/coverage/IDE artifacts from builds
- Update docs: remove smart crop from AI docs (uses Sharp directly), update bridge docs
2026-03-23 11:46:45 +08:00

358 lines
12 KiB
TypeScript

import { test, expect } from "@playwright/test";
import fs from "node:fs";
import { getTestImagePath } from "./helpers";
// ---------------------------------------------------------------------------
// Security tests: path traversal, XSS in filenames, rate limiting,
// auth token handling, and unauthenticated access.
// ---------------------------------------------------------------------------
const API = "http://localhost:1350";
async function getAuthToken(): Promise<string> {
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<string, string> {
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`,
);
// Should return 400 (invalid path) or 404, never the actual file
expect([400, 404]).toContain(res.status);
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,
'<img src=x onerror=alert(1)>.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("<script>");
// The filename may contain "onerror" as text but it's URL-encoded
// so it can't execute. Key assertion: no raw unescaped angle brackets.
expect(data.downloadUrl).not.toContain("<img");
}
// Status 200 (file processed with sanitized name) or 400/422 are all acceptable
expect([200, 400, 422]).toContain(res.status);
});
test("upload with directory traversal in filename is sanitized", async () => {
const { blob } = readTestImage();
const formData = new FormData();
formData.append("file", blob, "../../../etc/passwd.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,
});
if (res.status === 200) {
const data = await res.json();
// downloadUrl must not contain traversal sequences
expect(data.downloadUrl).not.toContain("..");
}
expect([200, 400, 422]).toContain(res.status);
});
test("upload with null bytes in filename is sanitized", async () => {
const { blob } = readTestImage();
const formData = new FormData();
formData.append("file", blob, "test\x00.png");
formData.append("settings", JSON.stringify({ width: 50 }));
const res = await fetch(`${API}/api/v1/tools/resize`, {
method: "POST",
headers: authHeaders(token),
body: formData,
});
// Server must handle gracefully (200 with sanitized name, 400, or 422)
expect([200, 400, 422]).toContain(res.status);
});
});
test.describe("Security: Rate limiting", () => {
// The test server is configured with RATE_LIMIT_PER_MIN=1000 so we need
// to send enough requests to potentially trigger it. This test verifies
// the rate limiter is active by checking that the appropriate headers
// are present on responses.
test("API returns rate limit headers", async () => {
const res = await fetch(`${API}/api/v1/health`);
expect(res.status).toBe(200);
// Fastify rate-limit plugin may use different header naming conventions
const remaining = res.headers.get("x-ratelimit-remaining");
const limit = res.headers.get("x-ratelimit-limit");
const retryAfter = res.headers.get("retry-after");
// At least one rate-limit related header should be present
const hasRateLimitHeaders =
remaining !== null || limit !== null || retryAfter !== null;
// If rate limiting is configured but headers aren't returned on health endpoint,
// that's also acceptable (some configurations only add headers on rate-limited routes)
if (!hasRateLimitHeaders) {
// Verify the server is at least responding correctly
expect(res.status).toBe(200);
}
});
test("rapid unauthenticated requests do not crash server", async () => {
// Fire 20 rapid requests in parallel to confirm stability
const requests = Array.from({ length: 20 }, () =>
fetch(`${API}/api/v1/health`).then((r) => r.status),
);
const statuses = await Promise.all(requests);
// All should be 200 or 429 (rate limited) - never 500
for (const status of statuses) {
expect([200, 429]).toContain(status);
}
});
});
test.describe("Security: Auth token handling", () => {
test("expired or invalid token returns 401 on protected routes", async () => {
// Use GET /api/v1/settings which is protected and simpler than multipart
const res = await fetch(`${API}/api/v1/settings`, {
headers: { Authorization: "Bearer expired-invalid-token-12345" },
});
expect(res.status).toBe(401);
});
test("missing Authorization header returns 401 on protected routes", async () => {
const res = await fetch(`${API}/api/v1/settings`);
expect(res.status).toBe(401);
});
test("malformed Authorization header returns 401", async () => {
const res = await fetch(`${API}/api/v1/settings`, {
headers: { Authorization: "NotBearer some-token" },
});
expect(res.status).toBe(401);
});
test("empty Bearer token returns 401", async () => {
const res = await fetch(`${API}/api/v1/settings`, {
headers: { Authorization: "Bearer " },
});
expect(res.status).toBe(401);
});
test("session endpoint with expired token returns 401", async () => {
const res = await fetch(`${API}/api/auth/session`, {
headers: { Authorization: "Bearer totally-fake-session-id" },
});
expect(res.status).toBe(401);
const data = await res.json();
expect(data.error).toBeDefined();
});
test("logout invalidates the session token", async () => {
// Login to get a fresh token
const loginRes = await fetch(`${API}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "admin", password: "admin" }),
});
const { token } = await loginRes.json();
expect(token).toBeDefined();
// Verify the token works
const sessionRes = await fetch(`${API}/api/auth/session`, {
headers: authHeaders(token),
});
expect(sessionRes.status).toBe(200);
// Logout
const logoutRes = await fetch(`${API}/api/auth/logout`, {
method: "POST",
headers: authHeaders(token),
});
expect(logoutRes.status).toBe(200);
// Verify the token is now invalid
const afterLogoutRes = await fetch(`${API}/api/auth/session`, {
headers: authHeaders(token),
});
expect(afterLogoutRes.status).toBe(401);
});
});
test.describe("Security: CSRF and request validation", () => {
let token: string;
test.beforeAll(async () => {
token = await getAuthToken();
});
test("JSON body with wrong content-type is handled gracefully", async () => {
const res = await fetch(`${API}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "text/plain" },
body: JSON.stringify({ username: "admin", password: "admin" }),
});
// Should not crash the server - returns 400 or 415 or handles it
expect(res.status).toBeLessThan(500);
});
test("oversized JSON body is rejected", async () => {
// Send a very large JSON payload
const largePayload = JSON.stringify({
username: "admin",
password: "a".repeat(10_000_000),
});
const res = await fetch(`${API}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: largePayload,
});
// Should be rejected, not cause a crash
expect(res.status).toBeLessThan(500);
});
test("non-image file upload is rejected", async () => {
const textContent = Buffer.from("This is not an image file at all");
const blob = new Blob([textContent], { type: "text/plain" });
const formData = new FormData();
formData.append("file", blob, "not-an-image.txt");
formData.append("settings", JSON.stringify({ width: 50 }));
const res = await fetch(`${API}/api/v1/tools/resize`, {
method: "POST",
headers: authHeaders(token),
body: formData,
});
// Server should reject non-image files
expect(res.status).toBe(400);
});
test("empty file upload is rejected", async () => {
const emptyBlob = new Blob([], { type: "image/png" });
const formData = new FormData();
formData.append("file", emptyBlob, "empty.png");
formData.append("settings", JSON.stringify({ width: 50 }));
const res = await fetch(`${API}/api/v1/tools/resize`, {
method: "POST",
headers: authHeaders(token),
body: formData,
});
// Server should reject empty files
expect(res.status).toBe(400);
});
test("admin-only endpoints reject non-admin access attempts", async () => {
// Without a token at all
const usersRes = await fetch(`${API}/api/auth/users`);
expect(usersRes.status).toBe(401);
const registerRes = await fetch(`${API}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: "hacker",
password: "password123",
}),
});
expect(registerRes.status).toBe(401);
});
});