mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user