mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
Reference in New Issue
Block a user