fix: allow SVG files in the convert tool

SVG files were rejected by the convert endpoint because
validateImageBuffer only recognized raster magic bytes. This adds
text-based SVG detection, sanitization in the tool factory, and
proper Sharp density handling so SVG-to-raster conversion works
through the standard convert route.
This commit is contained in:
Siddharth Kumar Sah
2026-03-30 11:37:09 +08:00
parent b1bfcbef9c
commit 4fa8dd0780
8 changed files with 270 additions and 41 deletions
+15 -4
View File
@@ -1,8 +1,18 @@
import sharp from "sharp";
import { env } from "../config.js";
import { isSvgBuffer } from "./svg-sanitize.js";
/** Formats we accept as input. */
const SUPPORTED_INPUT_FORMATS = new Set(["jpeg", "png", "webp", "gif", "tiff", "bmp", "avif"]);
const SUPPORTED_INPUT_FORMATS = new Set([
"jpeg",
"png",
"webp",
"gif",
"tiff",
"bmp",
"avif",
"svg",
]);
interface MagicEntry {
bytes: number[];
@@ -56,8 +66,8 @@ export async function validateImageBuffer(
return { valid: false, reason: "File contains no image data" };
}
// 2. Magic byte detection
const detectedFormat = detectMagicBytes(buffer);
// 2. Format detection (magic bytes for raster, text check for SVG)
const detectedFormat = detectMagicBytes(buffer) || (isSvgBuffer(buffer) ? "svg" : null);
if (!detectedFormat) {
return { valid: false, reason: "Unrecognized image format" };
}
@@ -72,7 +82,8 @@ export async function validateImageBuffer(
// 4. Dimensions check via sharp metadata
try {
const metadata = await sharp(buffer).metadata();
const sharpOpts = detectedFormat === "svg" ? { density: 72 } : undefined;
const metadata = await sharp(buffer, sharpOpts).metadata();
const width = metadata.width ?? 0;
const height = metadata.height ?? 0;
const megapixels = (width * height) / 1_000_000;
+3 -1
View File
@@ -13,6 +13,7 @@ export interface WorkerInput {
inputBuffer: Buffer;
settings: unknown;
filename: string;
inputFormat?: string;
}
export interface WorkerOutput {
@@ -50,7 +51,8 @@ export default async function processInWorker(input: WorkerInput): Promise<Worke
throw new Error(`Tool "${input.toolId}" not found in worker registry`);
}
const oriented = await autoOrient(Buffer.from(input.inputBuffer));
const buf = Buffer.from(input.inputBuffer);
const oriented = input.inputFormat === "svg" ? buf : await autoOrient(buf);
const result = await config.process(oriented, input.settings, input.filename);
return {
+45
View File
@@ -0,0 +1,45 @@
const MAX_SVG_SIZE = 10 * 1024 * 1024; // 10MB
/**
* Sanitize an SVG buffer to prevent XXE, SSRF, and script injection.
* Throws if the SVG exceeds the maximum allowed size.
*/
export function sanitizeSvg(buffer: Buffer): Buffer {
if (buffer.length > MAX_SVG_SIZE) {
throw new Error(`SVG exceeds maximum size of ${MAX_SVG_SIZE / 1024 / 1024}MB`);
}
let svg = buffer.toString("utf-8");
// Remove DOCTYPE (XXE prevention, including internal subsets)
svg = svg.replace(/<!DOCTYPE[^>[]*(?:\[[^\]]*\])?>/gi, "");
// Remove XML processing instructions except <?xml version...?>
svg = svg.replace(/<\?(?!xml\s)[^?]*\?>/gi, "");
// Remove XInclude elements and namespace declarations
svg = svg.replace(/<[^>]*xi:include[^>]*\/?>/gi, "");
svg = svg.replace(/xmlns:xi\s*=\s*["'][^"']*["']/gi, "");
// Remove script tags
svg = svg.replace(/<script[\s\S]*?<\/script>/gi, "");
// Remove foreignObject elements (can embed arbitrary HTML)
svg = svg.replace(/<foreignObject[\s\S]*?<\/foreignObject>/gi, "");
svg = svg.replace(/<foreignObject[^>]*\/>/gi, "");
// Remove event handlers (onload, onclick, onerror, etc.)
svg = svg.replace(/\bon\w+\s*=/gi, "data-removed=");
// 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:,');
svg = svg.replace(/href\s*=\s*["']file:/gi, 'href="data:,');
// Block use elements referencing external resources
svg = svg.replace(/url\s*\(\s*["']?https?:\/\//gi, 'url("data:,');
svg = svg.replace(/url\s*\(\s*["']?file:/gi, 'url("data:,');
return Buffer.from(svg, "utf-8");
}
/**
* Check whether a buffer looks like SVG content.
* Examines the first 4KB for an <svg tag.
*/
export function isSvgBuffer(buffer: Buffer): boolean {
const head = buffer.subarray(0, 4096).toString("utf-8").trim();
return head.startsWith("<svg") || (head.startsWith("<?xml") && head.includes("<svg"));
}
+16 -2
View File
@@ -10,6 +10,7 @@ import { autoOrient } from "../lib/auto-orient.js";
import { validateImageBuffer } from "../lib/file-validation.js";
import { sanitizeFilename } from "../lib/filename.js";
import type { WorkerInput, WorkerOutput } from "../lib/image-worker.js";
import { sanitizeSvg } from "../lib/svg-sanitize.js";
import { getWorkerPool } from "../lib/worker-pool.js";
import { createWorkspace } from "../lib/workspace.js";
@@ -144,6 +145,18 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
// Sanitize SVG input to prevent XXE, SSRF, and script injection
const isSvg = validation.format === "svg";
if (isSvg) {
try {
fileBuffer = sanitizeSvg(fileBuffer);
} catch (err) {
return reply.status(400).send({
error: err instanceof Error ? err.message : "Invalid SVG",
});
}
}
// Parse and validate settings
let settings: T;
try {
@@ -179,6 +192,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
inputBuffer: fileBuffer,
settings,
filename,
inputFormat: validation.format,
};
const workerResult: WorkerOutput = await pool.run(workerInput);
result = {
@@ -192,12 +206,12 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
{ workerErr, toolId: config.toolId },
"Worker processing failed, falling back to main thread",
);
const processBuffer = await autoOrient(fileBuffer);
const processBuffer = isSvg ? fileBuffer : await autoOrient(fileBuffer);
result = await config.process(processBuffer, settings, filename);
}
} else {
// AI tools: always main thread (they use Python bridge)
const processBuffer = await autoOrient(fileBuffer);
const processBuffer = isSvg ? fileBuffer : await autoOrient(fileBuffer);
result = await config.process(processBuffer, settings, filename);
}
+3 -1
View File
@@ -3,6 +3,7 @@ import { convert } from "@stirling-image/image-engine";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { isSvgBuffer } from "../../lib/svg-sanitize.js";
import { createToolRoute } from "../tool-factory.js";
const FORMAT_CONTENT_TYPES: Record<string, string> = {
@@ -24,7 +25,8 @@ export function registerConvert(app: FastifyInstance) {
toolId: "convert",
settingsSchema,
process: async (inputBuffer, settings, filename) => {
const image = sharp(inputBuffer);
const sharpOpts = isSvgBuffer(inputBuffer) ? { density: 300 } : undefined;
const image = sharp(inputBuffer, sharpOpts);
const result = await convert(image, settings);
const buffer = await result.toBuffer();
+1 -33
View File
@@ -4,6 +4,7 @@ import { basename, join } from "node:path";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { sanitizeSvg } from "../../lib/svg-sanitize.js";
import { createWorkspace } from "../../lib/workspace.js";
const settingsSchema = z.object({
@@ -16,39 +17,6 @@ const settingsSchema = z.object({
outputFormat: z.enum(["png", "jpg", "webp"]).default("png"),
});
const MAX_SVG_SIZE = 10 * 1024 * 1024; // 10MB
function sanitizeSvg(buffer: Buffer): Buffer {
if (buffer.length > MAX_SVG_SIZE) {
throw new Error(`SVG exceeds maximum size of ${MAX_SVG_SIZE / 1024 / 1024}MB`);
}
let svg = buffer.toString("utf-8");
// Remove DOCTYPE (XXE prevention, including internal subsets)
svg = svg.replace(/<!DOCTYPE[^>[]*(?:\[[^\]]*\])?>/gi, "");
// Remove XML processing instructions except <?xml version...?>
svg = svg.replace(/<\?(?!xml\s)[^?]*\?>/gi, "");
// Remove XInclude elements and namespace declarations
svg = svg.replace(/<[^>]*xi:include[^>]*\/?>/gi, "");
svg = svg.replace(/xmlns:xi\s*=\s*["'][^"']*["']/gi, "");
// Remove script tags
svg = svg.replace(/<script[\s\S]*?<\/script>/gi, "");
// Remove foreignObject elements (can embed arbitrary HTML)
svg = svg.replace(/<foreignObject[\s\S]*?<\/foreignObject>/gi, "");
svg = svg.replace(/<foreignObject[^>]*\/>/gi, "");
// Remove event handlers (onload, onclick, onerror, etc.)
svg = svg.replace(/\bon\w+\s*=/gi, "data-removed=");
// 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:,');
svg = svg.replace(/href\s*=\s*["']file:/gi, 'href="data:,');
// Block use elements referencing external resources
svg = svg.replace(/url\s*\(\s*["']?https?:\/\//gi, 'url("data:,');
svg = svg.replace(/url\s*\(\s*["']?file:/gi, 'url("data:,');
return Buffer.from(svg, "utf-8");
}
/**
* SVG to raster conversion.
* Custom route since input is SVG (not validated as image by magic bytes).