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
+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 });