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).
This commit is contained in:
SnapOtter
2026-05-13 21:33:50 +08:00
parent bc0cac42e3
commit 4e64ee2779
58 changed files with 2572 additions and 3677 deletions
+30 -26
View File
@@ -138,38 +138,42 @@ function getUniqueName(name: string, used: Set<string>): string {
}
export async function registerFetchUrlsRoute(app: FastifyInstance): Promise<void> {
app.post("/api/v1/fetch-urls", async (request, reply) => {
// Validate body
const parsed = fetchUrlsSchema.safeParse(request.body);
if (!parsed.success) {
const messages = parsed.error.issues.map((i) => i.message).join("; ");
return reply.status(400).send({ error: messages });
}
app.post(
"/api/v1/fetch-urls",
{ config: { rateLimit: { max: 200, timeWindow: "1 hour" } } },
async (request, reply) => {
// Validate body
const parsed = fetchUrlsSchema.safeParse(request.body);
if (!parsed.success) {
const messages = parsed.error.issues.map((i) => i.message).join("; ");
return reply.status(400).send({ error: messages });
}
const { urls } = parsed.data;
const jobId = randomUUID();
const workspace = await createWorkspace(jobId);
const outputDir = join(workspace, "output");
const { urls } = parsed.data;
const jobId = randomUUID();
const workspace = await createWorkspace(jobId);
const outputDir = join(workspace, "output");
const queue = new PQueue({ concurrency: URL_FETCH_CONCURRENCY });
const queue = new PQueue({ concurrency: URL_FETCH_CONCURRENCY });
// Track filenames to prevent collisions when multiple URLs resolve to the
// same name (e.g. https://a.com/photo.jpg and https://b.com/photo.jpg).
const usedFilenames = new Set<string>();
// Track filenames to prevent collisions when multiple URLs resolve to the
// same name (e.g. https://a.com/photo.jpg and https://b.com/photo.jpg).
const usedFilenames = new Set<string>();
// Pre-allocate result slots to preserve order
const resultSlots: FetchResult[] = new Array(urls.length);
// Pre-allocate result slots to preserve order
const resultSlots: FetchResult[] = new Array(urls.length);
await Promise.all(
urls.map((url, index) =>
queue.add(async () => {
resultSlots[index] = await fetchSingleUrl(url, jobId, outputDir, usedFilenames);
}),
),
);
await Promise.all(
urls.map((url, index) =>
queue.add(async () => {
resultSlots[index] = await fetchSingleUrl(url, jobId, outputDir, usedFilenames);
}),
),
);
return reply.send({ results: resultSlots });
});
return reply.send({ results: resultSlots });
},
);
}
async function fetchSingleUrl(
+54 -50
View File
@@ -25,67 +25,71 @@ function isPathTraversal(segment: string): boolean {
export async function fileRoutes(app: FastifyInstance): Promise<void> {
// ── POST /api/v1/upload ────────────────────────────────────────
app.post("/api/v1/upload", async (request: FastifyRequest, reply: FastifyReply) => {
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const inputDir = join(workspacePath, "input");
app.post(
"/api/v1/upload",
{ config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (request: FastifyRequest, reply: FastifyReply) => {
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const inputDir = join(workspacePath, "input");
const uploadedFiles: Array<{
name: string;
size: number;
format: string;
}> = [];
const uploadedFiles: Array<{
name: string;
size: number;
format: string;
}> = [];
const parts = request.parts();
const parts = request.parts();
for await (const part of parts) {
// Skip non-file fields
if (part.type !== "file") continue;
for await (const part of parts) {
// Skip non-file fields
if (part.type !== "file") continue;
// Consume buffer from the stream
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
const buffer = Buffer.concat(chunks);
// Consume buffer from the stream
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
const buffer = Buffer.concat(chunks);
// Skip empty parts (e.g. empty file field)
if (buffer.length === 0) continue;
// Skip empty parts (e.g. empty file field)
if (buffer.length === 0) continue;
// Validate the image (pass filename for extension-based format detection)
const validation = await validateImageBuffer(buffer, part.filename);
if (!validation.valid) {
return reply.status(400).send({
error: `Invalid file "${part.filename}": ${validation.reason}`,
// Validate the image (pass filename for extension-based format detection)
const validation = await validateImageBuffer(buffer, part.filename);
if (!validation.valid) {
return reply.status(400).send({
error: `Invalid file "${part.filename}": ${validation.reason}`,
});
}
// Sanitize SVG uploads to prevent XXE, SSRF, and script injection
const safeBuffer = isSvgBuffer(buffer) ? sanitizeSvg(buffer) : buffer;
// Sanitize filename
const safeName = sanitizeFilename(part.filename ?? "upload");
// Write to workspace input directory
const filePath = join(inputDir, safeName);
await writeFile(filePath, safeBuffer);
uploadedFiles.push({
name: safeName,
size: safeBuffer.length,
format: validation.format,
});
}
// Sanitize SVG uploads to prevent XXE, SSRF, and script injection
const safeBuffer = isSvgBuffer(buffer) ? sanitizeSvg(buffer) : buffer;
if (uploadedFiles.length === 0) {
return reply.status(400).send({ error: "No valid files uploaded" });
}
// Sanitize filename
const safeName = sanitizeFilename(part.filename ?? "upload");
// Write to workspace input directory
const filePath = join(inputDir, safeName);
await writeFile(filePath, safeBuffer);
uploadedFiles.push({
name: safeName,
size: safeBuffer.length,
format: validation.format,
return reply.send({
jobId,
files: uploadedFiles,
});
}
if (uploadedFiles.length === 0) {
return reply.status(400).send({ error: "No valid files uploaded" });
}
return reply.send({
jobId,
files: uploadedFiles,
});
});
},
);
// ── GET /api/v1/download/:jobId/:filename ──────────────────────
app.get(
+11 -6
View File
@@ -9,7 +9,7 @@ import type { z } from "zod";
import { db, schema } from "../db/index.js";
import { trackEvent } from "../lib/analytics.js";
import { autoOrient } from "../lib/auto-orient.js";
import { formatZodErrors } from "../lib/errors.js";
import { formatZodErrors, stripInternalPaths } from "../lib/errors.js";
import { isToolInstalled } from "../lib/feature-status.js";
import { validateImageBuffer } from "../lib/file-validation.js";
import { sanitizeFilename } from "../lib/filename.js";
@@ -153,7 +153,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
});
}
@@ -203,7 +203,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
} catch (err) {
return reply.status(422).send({
error: "Failed to decode HEIC file. Ensure libheif-examples is installed.",
details: err instanceof Error ? err.message : String(err),
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
});
}
}
@@ -223,7 +223,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
} catch (err) {
return reply.status(422).send({
error: `Failed to decode ${validation.format.toUpperCase()} file`,
details: err instanceof Error ? err.message : String(err),
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
});
}
}
@@ -259,7 +259,9 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
} catch (fallbackErr) {
return reply.status(422).send({
error: "Failed to decode AVIF file",
details: fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr),
details: stripInternalPaths(
fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr),
),
});
}
}
@@ -268,6 +270,9 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
reportProgress(15, "Preparing...");
// Parse and validate settings
if (settingsRaw && settingsRaw.length > 65536) {
return reply.status(400).send({ error: "Settings payload too large (max 64KB)" });
}
let settings: T;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
@@ -523,7 +528,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
});
return reply.status(422).send({
error: "Processing failed",
details: message,
details: stripInternalPaths(message),
});
}
},
+3 -2
View File
@@ -5,6 +5,7 @@ import { join } from "node:path";
import { promisify } from "node:util";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
import { stripInternalPaths } from "../../lib/errors.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
@@ -36,7 +37,7 @@ export function registerInfo(app: FastifyInstance) {
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
});
}
@@ -109,7 +110,7 @@ export function registerInfo(app: FastifyInstance) {
} catch (err) {
return reply.status(422).send({
error: "Failed to read image metadata",
details: err instanceof Error ? err.message : "Unknown error",
details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"),
});
}
});
+114 -68
View File
@@ -17,6 +17,7 @@ import { and, desc, eq, like, sql } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { env } from "../config.js";
import { db, schema, sqlite } from "../db/index.js";
import { auditLog } from "../lib/audit.js";
import {
@@ -81,6 +82,31 @@ function serializeFile(row: typeof schema.userFiles.$inferSelect) {
};
}
/**
* Check whether a user has exceeded their storage quota.
* Returns the total bytes used, or throws if the quota is exceeded.
*/
function checkStorageQuota(userId: string | null): void {
if (!userId || env.MAX_STORAGE_PER_USER_MB <= 0) return;
const result = db
.select({ total: sql<number>`coalesce(sum(${schema.userFiles.size}), 0)` })
.from(schema.userFiles)
.where(eq(schema.userFiles.userId, userId))
.get();
const usedBytes = result?.total ?? 0;
const limitBytes = env.MAX_STORAGE_PER_USER_MB * 1024 * 1024;
if (usedBytes >= limitBytes) {
const error = new Error(
`Storage quota exceeded. Used ${(usedBytes / (1024 * 1024)).toFixed(1)}MB of ${env.MAX_STORAGE_PER_USER_MB}MB`,
);
(error as Error & { statusCode: number }).statusCode = 413;
throw error;
}
}
// ── Route registration ─────────────────────────────────────────────
export async function userFileRoutes(app: FastifyInstance): Promise<void> {
@@ -160,82 +186,94 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
* Multipart form with one or more image file parts.
* Validates each (magic bytes + dimensions), stores to disk, creates DB record.
*/
app.post("/api/v1/files/upload", async (request: FastifyRequest, reply: FastifyReply) => {
const user = getAuthUser(request);
const userId = user?.id ?? null;
app.post(
"/api/v1/files/upload",
{ config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (request: FastifyRequest, reply: FastifyReply) => {
const user = getAuthUser(request);
const userId = user?.id ?? null;
const created: ReturnType<typeof serializeFile>[] = [];
const parts = request.parts();
for await (const part of parts) {
if (part.type !== "file") continue;
// Consume the stream into a buffer
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
const buffer = Buffer.concat(chunks);
if (buffer.length === 0) continue;
// Validate image
const validation = await validateImageBuffer(buffer, part.filename);
if (!validation.valid) {
return reply.status(400).send({
error: `Invalid file "${part.filename}": ${validation.reason}`,
});
}
// Sanitize SVG uploads to prevent XXE, SSRF, and script injection
const safeBuffer = isSvgBuffer(buffer) ? sanitizeSvg(buffer) : buffer;
const safeName = sanitizeFilename(part.filename ?? "upload");
const mimeType = formatToMime(validation.format);
// Persist to disk
const storedName = await saveFile(safeBuffer, safeName);
// Create DB record
const id = randomUUID();
// Enforce per-user storage quota before accepting uploads
try {
db.insert(schema.userFiles)
.values({
id,
userId,
originalName: safeName,
storedName,
mimeType,
size: safeBuffer.length,
width: validation.width,
height: validation.height,
version: 1,
parentId: null,
toolChain: null,
})
.run();
} catch {
return reply.status(409).send({ error: "Failed to save file record" });
checkStorageQuota(userId);
} catch (err) {
const statusCode = (err as Error & { statusCode?: number }).statusCode ?? 413;
return reply.status(statusCode).send({ error: (err as Error).message });
}
const row = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)).get();
const created: ReturnType<typeof serializeFile>[] = [];
if (row) created.push(serializeFile(row));
}
const parts = request.parts();
if (created.length === 0) {
return reply.status(400).send({ error: "No valid files uploaded" });
}
for await (const part of parts) {
if (part.type !== "file") continue;
auditLog(request.log, "FILE_UPLOADED", {
userId,
count: created.length,
files: created.map((f) => f.originalName),
});
// Consume the stream into a buffer
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
const buffer = Buffer.concat(chunks);
return reply.status(201).send({ files: created });
});
if (buffer.length === 0) continue;
// Validate image
const validation = await validateImageBuffer(buffer, part.filename);
if (!validation.valid) {
return reply.status(400).send({
error: `Invalid file "${part.filename}": ${validation.reason}`,
});
}
// Sanitize SVG uploads to prevent XXE, SSRF, and script injection
const safeBuffer = isSvgBuffer(buffer) ? sanitizeSvg(buffer) : buffer;
const safeName = sanitizeFilename(part.filename ?? "upload");
const mimeType = formatToMime(validation.format);
// Persist to disk
const storedName = await saveFile(safeBuffer, safeName);
// Create DB record
const id = randomUUID();
try {
db.insert(schema.userFiles)
.values({
id,
userId,
originalName: safeName,
storedName,
mimeType,
size: safeBuffer.length,
width: validation.width,
height: validation.height,
version: 1,
parentId: null,
toolChain: null,
})
.run();
} catch {
return reply.status(409).send({ error: "Failed to save file record" });
}
const row = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)).get();
if (row) created.push(serializeFile(row));
}
if (created.length === 0) {
return reply.status(400).send({ error: "No valid files uploaded" });
}
auditLog(request.log, "FILE_UPLOADED", {
userId,
count: created.length,
files: created.map((f) => f.originalName),
});
return reply.status(201).send({ files: created });
},
);
/**
* GET /api/v1/files/:id
@@ -503,6 +541,14 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
const user = getAuthUser(request);
const userId = user?.id ?? null;
// Enforce per-user storage quota before saving results
try {
checkStorageQuota(userId);
} catch (err) {
const statusCode = (err as Error & { statusCode?: number }).statusCode ?? 413;
return reply.status(statusCode).send({ error: (err as Error).message });
}
let fileBuffer: Buffer | null = null;
let filename = "result";
let parentId: string | null = null;