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:
@@ -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:";
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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]");
|
||||
}
|
||||
|
||||
@@ -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}=`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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. javascript:) 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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user