Andrey Antukh fc500d1102 🐛 Harden SVG sanitization: workers, limits and error mapping
Address the review findings on the DOMPurify SVG sanitization.

F1: sanitizeSvg was synchronous and blocked the service's only event
loop: a ~30 MiB SVG (the old cap) froze media-processor for ~20 s and
peaked at ~3.4 GiB. The sanitizer now runs in a worker_threads pool
(sanitizeSvgSync is the CPU-bound core), each worker has a
resourceLimits heap cap so an OOM kills the worker and not the process,
and a stuck job is terminated by a timeout and respawned. The default
SVG cap drops to 2 MiB and is enforced in both modes (the backend
validates the declared and the real size on upload, and the real bytes
on import), so a file is accepted or rejected the same way with and
without media-processor.

F2: an unexpected sanitizer failure is logged and reported as 503
svg-sanitization-failed instead of 400 invalid-svg-file, so a service
fault is not blamed on the user's file and no internal detail leaks in
the hint.

F4: the remote backend rejects a 2xx response that is not an SVG
instead of storing it.

Also pin the backend/service wire contract with a test that posts the
exact multipart the JVM builds, and pin the data: URI behavior.

AI-assisted-by: deepseek-v4.1-flash
2026-10-07 17:49:38 +00:00

73 lines
4.0 KiB
TypeScript

import { z } from "zod";
import { hkdfSync } from "node:crypto";
import type { AppConfig } from "./types.js";
const envSchema = z.object({
PENPOT_MEDIA_PROCESSOR_PORT: z.coerce.number().int().positive().default(6065),
PENPOT_MEDIA_PROCESSOR_HOST: z.string().default("0.0.0.0"),
PENPOT_MEDIA_PROCESSOR_MAX_CONCURRENT_REQUESTS: z.coerce.number().int().min(1).default(10),
PENPOT_MEDIA_PROCESSOR_REQUEST_TIMEOUT: z.coerce.number().int().nonnegative().default(180000),
PENPOT_MEDIA_PROCESSOR_MAX_FILE_SIZE: z.coerce.number().int().positive().default(367001600), // 350 MB
PENPOT_MEDIA_PROCESSOR_MEMORY_THRESHOLD: z.coerce.number().int().positive().default(10485760), // 10 MB — uploads below this use memory storage; above use disk storage
PENPOT_MEDIA_PROCESSOR_IMAGE_MAX_PIXELS: z.coerce.number().int().positive().default(128_000_000),
PENPOT_MEDIA_PROCESSOR_IMAGE_MAX_WIDTH: z.coerce.number().int().positive().default(16384),
PENPOT_MEDIA_PROCESSOR_IMAGE_MAX_HEIGHT: z.coerce.number().int().positive().default(16384),
PENPOT_MEDIA_PROCESSOR_SVG_MAX_SIZE: z.coerce.number().int().positive().default(2097152), // 2 MB — see mem:media-processor/core
PENPOT_MEDIA_PROCESSOR_SVG_WORKERS: z.coerce.number().int().nonnegative().default(2), // 0 = run inline (tests/escape hatch)
PENPOT_MEDIA_PROCESSOR_SVG_WORKER_MAX_OLD_MB: z.coerce.number().int().positive().default(512),
PENPOT_MEDIA_PROCESSOR_SVG_TIMEOUT: z.coerce.number().int().positive().default(30000),
PENPOT_MEDIA_PROCESSOR_FONT_PROCESS_MEM: z.coerce.number().int().positive().default(512),
PENPOT_MEDIA_PROCESSOR_FONT_PROCESS_CPU_TIME: z.coerce.number().int().positive().default(30),
PENPOT_MEDIA_PROCESSOR_FONT_TIMEOUT: z.coerce.number().int().positive().default(120000),
PENPOT_MEDIA_PROCESSOR_SHARED_KEY: z.string().optional(),
PENPOT_SECRET_KEY: z.string().optional(),
PENPOT_MEDIA_PROCESSOR_LOG_LEVEL: z
.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"])
.default("info"),
PENPOT_LOGGERS_LOKI_URI: z.string().optional(),
PENPOT_LOGGERS_LOKI_JOB: z.string().default("media-processor"),
PENPOT_LOGGERS_LOKI_ENVIRONMENT: z.string().optional(),
PENPOT_LOGGERS_LOKI_INSTANCE: z.string().optional(),
});
function deriveSharedKey(secret: string): string {
const key = hkdfSync("blake2b512", secret, Buffer.from("media-processor"), "", 32);
return Buffer.from(key).toString("base64url");
}
export function loadConfig(): AppConfig {
const parsed = envSchema.parse(process.env);
let sharedKey: string | null = null;
if (parsed.PENPOT_MEDIA_PROCESSOR_SHARED_KEY) {
sharedKey = parsed.PENPOT_MEDIA_PROCESSOR_SHARED_KEY;
} else if (parsed.PENPOT_SECRET_KEY) {
sharedKey = deriveSharedKey(parsed.PENPOT_SECRET_KEY);
}
return {
port: parsed.PENPOT_MEDIA_PROCESSOR_PORT,
host: parsed.PENPOT_MEDIA_PROCESSOR_HOST,
maxConcurrentRequests: parsed.PENPOT_MEDIA_PROCESSOR_MAX_CONCURRENT_REQUESTS,
requestTimeout: parsed.PENPOT_MEDIA_PROCESSOR_REQUEST_TIMEOUT,
maxFileSize: parsed.PENPOT_MEDIA_PROCESSOR_MAX_FILE_SIZE,
memoryThreshold: parsed.PENPOT_MEDIA_PROCESSOR_MEMORY_THRESHOLD,
imageMaxPixels: parsed.PENPOT_MEDIA_PROCESSOR_IMAGE_MAX_PIXELS,
imageMaxWidth: parsed.PENPOT_MEDIA_PROCESSOR_IMAGE_MAX_WIDTH,
imageMaxHeight: parsed.PENPOT_MEDIA_PROCESSOR_IMAGE_MAX_HEIGHT,
svgMaxSize: parsed.PENPOT_MEDIA_PROCESSOR_SVG_MAX_SIZE,
svgWorkers: parsed.PENPOT_MEDIA_PROCESSOR_SVG_WORKERS,
svgWorkerMaxOldMb: parsed.PENPOT_MEDIA_PROCESSOR_SVG_WORKER_MAX_OLD_MB,
svgTimeout: parsed.PENPOT_MEDIA_PROCESSOR_SVG_TIMEOUT,
fontProcessMem: parsed.PENPOT_MEDIA_PROCESSOR_FONT_PROCESS_MEM,
fontProcessCpuTime: parsed.PENPOT_MEDIA_PROCESSOR_FONT_PROCESS_CPU_TIME,
fontTimeout: parsed.PENPOT_MEDIA_PROCESSOR_FONT_TIMEOUT,
sharedKey,
logLevel: parsed.PENPOT_MEDIA_PROCESSOR_LOG_LEVEL,
lokiUri: parsed.PENPOT_LOGGERS_LOKI_URI || null,
lokiJob: parsed.PENPOT_LOGGERS_LOKI_JOB,
lokiEnvironment: parsed.PENPOT_LOGGERS_LOKI_ENVIRONMENT || null,
lokiInstance: parsed.PENPOT_LOGGERS_LOKI_INSTANCE || null,
};
}