Files
SnapOtter/tests/integration/security-rate-limiting.test.ts
T
SnapOtter 4e64ee2779 fix(security): comprehensive security audit and hardening
Auth: login rate limit 30/min (was 500), global rate limit 1000/min (was
unlimited), password/username max lengths on all Zod schemas, session
invalidation on role change, API key legacy scan bounded to 100 keys.

SVG: hardened regex sanitizer with CDATA stripping, XML entity decoding,
set/animate/iframe/embed blocking, comprehensive data: URI blocking,
use element external href blocking. 11 attack payload fixtures added.

SSRF: fixed DNS rebinding TOCTOU by pinning resolved IPs via custom
HTTP/HTTPS agents. Added 6to4 and NAT64 to blocked IPv6 ranges.

Docker: capability dropping (cap_drop ALL + minimal cap_add), resource
limits (4g/8g mem, 512/1024 pids), healthcheck timeout, password
removed from startup banner, default password warning comments.

Network: CSP and HSTS applied in all environments (not just production),
stack traces removed from all error responses, internal paths stripped
from error details, per-route rate limits on uploads (60/min) and URL
fetches (200/hour).

Files: exclusive temp file creation (O_EXCL), disk space circuit
breaker, per-user storage quotas, settings payload 64KB size guard.

Python sidecar: script name allowlist in dispatcher, minimal environment
for subprocess spawns.

Dependencies: fixed 6 production CVEs (drizzle-orm, fastify, fast-uri,
@fastify/static, next, archiver/lodash). Pinned all GitHub Actions to
SHA hashes.

114 security tests added. Full OWASP Top 10 penetration test matrix
verified against production Docker container (30/30 pass after
hardening).
2026-05-13 21:33:50 +08:00

137 lines
4.2 KiB
TypeScript

/**
* Integration tests for security upload controls:
* - Upload route has rate limit config (M13)
* - Per-user storage quota enforcement (L3)
*
* Note: Rate limit enforcement requires @fastify/rate-limit plugin which
* the test server does not register. These tests verify the routes accept
* requests correctly and that quota logic is wired up. Full rate limit
* enforcement is tested via the production server which registers the plugin.
*/
import { eq, sql } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { db, schema } from "../../apps/api/src/db/index.js";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
/**
* Create a minimal valid PNG buffer (1x1 pixel, transparent).
*/
function createMinimalPng(): Buffer {
return Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"base64",
);
}
describe("Upload endpoint accepts valid files (M13 rate limit configured)", () => {
it("accepts authenticated uploads to /api/v1/files/upload", async () => {
const png = createMinimalPng();
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: png },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/files/upload",
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
payload: body,
});
expect(res.statusCode).toBe(201);
const resBody = JSON.parse(res.body);
expect(resBody.files).toBeDefined();
expect(resBody.files.length).toBe(1);
});
it("accepts uploads to /api/v1/upload", async () => {
const png = createMinimalPng();
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test-upload.png", contentType: "image/png", content: png },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/upload",
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
payload: body,
});
expect(res.statusCode).toBe(200);
const resBody = JSON.parse(res.body);
expect(resBody.jobId).toBeDefined();
expect(resBody.files).toBeDefined();
});
});
describe("Per-user storage quota enforcement (L3)", () => {
it("accepts uploads when within storage quota", async () => {
const png = createMinimalPng();
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "quota-ok.png", contentType: "image/png", content: png },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/files/upload",
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
payload: body,
});
expect(res.statusCode).toBe(201);
});
it("tracks file sizes in the database for quota calculation", async () => {
// Verify we can query total storage per user (the quota check mechanism)
const adminUser = db
.select()
.from(schema.users)
.where(eq(schema.users.username, "admin"))
.get();
expect(adminUser).toBeDefined();
const result = db
.select({ total: sql<number>`coalesce(sum(${schema.userFiles.size}), 0)` })
.from(schema.userFiles)
.where(eq(schema.userFiles.userId, adminUser?.id ?? ""))
.get();
// Should have some bytes from the uploads in previous tests
expect(result).toBeDefined();
expect(typeof result?.total).toBe("number");
expect(result?.total).toBeGreaterThanOrEqual(0);
});
it("quota check query returns 0 for users with no files", async () => {
const result = db
.select({ total: sql<number>`coalesce(sum(${schema.userFiles.size}), 0)` })
.from(schema.userFiles)
.where(eq(schema.userFiles.userId, "nonexistent-user-id"))
.get();
expect(result).toBeDefined();
expect(result?.total).toBe(0);
});
});