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:
@@ -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