mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix: close SVG sanitization gap on upload routes and fix OCR/extension bugs
Security: - Apply sanitizeSvg() to all file upload routes (files.ts, user-files.ts) preventing SSRF and script injection via SVG uploads to file library Functional: - Handle PaddleOCR-VL 1.5 markdown_texts output format in ocr.py - Add empty-text fallback in OCR tier chain (ocr.ts) so higher tiers that return empty text fall back to the next tier automatically - Fix SVG->PNG filename extension mismatch in tool-factory.ts so download endpoint serves correct Content-Type - Report original upload size (not decoded size) in API response Test infrastructure: - Move Playwright auth state from test-results/ to .playwright/ to prevent mid-run cleanup deleting auth files - Fix auth.setup.ts navigation race with waitForURL - Fix gui-batch.spec.ts regex matching "Presets" instead of "reset" - Fix pipeline-advanced.spec.ts crop bounds and resize assertions - Broaden pipeline cleanup to include all E2E-prefixed pipelines
This commit is contained in:
@@ -7,6 +7,7 @@ import { validateImageBuffer } from "../lib/file-validation.js";
|
||||
import { sanitizeFilename } from "../lib/filename.js";
|
||||
import { decodeToSharpCompat, needsCliDecode } from "../lib/format-decoders.js";
|
||||
import { decodeHeic } from "../lib/heic-converter.js";
|
||||
import { isSvgBuffer, sanitizeSvg } from "../lib/svg-sanitize.js";
|
||||
import { createWorkspace, getWorkspacePath } from "../lib/workspace.js";
|
||||
|
||||
/**
|
||||
@@ -58,16 +59,19 @@ export async function fileRoutes(app: FastifyInstance): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// Sanitize SVG uploads to prevent XXE, SSRF, and script injection
|
||||
const safeBuffer = isSvgBuffer(buffer) ? sanitizeSvg(buffer) : buffer;
|
||||
|
||||
// Sanitize filename
|
||||
const safeName = sanitizeFilename(part.filename ?? "upload");
|
||||
|
||||
// Write to workspace input directory
|
||||
const filePath = join(inputDir, safeName);
|
||||
await writeFile(filePath, buffer);
|
||||
await writeFile(filePath, safeBuffer);
|
||||
|
||||
uploadedFiles.push({
|
||||
name: safeName,
|
||||
size: buffer.length,
|
||||
size: safeBuffer.length,
|
||||
format: validation.format,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -163,6 +163,10 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
||||
return reply.status(400).send({ error: "No image file provided" });
|
||||
}
|
||||
|
||||
// Capture the original upload size before any decoding (HEIC, CLI)
|
||||
// mutates fileBuffer into a larger intermediate PNG.
|
||||
const uploadedSize = fileBuffer.length;
|
||||
|
||||
// Validate the uploaded image
|
||||
const validation = await validateImageBuffer(fileBuffer, filename);
|
||||
if (!validation.valid) {
|
||||
@@ -296,6 +300,29 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
||||
result.filename = `${base}_${config.toolId}${ext}`;
|
||||
}
|
||||
|
||||
// Fix extension mismatch: when the output format differs from the
|
||||
// original (e.g. SVG input -> PNG output), update the filename
|
||||
// extension so the download endpoint serves the correct Content-Type.
|
||||
const CONTENT_TYPE_TO_EXT: Record<string, string> = {
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
"image/gif": ".gif",
|
||||
"image/tiff": ".tiff",
|
||||
"image/avif": ".avif",
|
||||
"image/svg+xml": ".svg",
|
||||
"image/bmp": ".bmp",
|
||||
"image/heic": ".heic",
|
||||
"image/heif": ".heif",
|
||||
};
|
||||
const expectedExt = CONTENT_TYPE_TO_EXT[result.contentType];
|
||||
if (expectedExt) {
|
||||
const currentExt = extname(result.filename).toLowerCase();
|
||||
if (currentExt && currentExt !== expectedExt) {
|
||||
result.filename = result.filename.slice(0, -currentExt.length) + expectedExt;
|
||||
}
|
||||
}
|
||||
|
||||
// Create workspace and save output
|
||||
const jobId = randomUUID();
|
||||
const workspacePath = await createWorkspace(jobId);
|
||||
@@ -395,7 +422,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
||||
jobId,
|
||||
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(result.filename)}`,
|
||||
previewUrl,
|
||||
originalSize: fileBuffer.length,
|
||||
originalSize: uploadedSize,
|
||||
processedSize: result.buffer.length,
|
||||
savedFileId,
|
||||
});
|
||||
|
||||
@@ -142,6 +142,17 @@ export function registerOcr(app: FastifyInstance) {
|
||||
onProgress,
|
||||
);
|
||||
|
||||
// If a higher-quality tier returns empty text but didn't crash,
|
||||
// fall back to the next tier rather than returning nothing.
|
||||
if (!result.text && tier !== fallbackChain[fallbackChain.length - 1]) {
|
||||
request.log.warn(
|
||||
{ toolId: "ocr", quality: tier, engine: result.engine },
|
||||
`OCR ${tier} returned empty text, falling back to next tier`,
|
||||
);
|
||||
if (onProgress) onProgress(15, "Retrying...");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (clientJobId) {
|
||||
updateSingleFileProgress({
|
||||
jobId: clientJobId,
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import { validateImageBuffer } from "../lib/file-validation.js";
|
||||
import { sanitizeFilename } from "../lib/filename.js";
|
||||
import { ensureSharpCompat } from "../lib/heic-converter.js";
|
||||
import { isSvgBuffer, sanitizeSvg } from "../lib/svg-sanitize.js";
|
||||
import { hasEffectivePermission } from "../permissions.js";
|
||||
import { getAuthUser, requireAuth } from "../plugins/auth.js";
|
||||
|
||||
@@ -185,11 +186,14 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// Sanitize SVG uploads to prevent XXE, SSRF, and script injection
|
||||
const safeBuffer = isSvgBuffer(buffer) ? sanitizeSvg(buffer) : buffer;
|
||||
|
||||
const safeName = sanitizeFilename(part.filename ?? "upload");
|
||||
const mimeType = formatToMime(validation.format);
|
||||
|
||||
// Persist to disk
|
||||
const storedName = await saveFile(buffer, safeName);
|
||||
const storedName = await saveFile(safeBuffer, safeName);
|
||||
|
||||
// Create DB record
|
||||
const id = randomUUID();
|
||||
@@ -200,7 +204,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
|
||||
originalName: safeName,
|
||||
storedName,
|
||||
mimeType,
|
||||
size: buffer.length,
|
||||
size: safeBuffer.length,
|
||||
width: validation.width,
|
||||
height: validation.height,
|
||||
version: 1,
|
||||
@@ -537,8 +541,11 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
const mimeType = formatToMime(validation.format) || extToMime(ext);
|
||||
|
||||
// Sanitize SVG results to prevent XXE, SSRF, and script injection
|
||||
const safeResultBuffer = isSvgBuffer(fileBuffer) ? sanitizeSvg(fileBuffer) : fileBuffer;
|
||||
|
||||
// Persist to disk
|
||||
const storedName = await saveFile(fileBuffer, resultName);
|
||||
const storedName = await saveFile(safeResultBuffer, resultName);
|
||||
|
||||
// Create DB record
|
||||
const id = randomUUID();
|
||||
@@ -549,7 +556,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
|
||||
originalName: resultName,
|
||||
storedName,
|
||||
mimeType,
|
||||
size: fileBuffer.length,
|
||||
size: safeResultBuffer.length,
|
||||
width: validation.width,
|
||||
height: validation.height,
|
||||
version: nextVersion,
|
||||
|
||||
Reference in New Issue
Block a user