Merge branch 'security/comprehensive-hardening'

# Conflicts:
#	tests/integration/color-palette.test.ts
#	tests/integration/compare.test.ts
#	tests/integration/watermark-image.test.ts
This commit is contained in:
SnapOtter
2026-05-14 20:54:33 +08:00
64 changed files with 2536 additions and 3603 deletions
+3 -3
View File
@@ -25,9 +25,9 @@
"archiver": "^7.0.1",
"better-sqlite3": "^11.7.0",
"dotenv": "^16.4.0",
"drizzle-orm": "^0.38.0",
"drizzle-orm": "^0.45.2",
"exif-reader": "^2.0.3",
"fastify": "^5.2.0",
"fastify": "^5.8.5",
"fflate": "^0.8.2",
"js-yaml": "^4.1.1",
"mupdf": "^1.27.0",
@@ -52,7 +52,7 @@
"@types/pdfkit": "^0.17.5",
"@types/potrace": "^2.1.5",
"@types/qrcode": "^1.5.6",
"drizzle-kit": "^0.30.0",
"drizzle-kit": "^0.31.0",
"typescript": "^5.7.0"
},
"license": "AGPL-3.0"
+7 -10
View File
@@ -111,11 +111,9 @@ app.setErrorHandler((error: Error & { statusCode?: number }, request, reply) =>
if (statusCode >= 500) {
captureException(error, request);
}
const isProduction = process.env.NODE_ENV === "production";
reply.status(statusCode).send({
error: statusCode >= 500 ? "Internal server error" : error.message,
...(statusCode < 500 && { details: error.message }),
...(!isProduction && statusCode >= 500 && { details: error.stack ?? error.message }),
});
});
@@ -126,24 +124,23 @@ await app.register(cors, {
: process.env.NODE_ENV !== "production",
});
// Security headers
// Security headers -- applied in all environments. HSTS is ignored over plain
// HTTP so it is safe (and desirable) to send it in dev/staging too. CSP catches
// injection issues early when applied during development.
app.addHook("onSend", async (_request, reply) => {
reply.header("X-Content-Type-Options", "nosniff");
reply.header("X-Frame-Options", "DENY");
reply.header("X-XSS-Protection", "0");
reply.header("Referrer-Policy", "strict-origin-when-cross-origin");
reply.header("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
if (process.env.NODE_ENV === "production") {
reply.header("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
reply.header("Content-Security-Policy", buildCsp(_request.url.startsWith("/api/docs")));
}
reply.header("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
reply.header("Content-Security-Policy", buildCsp(_request.url.startsWith("/api/docs")));
});
// Always register rate-limit plugin so per-route limits (login brute-force protection) work.
// When RATE_LIMIT_PER_MIN=0, the global limit is set high enough to be effectively unlimited
// while still enabling per-route overrides like the login endpoint.
// RATE_LIMIT_PER_MIN defaults to 300 via env schema; floor at 1 as a safety net.
await app.register(rateLimit, {
max: env.RATE_LIMIT_PER_MIN > 0 ? env.RATE_LIMIT_PER_MIN : 50000,
max: Math.max(env.RATE_LIMIT_PER_MIN, 1),
timeWindow: "1 minute",
allowList: (request) => !request.url.startsWith("/api/"),
});
+10
View File
@@ -2,6 +2,16 @@ const POSTHOG_ORIGINS = ["https://us.i.posthog.com", "https://us-assets.i.postho
const SENTRY_ORIGINS = ["https://*.ingest.us.sentry.io"];
const SCALAR_FONT_ORIGIN = "https://fonts.scalar.com";
/**
* Build a Content-Security-Policy header value.
*
* Notes on 'unsafe-inline':
* - style-src: Required because the React SPA uses inline styles extensively
* (100+ occurrences across 45+ components). Removing it would break the UI.
* - script-src (docs only): The @scalar/fastify-api-reference plugin injects
* inline scripts for its interactive API reference UI. A nonce-based approach
* would require forking the Scalar plugin, which is not practical.
*/
export function buildCsp(isDocs: boolean): string {
const connectSrc = ["'self'", "data:", ...POSTHOG_ORIGINS, ...SENTRY_ORIGINS].join(" ");
const fontSrc = isDocs ? `'self' data: ${SCALAR_FONT_ORIGIN}` : "'self' data:";
+4 -2
View File
@@ -20,7 +20,7 @@ const envSchema = z.object({
MAX_BATCH_SIZE: z.coerce.number().default(0),
CONCURRENT_JOBS: z.coerce.number().default(0),
MAX_MEGAPIXELS: z.coerce.number().default(0),
RATE_LIMIT_PER_MIN: z.coerce.number().default(0),
RATE_LIMIT_PER_MIN: z.coerce.number().default(1000),
DB_PATH: z.string().default("./data/snapotter.db"),
FILES_STORAGE_PATH: z.string().default("./data/files"),
WORKSPACE_PATH: z.string().default("./tmp/workspace"),
@@ -35,9 +35,11 @@ const envSchema = z.object({
MAX_CANVAS_PIXELS: z.coerce.number().default(0),
MAX_SVG_SIZE_MB: z.coerce.number().default(0),
MAX_SPLIT_GRID: z.coerce.number().default(100),
MAX_STORAGE_PER_USER_MB: z.coerce.number().default(5000),
MAX_WORKSPACE_SIZE_GB: z.coerce.number().default(10),
MAX_PDF_PAGES: z.coerce.number().default(0),
SESSION_DURATION_HOURS: z.coerce.number().default(168),
LOGIN_ATTEMPT_LIMIT: z.coerce.number().default(500),
LOGIN_ATTEMPT_LIMIT: z.coerce.number().default(30),
TRUST_PROXY: z
.enum(["true", "false"])
.default("true")
+8
View File
@@ -5,3 +5,11 @@ export function formatZodErrors(issues: ZodIssue[]): string {
.map((i) => (i.path.length > 0 ? `${i.path.join(".")}: ${i.message}` : i.message))
.join("; ");
}
/**
* Strip internal filesystem paths from error messages to avoid
* leaking server directory structure to API consumers.
*/
export function stripInternalPaths(message: string): string {
return message.replace(/\/(tmp|data|app|opt|home|workspace)\b[^\s'")}]*/g, "[internal]");
}
+74 -22
View File
@@ -4,9 +4,43 @@ import { readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { extname, join } from "node:path";
import { promisify } from "node:util";
import { stripInternalPaths } from "./errors.js";
const execFileAsync = promisify(execFile);
/** Maximum character length for any single ExifTool tag value. */
const MAX_TAG_VALUE_LENGTH = 10_000;
/** Allowed pattern for ExifTool tag names (alphanumeric, colon, underscore, hyphen). */
const TAG_NAME_PATTERN = /^[a-zA-Z0-9:_-]+$/;
/**
* Validate and sanitize a tag value: strip null bytes, enforce length limit.
* Returns the sanitized value or throws if it exceeds the length limit.
*/
export function sanitizeTagValue(value: string, tagName: string): string {
// Strip null bytes
const cleaned = value.replace(/\0/g, "");
if (cleaned.length > MAX_TAG_VALUE_LENGTH) {
throw new Error(
`Tag value for "${tagName}" exceeds maximum length of ${MAX_TAG_VALUE_LENGTH} characters`,
);
}
return cleaned;
}
/**
* Validate a tag name against the allowed pattern.
* Throws if the name contains invalid characters.
*/
export function validateTagName(name: string): void {
if (!TAG_NAME_PATTERN.test(name)) {
throw new Error(
`Invalid tag name "${name}": only alphanumeric, colon, underscore, and hyphen are allowed`,
);
}
}
/** Grouped metadata returned by ExifTool -json -G */
export interface ExifToolMetadata {
[group: string]: Record<string, unknown>;
@@ -102,6 +136,9 @@ export async function inspectMetadata(buffer: Buffer, filename: string): Promise
gps: Object.keys(gps).length > 0 ? gps : null,
keywords: uniqueKeywords,
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(stripInternalPaths(message));
} finally {
await rm(tempPath, { force: true }).catch(() => {});
}
@@ -130,6 +167,9 @@ export async function writeMetadata(
maxBuffer: 10 * 1024 * 1024,
});
return await readFile(tempPath);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(stripInternalPaths(message));
} finally {
await rm(tempPath, { force: true }).catch(() => {});
}
@@ -191,35 +231,42 @@ export interface EditMetadataSettings {
/**
* Convert settings object into ExifTool CLI tag arguments.
* All tag values are sanitized (null bytes stripped, length limited).
* All tag names in fieldsToRemove are validated against an allowed pattern.
*/
export function buildTagArgs(settings: EditMetadataSettings): string[] {
const args: string[] = [];
/** Helper: sanitize a string value before adding as a tag argument. */
const s = (value: string, tagName: string): string => sanitizeTagValue(value, tagName);
// Common aliases
const artist = settings.artist || settings.author;
const description = settings.imageDescription || settings.title;
// Basic EXIF fields
if (artist) args.push(`-Artist=${artist}`);
if (settings.copyright) args.push(`-Copyright=${settings.copyright}`);
if (description) args.push(`-ImageDescription=${description}`);
if (settings.software) args.push(`-Software=${settings.software}`);
if (settings.title) args.push(`-XMP:Title=${settings.title}`);
if (artist) args.push(`-Artist=${s(artist, "Artist")}`);
if (settings.copyright) args.push(`-Copyright=${s(settings.copyright, "Copyright")}`);
if (description) args.push(`-ImageDescription=${s(description, "ImageDescription")}`);
if (settings.software) args.push(`-Software=${s(settings.software, "Software")}`);
if (settings.title) args.push(`-XMP:Title=${s(settings.title, "XMP:Title")}`);
// Date fields
if (settings.dateTime) args.push(`-ModifyDate=${settings.dateTime}`);
if (settings.dateTimeOriginal) args.push(`-DateTimeOriginal=${settings.dateTimeOriginal}`);
if (settings.dateTime) args.push(`-ModifyDate=${s(settings.dateTime, "ModifyDate")}`);
if (settings.dateTimeOriginal)
args.push(`-DateTimeOriginal=${s(settings.dateTimeOriginal, "DateTimeOriginal")}`);
// Date shift (applies to all date fields)
if (settings.dateShift) {
const direction = settings.dateShift.startsWith("-") ? "-" : "+";
const value = settings.dateShift.replace(/^[+-]/, "");
const cleaned = s(settings.dateShift, "dateShift");
const direction = cleaned.startsWith("-") ? "-" : "+";
const value = cleaned.replace(/^[+-]/, "");
args.push(`-AllDates${direction}=0:0:0 ${value}:0`);
}
// Set all dates to a specific value
if (settings.setAllDates) {
args.push(`-AllDates=${settings.setAllDates}`);
args.push(`-AllDates=${s(settings.setAllDates, "setAllDates")}`);
}
// GPS coordinates
@@ -248,26 +295,31 @@ export function buildTagArgs(settings: EditMetadataSettings): string[] {
args.push("-XMP:Subject=");
}
for (const kw of settings.keywords) {
if (kw.trim()) {
args.push(`-IPTC:Keywords+=${kw.trim()}`);
args.push(`-XMP:Subject+=${kw.trim()}`);
const trimmed = s(kw, "keyword").trim();
if (trimmed) {
args.push(`-IPTC:Keywords+=${trimmed}`);
args.push(`-XMP:Subject+=${trimmed}`);
}
}
}
// IPTC fields
if (settings.iptcTitle) args.push(`-IPTC:ObjectName=${settings.iptcTitle}`);
if (settings.iptcHeadline) args.push(`-IPTC:Headline=${settings.iptcHeadline}`);
if (settings.iptcCity) args.push(`-IPTC:City=${settings.iptcCity}`);
if (settings.iptcState) args.push(`-IPTC:Province-State=${settings.iptcState}`);
if (settings.iptcCountry) args.push(`-IPTC:Country-PrimaryLocationName=${settings.iptcCountry}`);
if (settings.iptcTitle) args.push(`-IPTC:ObjectName=${s(settings.iptcTitle, "IPTC:ObjectName")}`);
if (settings.iptcHeadline)
args.push(`-IPTC:Headline=${s(settings.iptcHeadline, "IPTC:Headline")}`);
if (settings.iptcCity) args.push(`-IPTC:City=${s(settings.iptcCity, "IPTC:City")}`);
if (settings.iptcState)
args.push(`-IPTC:Province-State=${s(settings.iptcState, "IPTC:Province-State")}`);
if (settings.iptcCountry)
args.push(
`-IPTC:Country-PrimaryLocationName=${s(settings.iptcCountry, "IPTC:Country-PrimaryLocationName")}`,
);
// Field removal -- only allow safe EXIF/IPTC/XMP tag names (alphanumeric, colon, hyphen)
// Field removal -- validate tag names against allowed pattern
if (settings.fieldsToRemove && settings.fieldsToRemove.length > 0) {
for (const field of settings.fieldsToRemove) {
if (/^[A-Za-z0-9:_-]+$/.test(field)) {
args.push(`-${field}=`);
}
validateTagName(field);
args.push(`-${field}=`);
}
}
+23 -1
View File
@@ -1,8 +1,29 @@
import { randomUUID } from "node:crypto";
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
import { mkdir, readFile, statfs, unlink, writeFile } from "node:fs/promises";
import { extname, join } from "node:path";
import { env } from "../config.js";
/** Minimum free disk space (100 MB) before refusing writes. */
const MIN_FREE_BYTES = 100 * 1024 * 1024;
/**
* Check available disk space and throw 507 if below threshold.
*/
async function assertDiskSpace(dir: string): Promise<void> {
try {
const stats = await statfs(dir);
const freeBytes = stats.bfree * stats.bsize;
if (freeBytes < MIN_FREE_BYTES) {
const err = new Error("Insufficient disk space") as Error & { statusCode: number };
err.statusCode = 507;
throw err;
}
} catch (e) {
// Re-throw our own 507 errors; swallow statfs failures (e.g. unsupported OS)
if (e instanceof Error && (e as Error & { statusCode?: number }).statusCode === 507) throw e;
}
}
const SAFE_STORAGE_EXTENSIONS = new Set([
".jpg",
".jpeg",
@@ -41,6 +62,7 @@ export async function ensureStorageDir(): Promise<void> {
export async function saveFile(buffer: Buffer, originalName: string): Promise<string> {
await ensureStorageDir();
await assertDiskSpace(env.FILES_STORAGE_PATH);
let ext = extname(originalName).toLowerCase() || ".bin";
// Only allow known image extensions to be stored — reject dangerous extensions
// even if they somehow pass upstream sanitization.
+30 -16
View File
@@ -1,6 +1,7 @@
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import { readFile, rm, writeFile } from "node:fs/promises";
import { constants } from "node:fs";
import { open, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
@@ -8,6 +9,19 @@ import sharp from "sharp";
const execFileAsync = promisify(execFile);
/**
* Write a buffer to a temp file exclusively (O_CREAT | O_EXCL | O_WRONLY).
* Prevents symlink / race-condition attacks on predictable temp paths.
*/
async function writeTempExclusive(filePath: string, buffer: Buffer): Promise<void> {
const fh = await open(filePath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY);
try {
await fh.writeFile(buffer);
} finally {
await fh.close();
}
}
/** Formats that need external CLI tools (not decodable by Sharp). */
const CLI_DECODED_FORMATS = new Set([
"raw",
@@ -102,7 +116,7 @@ export async function decodeAnyFormat(buffer: Buffer, format: string): Promise<B
const outputPath = join(tmpdir(), `any-out-${id}.png`);
try {
await writeFile(inputPath, buffer);
await writeTempExclusive(inputPath, buffer);
await execFileAsync(
cmd,
magickArgs(cmd, [inputPath, "-colorspace", "sRGB", `png:${outputPath}`]),
@@ -146,7 +160,7 @@ async function decodeIco(buffer: Buffer): Promise<Buffer> {
const outputPath = join(tmpdir(), `ico-out-${id}.png`);
try {
await writeFile(inputPath, buffer);
await writeTempExclusive(inputPath, buffer);
// ICO contains multiple sizes; extract the largest by sorting
await execFileAsync(cmd, magickArgs(cmd, [`${inputPath}[-1]`, `png:${outputPath}`]), {
timeout: 120_000,
@@ -176,7 +190,7 @@ async function decodeRaw(buffer: Buffer, ext?: string): Promise<Buffer> {
const outputPath = join(tmpdir(), `raw-out-${id}.png`);
try {
await writeFile(inputPath, buffer);
await writeTempExclusive(inputPath, buffer);
// Attempt 1: ExifTool embedded JPEG extraction (fast path)
try {
@@ -223,7 +237,7 @@ async function decodePsd(buffer: Buffer): Promise<Buffer> {
const outputPath = join(tmpdir(), `psd-out-${id}.png`);
try {
await writeFile(inputPath, buffer);
await writeTempExclusive(inputPath, buffer);
await execFileAsync(cmd, magickArgs(cmd, [`${inputPath}[0]`, `png:${outputPath}`]), {
timeout: 120_000,
});
@@ -244,7 +258,7 @@ async function decodeTga(buffer: Buffer): Promise<Buffer> {
const outputPath = join(tmpdir(), `tga-out-${id}.png`);
try {
await writeFile(inputPath, buffer);
await writeTempExclusive(inputPath, buffer);
await execFileAsync(cmd, magickArgs(cmd, [inputPath, `png:${outputPath}`]), {
timeout: 120_000,
});
@@ -265,7 +279,7 @@ async function decodeExr(buffer: Buffer): Promise<Buffer> {
const outputPath = join(tmpdir(), `exr-out-${id}.png`);
try {
await writeFile(inputPath, buffer);
await writeTempExclusive(inputPath, buffer);
// ImageMagick needs the OpenEXR delegate which is often missing on macOS
try {
@@ -302,7 +316,7 @@ async function decodeHdr(buffer: Buffer): Promise<Buffer> {
const outputPath = join(tmpdir(), `hdr-out-${id}.png`);
try {
await writeFile(inputPath, buffer);
await writeTempExclusive(inputPath, buffer);
await execFileAsync(
cmd,
magickArgs(cmd, [inputPath, "-colorspace", "sRGB", "-depth", "8", `png:${outputPath}`]),
@@ -322,7 +336,7 @@ async function decodeBmp(buffer: Buffer): Promise<Buffer> {
const outputPath = join(tmpdir(), `bmp-out-${id}.png`);
try {
await writeFile(inputPath, buffer);
await writeTempExclusive(inputPath, buffer);
await execFileAsync(cmd, magickArgs(cmd, [inputPath, `png:${outputPath}`]), {
timeout: 120_000,
});
@@ -339,7 +353,7 @@ async function decodeJxl(buffer: Buffer): Promise<Buffer> {
const outputPath = join(tmpdir(), `jxl-out-${id}.png`);
try {
await writeFile(inputPath, buffer);
await writeTempExclusive(inputPath, buffer);
// Try djxl first (from libjxl-tools) — works even when ImageMagick
// lacks a JXL delegate (common on Ubuntu stock packages).
@@ -368,7 +382,7 @@ async function decodeJp2(buffer: Buffer): Promise<Buffer> {
const inputPath = join(tmpdir(), `jp2-in-${id}.jp2`);
const outputPath = join(tmpdir(), `jp2-out-${id}.png`);
try {
await writeFile(inputPath, buffer);
await writeTempExclusive(inputPath, buffer);
try {
await execFileAsync("opj_decompress", ["-i", inputPath, "-o", outputPath], {
timeout: 60_000,
@@ -403,7 +417,7 @@ async function decodeEps(buffer: Buffer): Promise<Buffer> {
const inputPath = join(tmpdir(), `eps-in-${id}.eps`);
const outputPath = join(tmpdir(), `eps-out-${id}.png`);
try {
await writeFile(inputPath, buffer);
await writeTempExclusive(inputPath, buffer);
await execFileAsync(
cmd,
magickArgs(cmd, [
@@ -433,7 +447,7 @@ async function decodeDds(buffer: Buffer): Promise<Buffer> {
const inputPath = join(tmpdir(), `dds-in-${id}.dds`);
const outputPath = join(tmpdir(), `dds-out-${id}.png`);
try {
await writeFile(inputPath, buffer);
await writeTempExclusive(inputPath, buffer);
await execFileAsync(cmd, magickArgs(cmd, [`${inputPath}[0]`, `png:${outputPath}`]), {
timeout: 120_000,
});
@@ -452,7 +466,7 @@ async function decodeDpx(buffer: Buffer): Promise<Buffer> {
const inputPath = join(tmpdir(), `dpx-in-${id}.dpx`);
const outputPath = join(tmpdir(), `dpx-out-${id}.png`);
try {
await writeFile(inputPath, buffer);
await writeTempExclusive(inputPath, buffer);
await execFileAsync(
cmd,
magickArgs(cmd, [inputPath, "-colorspace", "sRGB", `png:${outputPath}`]),
@@ -473,7 +487,7 @@ async function decodeFits(buffer: Buffer): Promise<Buffer> {
const inputPath = join(tmpdir(), `fits-in-${id}.fits`);
const outputPath = join(tmpdir(), `fits-out-${id}.png`);
try {
await writeFile(inputPath, buffer);
await writeTempExclusive(inputPath, buffer);
await execFileAsync(
cmd,
magickArgs(cmd, [
@@ -516,7 +530,7 @@ async function decodeNetpbm(buffer: Buffer, format: string): Promise<Buffer> {
const inputPath = join(tmpdir(), `netpbm-in-${id}.${ext}`);
const outputPath = join(tmpdir(), `netpbm-out-${id}.png`);
try {
await writeFile(inputPath, buffer);
await writeTempExclusive(inputPath, buffer);
await execFileAsync(cmd, magickArgs(cmd, [inputPath, `png:${outputPath}`]), {
timeout: 120_000,
});
+17 -3
View File
@@ -1,12 +1,26 @@
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import { readFile, rm, writeFile } from "node:fs/promises";
import { constants } from "node:fs";
import { open, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
/**
* Write a buffer to a temp file exclusively (O_CREAT | O_EXCL | O_WRONLY).
* Prevents symlink / race-condition attacks on predictable temp paths.
*/
async function writeTempExclusive(filePath: string, buffer: Buffer): Promise<void> {
const fh = await open(filePath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY);
try {
await fh.writeFile(buffer);
} finally {
await fh.close();
}
}
/**
* Find the HEIF decode command. Both heif-convert and heif-dec accept
* `<input> <output>` positional arguments.
@@ -44,7 +58,7 @@ export async function decodeHeic(buffer: Buffer): Promise<Buffer> {
const suffixedPath = outputPath.replace(/\.png$/, "-1.png");
try {
await writeFile(inputPath, buffer);
await writeTempExclusive(inputPath, buffer);
await execFileAsync(cmd, [inputPath, outputPath], { timeout: 120_000 });
// Single-image HEIF: exact filename. Multi-image: -1 suffix on first image.
@@ -92,7 +106,7 @@ export async function encodeHeic(buffer: Buffer, quality = 80): Promise<Buffer>
const outputPath = join(tmpdir(), `heic-out-${id}.heic`);
try {
await writeFile(inputPath, buffer);
await writeTempExclusive(inputPath, buffer);
await execFileAsync("heif-enc", ["-q", String(quality), "-o", outputPath, inputPath], {
timeout: 120_000,
});
+112 -8
View File
@@ -1,4 +1,6 @@
import { lookup } from "node:dns/promises";
import http from "node:http";
import https from "node:https";
import { isIP } from "node:net";
function isPrivateIPv4(ip: string): boolean {
@@ -25,6 +27,10 @@ function isPrivateIPv6(ip: string): boolean {
if (normalized.startsWith("fe80:")) return true;
if (normalized.startsWith("fc") || normalized.startsWith("fd")) return true;
if (normalized.startsWith("2001:db8:")) return true;
// 6to4 addresses can encapsulate private IPv4 addresses
if (normalized.startsWith("2002:")) return true;
// NAT64 prefix maps to IPv4 -- block to prevent SSRF via IPv4-mapped addresses
if (normalized.startsWith("64:ff9b:")) return true;
if (normalized.includes("::ffff:")) {
const v4 = normalized.split("::ffff:")[1];
if (v4 && isPrivateIPv4(v4)) return true;
@@ -32,13 +38,18 @@ function isPrivateIPv6(ip: string): boolean {
return false;
}
async function resolveAndCheck(hostname: string): Promise<void> {
/**
* Resolve a hostname and validate all returned IPs are public.
* Returns the first valid resolved IP so callers can pin it for the actual
* connection, preventing DNS rebinding (TOCTOU) attacks.
*/
async function resolveAndCheck(hostname: string): Promise<string> {
const bare = hostname.replace(/^\[|]$/g, "");
if (isIP(bare)) {
if (isPrivateIPv4(bare) || isPrivateIPv6(bare)) {
throw new Error("URL resolves to a private or reserved IP address");
}
return;
return bare;
}
const result = await lookup(hostname, { all: true });
@@ -49,9 +60,15 @@ async function resolveAndCheck(hostname: string): Promise<void> {
throw new Error("URL resolves to a private or reserved IP address");
}
}
return addresses[0].address;
}
export async function validateFetchUrl(url: string): Promise<void> {
/**
* Validate that a URL points to a public address and return the pinned IP.
* The resolved IP should be used for the actual connection to prevent DNS
* rebinding between validation and fetch.
*/
export async function validateFetchUrl(url: string): Promise<{ resolvedIp: string }> {
let parsed: URL;
try {
parsed = new URL(url);
@@ -63,7 +80,8 @@ export async function validateFetchUrl(url: string): Promise<void> {
throw new Error("Only HTTP and HTTPS URLs are supported");
}
await resolveAndCheck(parsed.hostname);
const resolvedIp = await resolveAndCheck(parsed.hostname);
return { resolvedIp };
}
export const MAX_REDIRECTS = 5;
@@ -72,21 +90,107 @@ export const MAX_URL_FETCH_SIZE = 50 * 1024 * 1024;
export const MAX_URLS_PER_REQUEST = 50;
export const URL_FETCH_CONCURRENCY = 4;
/**
* Create an HTTP(S) agent that pins DNS resolution to a specific IP address.
* This prevents DNS rebinding attacks where a hostname resolves to a different
* (private) IP between our SSRF validation and the actual connection.
*/
function createPinnedAgent(resolvedIp: string, protocol: string): http.Agent | https.Agent {
const pinnedLookup: (
hostname: string,
options: object,
callback: (err: NodeJS.ErrnoException | null, address: string, family: number) => void,
) => void = (_hostname, _options, callback) => {
const family = resolvedIp.includes(":") ? 6 : 4;
callback(null, resolvedIp, family);
};
if (protocol === "https:") {
return new https.Agent({ lookup: pinnedLookup as never, maxSockets: 1 });
}
return new http.Agent({ lookup: pinnedLookup as never, maxSockets: 1 });
}
export async function safeFetch(url: string, signal?: AbortSignal): Promise<Response> {
let currentUrl = url;
for (let i = 0; i <= MAX_REDIRECTS; i++) {
await validateFetchUrl(currentUrl);
const res = await fetch(currentUrl, {
const { resolvedIp } = await validateFetchUrl(currentUrl);
const parsed = new URL(currentUrl);
const agent = createPinnedAgent(resolvedIp, parsed.protocol);
// Use the Node.js fetch dispatcher option for IP pinning.
// Replace hostname with the resolved IP for HTTP; for HTTPS, use
// the pinned agent to maintain SNI with the original hostname.
let fetchUrl = currentUrl;
if (parsed.protocol === "http:") {
// For HTTP, replace hostname directly -- no TLS/SNI concerns
const pinnedUrl = new URL(currentUrl);
pinnedUrl.hostname = resolvedIp.includes(":") ? `[${resolvedIp}]` : resolvedIp;
fetchUrl = pinnedUrl.href;
}
const fetchOptions: RequestInit & { agent?: http.Agent | https.Agent } = {
signal,
redirect: "manual",
headers: { "User-Agent": "SnapOtter/1.0 (image-fetch)" },
});
headers: {
"User-Agent": "SnapOtter/1.0 (image-fetch)",
Host: parsed.host,
},
};
// Node.js undici-based fetch does not support the `agent` option directly.
// For HTTP we use the IP-replaced URL. For HTTPS we use the pinned agent
// via the Node.js http/https request internals by importing from node:https.
let res: Response;
if (parsed.protocol === "https:") {
// For HTTPS, use node:https with the pinned agent and original hostname for SNI
res = await new Promise<Response>((resolve, reject) => {
const req = https.request(
currentUrl,
{
agent,
signal: signal ?? undefined,
headers: {
"User-Agent": "SnapOtter/1.0 (image-fetch)",
},
method: "GET",
},
(incomingMessage) => {
const chunks: Buffer[] = [];
incomingMessage.on("data", (chunk: Buffer) => chunks.push(chunk));
incomingMessage.on("end", () => {
const body = Buffer.concat(chunks);
const headers = new Headers();
for (const [key, value] of Object.entries(incomingMessage.headers)) {
if (value) {
const vals = Array.isArray(value) ? value : [value];
for (const v of vals) headers.append(key, v);
}
}
resolve(
new Response(body, {
status: incomingMessage.statusCode ?? 500,
statusText: incomingMessage.statusMessage ?? "",
headers,
}),
);
});
incomingMessage.on("error", reject);
},
);
req.on("error", reject);
req.end();
});
} else {
res = await fetch(fetchUrl, fetchOptions);
}
if (res.status >= 300 && res.status < 400) {
const location = res.headers.get("location");
if (!location) throw new Error("Redirect without Location header");
await res.body?.cancel();
currentUrl = new URL(location, currentUrl).href;
agent.destroy();
continue;
}
+49 -5
View File
@@ -1,6 +1,16 @@
import { gunzipSync } from "node:zlib";
import { env } from "../config.js";
/**
* Decode common HTML/XML numeric character references (&#xNN; and &#NNN;)
* so that obfuscated `javascript:` / `data:` URIs are caught by later regex passes.
*/
function decodeNumericEntities(input: string): string {
return input
.replace(/&#x([0-9a-fA-F]+);/g, (_m, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
.replace(/&#(\d+);/g, (_m, dec) => String.fromCharCode(Number.parseInt(dec, 10)));
}
/**
* Sanitize an SVG buffer to prevent XXE, SSRF, and script injection.
* Throws if the SVG exceeds the maximum allowed size.
@@ -11,6 +21,13 @@ export function sanitizeSvg(buffer: Buffer): Buffer {
throw new Error(`SVG exceeds maximum size of ${env.MAX_SVG_SIZE_MB}MB`);
}
let svg = buffer.toString("utf-8");
// ── Pre-processing: strip CDATA sections and decode numeric entities ──
// CDATA sections can hide script content from regex-based checks.
svg = svg.replace(/<!\[CDATA\[[\s\S]*?\]\]>/gi, "");
// Decode numeric entities so obfuscated URIs (e.g. &#106;avascript:) are visible.
svg = decodeNumericEntities(svg);
// Remove DOCTYPE (XXE prevention, including internal subsets)
svg = svg.replace(/<!DOCTYPE[^>[]*(?:\[[^\]]*\])?>/gi, "");
// Remove XML processing instructions except <?xml version...?>
@@ -18,22 +35,49 @@ export function sanitizeSvg(buffer: Buffer): Buffer {
// Remove XInclude elements and namespace declarations
svg = svg.replace(/<[^>]*xi:include[^>]*\/?>/gi, "");
svg = svg.replace(/xmlns:xi\s*=\s*["'][^"']*["']/gi, "");
// Remove script tags
// ── Strip dangerous elements ──
// Remove script tags (including nested inside <svg>)
svg = svg.replace(/<script[\s\S]*?<\/script>/gi, "");
svg = svg.replace(/<script[^>]*\/>/gi, "");
// Remove foreignObject elements (can embed arbitrary HTML)
svg = svg.replace(/<foreignObject[\s\S]*?<\/foreignObject>/gi, "");
svg = svg.replace(/<foreignObject[^>]*\/>/gi, "");
// Remove iframe elements (non-SVG, can load external content)
svg = svg.replace(/<iframe[\s\S]*?<\/iframe>/gi, "");
svg = svg.replace(/<iframe[^>]*\/>/gi, "");
// Remove embed elements (non-SVG, can load external content)
svg = svg.replace(/<embed[\s\S]*?<\/embed>/gi, "");
svg = svg.replace(/<embed[^>]*\/>/gi, "");
// Remove <set> elements (can inject attributes/URIs at runtime)
svg = svg.replace(/<set[\s\S]*?<\/set>/gi, "");
svg = svg.replace(/<set\b[^>]*\/>/gi, "");
// Remove <animate> elements (can inject attributes/URIs at runtime)
svg = svg.replace(/<animate[\s\S]*?<\/animate>/gi, "");
svg = svg.replace(/<animate\b[^>]*\/>/gi, "");
// Remove event handlers (onload, onclick, onerror, etc.)
svg = svg.replace(/\bon\w+\s*=/gi, "data-removed=");
// Block dangerous URI schemes in href attributes
// Replace both the attribute name and its value to prevent residual payloads.
svg = svg.replace(/\bon\w+\s*=\s*["'][^"']*["']/gi, 'data-removed=""');
svg = svg.replace(/\bon\w+\s*=\s*\S+/gi, 'data-removed=""');
// ── Block <use> with external href (before generic href blocking) ──
svg = svg.replace(/<use\b[^>]*href\s*=\s*["']https?:\/\/[^"']*["'][^>]*\/?>/gi, "");
svg = svg.replace(/<use\b[^>]*xlink:href\s*=\s*["']https?:\/\/[^"']*["'][^>]*\/?>/gi, "");
// ── Block dangerous URI schemes in href attributes ──
svg = svg.replace(/xlink:href\s*=\s*["']https?:\/\//gi, 'xlink:href="data:,');
svg = svg.replace(/href\s*=\s*["']https?:\/\//gi, 'href="data:,');
svg = svg.replace(/href\s*=\s*["']javascript:/gi, 'href="data:,');
svg = svg.replace(/href\s*=\s*["']data:text\/html/gi, 'href="data:,');
// Block ALL data: URIs in href (not just data:text/html)
svg = svg.replace(/href\s*=\s*["']data:/gi, 'href="data:,');
svg = svg.replace(/href\s*=\s*["']file:/gi, 'href="data:,');
// Block use elements referencing external resources
// ── Block dangerous schemes in url() values ──
svg = svg.replace(/url\s*\(\s*["']?https?:\/\//gi, 'url("data:,');
svg = svg.replace(/url\s*\(\s*["']?file:/gi, 'url("data:,');
svg = svg.replace(/url\s*\(\s*["']?data:/gi, 'url("data:,');
return Buffer.from(svg, "utf-8");
}
+54 -1
View File
@@ -1,12 +1,65 @@
import { mkdir, rm } from "node:fs/promises";
import { existsSync } from "node:fs";
import { mkdir, rm, statfs } from "node:fs/promises";
import { join } from "node:path";
import { env } from "../config.js";
/**
* Check available disk space before creating a workspace.
* Triggers cleanup if free space is low, and rejects with 503 if
* space remains critically low after cleanup.
*/
async function checkWorkspaceCapacity(workspaceRoot: string): Promise<void> {
if (!existsSync(workspaceRoot)) return;
let stats;
try {
stats = await statfs(workspaceRoot);
} catch {
return;
}
const freeBytes = stats.bavail * stats.bsize;
const freeGB = freeBytes / 1024 ** 3;
if (freeGB < 1) {
// Attempt to reclaim space by cleaning up old workspaces
const { readdir, stat: fsStat } = await import("node:fs/promises");
const entries = await readdir(workspaceRoot, { withFileTypes: true }).catch(() => []);
const now = Date.now();
for (const entry of entries) {
const fullPath = join(workspaceRoot, entry.name);
try {
const s = await fsStat(fullPath);
// Remove workspaces older than 1 hour during emergency cleanup
if (now - s.mtimeMs > 60 * 60 * 1000) {
await rm(fullPath, { recursive: true, force: true });
}
} catch {
// Skip entries that can't be stat'd
}
}
// Recheck after cleanup
let stats2;
try {
stats2 = await statfs(workspaceRoot);
} catch {
return;
}
const freeGB2 = (stats2.bavail * stats2.bsize) / 1024 ** 3;
if (freeGB2 < 0.5) {
const error = new Error("Insufficient disk space for processing");
(error as Error & { statusCode: number }).statusCode = 503;
throw error;
}
}
}
/**
* Create a workspace directory structure for a processing job.
* Returns the workspace root path.
*/
export async function createWorkspace(jobId: string): Promise<string> {
await checkWorkspaceCapacity(env.WORKSPACE_PATH);
const root = getWorkspacePath(jobId);
await mkdir(join(root, "input"), { recursive: true });
await mkdir(join(root, "output"), { recursive: true });
+35 -20
View File
@@ -72,19 +72,19 @@ function validateUsername(username: string): string | null {
// ── Zod schemas for auth request bodies ──────────────────────────
const loginSchema = z.object({
username: z.string().min(1, "Username is required"),
password: z.string().min(1, "Password is required"),
export const loginSchema = z.object({
username: z.string().min(1, "Username is required").max(255, "Username too long"),
password: z.string().min(1, "Password is required").max(1024, "Password too long"),
});
const changePasswordSchema = z.object({
currentPassword: z.string().min(1, "Current password is required"),
newPassword: z.string().min(1, "New password is required"),
export const changePasswordSchema = z.object({
currentPassword: z.string().min(1, "Current password is required").max(1024, "Password too long"),
newPassword: z.string().min(1, "New password is required").max(1024, "Password too long"),
});
const registerSchema = z.object({
username: z.string().min(1, "Username is required"),
password: z.string().min(1, "Password is required"),
export const registerSchema = z.object({
username: z.string().min(1, "Username is required").max(255, "Username too long"),
password: z.string().min(1, "Password is required").max(1024, "Password too long"),
role: z.string().optional(),
team: z.string().optional(),
});
@@ -94,8 +94,8 @@ const updateUserSchema = z.object({
team: z.string().optional(),
});
const resetPasswordSchema = z.object({
newPassword: z.string().min(1, "New password is required"),
export const resetPasswordSchema = z.object({
newPassword: z.string().min(1, "New password is required").max(1024, "Password too long"),
});
// ── Request helpers ───────────────────────────────────────────────
@@ -641,6 +641,15 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
db.update(schema.users).set(updates).where(eq(schema.users.id, id)).run();
// Invalidate all sessions when role changes to force re-login with new permissions
if (updates.role && updates.role !== user.role) {
db.delete(schema.sessions).where(eq(schema.sessions.userId, id)).run();
request.log.info(
{ targetUserId: id, oldRole: user.role, newRole: updates.role },
"Sessions invalidated due to role change",
);
}
auditLog(request.log, "USER_UPDATED", {
adminId: admin.id,
targetUserId: id,
@@ -813,15 +822,21 @@ export async function authMiddleware(app: FastifyInstance): Promise<void> {
.from(schema.apiKeys)
.where(eq(schema.apiKeys.keyPrefix, prefix))
.all();
// Fall back to full scan for legacy keys without a prefix
const keysToCheck =
candidates.length > 0
? candidates
: db
.select()
.from(schema.apiKeys)
.all()
.filter((k) => !k.keyPrefix);
// Fall back to full scan for legacy keys without a prefix (bounded to 100)
let keysToCheck: typeof candidates;
if (candidates.length > 0) {
keysToCheck = candidates;
} else {
request.log.warn(
"Legacy API key lookup triggered (no keyPrefix match). Migrate keys to use prefix-based lookup.",
);
keysToCheck = db
.select()
.from(schema.apiKeys)
.all()
.filter((k) => !k.keyPrefix)
.slice(0, 100);
}
for (const key of keysToCheck) {
const matches = await verifyPassword(token, key.keyHash);
if (matches) {
+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;
+1 -1
View File
@@ -14,7 +14,7 @@
"dependencies": {
"framer-motion": "^11.18.0",
"lucide-react": "^0.469.0",
"next": "^15.3.0",
"next": "^15.5.18",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},