fix: resolve 14 security, correctness, and robustness issues found during QA sweep

Security fixes:
- Add auth + ownership check to thumbnail endpoint (was unauthenticated)
- Validate ExifTool fieldsToRemove against safe tag name pattern
- Add SVG sanitization to pipeline execute and batch endpoints
- Replace basename() with sanitizeFilename() in 16 tool routes
- Escape SQL LIKE wildcards in file search to prevent pattern injection
- Improve settings HTML tag validation pattern

Bug fixes:
- Skip autoOrient for SVG inputs in pipeline (prevents misinterpretation)
- Remove double-encode in compress targetSize (was degrading quality)
- Fix bg-effects alpha value from 255 to 1.0 (Sharp expects float)
- Guard download stream error handler against headers-already-sent race
- Use O_EXCL atomic file creation for install lock (fixes TOCTOU race)
- Truncate collage file array to template image count

UX fixes:
- Accept empty JSON bodies on POST endpoints (install/uninstall)
- Custom JSON content type parser that treats empty body as {}
This commit is contained in:
SnapOtter
2026-05-01 18:11:49 +08:00
parent 1a2288f99b
commit ff8dcf63c7
27 changed files with 107 additions and 61 deletions
+3 -2
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { join } from "node:path";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
import { z } from "zod";
@@ -8,6 +8,7 @@ import { readBarcodes } from "zxing-wasm/reader";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -93,7 +94,7 @@ export function registerBarcodeRead(app: FastifyInstance) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "image");
filename = sanitizeFilename(part.filename ?? "image");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
}
+4 -3
View File
@@ -1,9 +1,10 @@
import { randomUUID } from "node:crypto";
import { basename, extname } from "node:path";
import { extname } from "node:path";
import archiver from "archiver";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { formatZodErrors } from "../../lib/errors.js";
import { sanitizeFilename } from "../../lib/filename.js";
const settingsSchema = z.object({
pattern: z.string().min(1).max(1000).default("image-{{index}}"),
@@ -31,7 +32,7 @@ export function registerBulkRename(app: FastifyInstance) {
if (buf.length > 0) {
files.push({
buffer: buf,
filename: basename(part.filename ?? `file-${files.length}`),
filename: sanitizeFilename(part.filename ?? `file-${files.length}`),
});
}
} else if (part.fieldname === "settings") {
@@ -89,7 +90,7 @@ export function registerBulkRename(app: FastifyInstance) {
.replace(/\{\{padded\}\}/g, padded)
.replace(/\{\{original\}\}/g, files[i].filename.replace(ext, "")) + ext;
archive.append(files[i].buffer, { name: basename(newName) });
archive.append(files[i].buffer, { name: sanitizeFilename(newName) });
}
await archive.finalize();
+7 -2
View File
@@ -1,12 +1,13 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { join } from "node:path";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -435,7 +436,7 @@ export function registerCollage(app: FastifyInstance) {
if (buf.length > 0) {
files.push({
buffer: buf,
filename: basename(part.filename ?? `image-${files.length}`),
filename: sanitizeFilename(part.filename ?? `image-${files.length}`),
});
}
} else if (part.fieldname === "settings") {
@@ -485,6 +486,10 @@ export function registerCollage(app: FastifyInstance) {
return reply.status(400).send({ error: `Unknown template: ${settings.templateId}` });
}
if (files.length > template.imageCount) {
files.length = template.imageCount;
}
// Determine output canvas size
const BASE_SIZE = 2400;
const arMultiplier = getAspectMultiplier(settings.aspectRatio);
+2 -2
View File
@@ -1,6 +1,6 @@
import { basename } from "node:path";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { sanitizeFilename } from "../../lib/filename.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
/**
@@ -55,7 +55,7 @@ export function registerColorPalette(app: FastifyInstance) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "image");
filename = sanitizeFilename(part.filename ?? "image");
}
}
} catch (err) {
@@ -1,12 +1,13 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { join } from "node:path";
import { seamCarve } from "@snapotter/ai";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
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 { createWorkspace } from "../../lib/workspace.js";
@@ -41,7 +42,7 @@ export function registerContentAwareResize(app: FastifyInstance) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "image");
filename = sanitizeFilename(part.filename ?? "image");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
}
+4 -3
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { join } from "node:path";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
import { z } from "zod";
@@ -12,6 +12,7 @@ import {
writeMetadata,
} from "../../lib/exiftool.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
import { registerToolProcessFn } from "../tool-factory.js";
@@ -83,7 +84,7 @@ export function registerEditMetadata(app: FastifyInstance) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "image");
filename = sanitizeFilename(part.filename ?? "image");
}
}
} catch (err) {
@@ -124,7 +125,7 @@ export function registerEditMetadata(app: FastifyInstance) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "image");
filename = sanitizeFilename(part.filename ?? "image");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
}
+3 -3
View File
@@ -1,5 +1,4 @@
import { randomUUID } from "node:crypto";
import { basename, extname } from "node:path";
import archiver from "archiver";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
@@ -7,6 +6,7 @@ import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
const settingsSchema = z.object({}).passthrough();
@@ -39,7 +39,7 @@ export function registerFavicon(app: FastifyInstance) {
chunks.push(chunk);
}
const buffer = Buffer.concat(chunks);
const filename = basename(part.filename ?? `image-${uploadedFiles.length + 1}`);
const filename = sanitizeFilename(part.filename ?? `image-${uploadedFiles.length + 1}`);
uploadedFiles.push({ buffer, filename });
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
@@ -97,7 +97,7 @@ export function registerFavicon(app: FastifyInstance) {
for (const file of uploadedFiles) {
// Decode HEIC/HEIF if needed, then normalize EXIF orientation
const decoded = await autoOrient(await ensureSharpCompat(file.buffer));
const stem = basename(file.filename, extname(file.filename));
const stem = sanitizeFilename(file.filename).replace(/\.[^.]+$/, "");
// Single file: flat structure. Multiple files: per-image folders.
const prefix = isSingleFile ? "" : `${stem}/`;
+2 -2
View File
@@ -1,9 +1,9 @@
import { basename } from "node:path";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
const settingsSchema = z.object({
@@ -108,7 +108,7 @@ export function registerFindDuplicates(app: FastifyInstance) {
if (buf.length > 0) {
files.push({
buffer: buf,
filename: basename(part.filename ?? `image-${files.length}`),
filename: sanitizeFilename(part.filename ?? `image-${files.length}`),
originalSize: buf.length,
});
}
+3 -2
View File
@@ -1,12 +1,13 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { join } from "node:path";
import type { FastifyInstance } from "fastify";
import PDFDocument from "pdfkit";
import sharp from "sharp";
import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -109,7 +110,7 @@ export function registerImageToPdf(app: FastifyInstance) {
if (buf.length > 0) {
files.push({
buffer: buf,
filename: basename(part.filename ?? `image-${files.length}`),
filename: sanitizeFilename(part.filename ?? `image-${files.length}`),
});
}
} else if (part.fieldname === "settings") {
+2 -2
View File
@@ -1,7 +1,7 @@
import { basename } from "node:path";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
@@ -23,7 +23,7 @@ export function registerInfo(app: FastifyInstance) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "image");
filename = sanitizeFilename(part.filename ?? "image");
}
}
} catch (err) {
+2 -2
View File
@@ -1,5 +1,4 @@
import { randomUUID } from "node:crypto";
import { basename } from "node:path";
import { extractText } from "@snapotter/ai";
import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
@@ -7,6 +6,7 @@ import { z } from "zod";
import { formatZodErrors } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { createWorkspace } from "../../lib/workspace.js";
import { updateSingleFileProgress } from "../progress.js";
@@ -50,7 +50,7 @@ export function registerOcr(app: FastifyInstance) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "image");
filename = sanitizeFilename(part.filename ?? "image");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
+3 -2
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { join } from "node:path";
import { detectFaceLandmarks, removeBackground } from "@snapotter/ai";
import {
getBundleForTool,
@@ -15,6 +15,7 @@ import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js";
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 { createWorkspace, getWorkspacePath } from "../../lib/workspace.js";
@@ -154,7 +155,7 @@ export function registerPassportPhoto(app: FastifyInstance) {
const chunks: Buffer[] = [];
for await (const chunk of part.file) chunks.push(chunk);
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "image");
filename = sanitizeFilename(part.filename ?? "image");
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
}
@@ -237,7 +237,7 @@ export function registerRemoveBackground(app: FastifyInstance) {
const chunks: Buffer[] = [];
for await (const chunk of part.file) chunks.push(chunk);
bgImageBuffer = Buffer.concat(chunks);
bgFilename = part.filename ?? "background";
bgFilename = sanitizeFilename(part.filename ?? "background");
} else if (part.type === "field" && part.fieldname === "settings") {
settingsRaw = part.value as string;
}
+3 -2
View File
@@ -1,11 +1,12 @@
import { randomUUID } from "node:crypto";
import { basename, extname } from "node:path";
import { extname } from "node:path";
import archiver from "archiver";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
import { registerToolProcessFn } from "../tool-factory.js";
@@ -49,7 +50,7 @@ export function registerSplit(app: FastifyInstance) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "image");
filename = sanitizeFilename(part.filename ?? "image");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
}
+3 -2
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { join } from "node:path";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
@@ -8,6 +8,7 @@ import { env } from "../../config.js";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -58,7 +59,7 @@ export function registerStitch(app: FastifyInstance) {
if (buf.length > 0) {
files.push({
buffer: buf,
filename: basename(part.filename ?? `image-${files.length}`),
filename: sanitizeFilename(part.filename ?? `image-${files.length}`),
});
}
} else if (part.fieldname === "settings") {
+2 -2
View File
@@ -1,8 +1,8 @@
import { basename } from "node:path";
import { parseExif, parseGps, parseXmp, stripMetadata } from "@snapotter/image-engine";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { sanitizeFilename } from "../../lib/filename.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
@@ -110,7 +110,7 @@ export function registerStripMetadata(app: FastifyInstance) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "image");
filename = sanitizeFilename(part.filename ?? "image");
}
}
} catch (err) {
+3 -3
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { join } from "node:path";
import archiver from "archiver";
import type { FastifyInstance } from "fastify";
import PQueue from "p-queue";
@@ -89,7 +89,7 @@ async function convertSvg(
break;
}
const baseName = basename(filename).replace(/\.svg$/i, "");
const baseName = sanitizeFilename(filename).replace(/\.svg$/i, "");
return { buffer, filename: `${baseName}.${ext}`, ext };
}
@@ -331,7 +331,7 @@ export function registerSvgToRaster(app: FastifyInstance) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "output").replace(/\.svg$/i, "");
filename = sanitizeFilename(part.filename ?? "output").replace(/\.svg$/i, "");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
}
+3 -2
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { join } from "node:path";
import { vectorize as vtrace } from "@neplex/vectorizer";
import type { FastifyInstance } from "fastify";
import potrace from "potrace";
@@ -8,6 +8,7 @@ import sharp from "sharp";
import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -62,7 +63,7 @@ export function registerVectorize(app: FastifyInstance) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "output").replace(/\.[^.]+$/, "");
filename = sanitizeFilename(part.filename ?? "output").replace(/\.[^.]+$/, "");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
}
+2 -1
View File
@@ -3,6 +3,7 @@ import sharp from "sharp";
import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
const settingsSchema = z.object({
@@ -34,7 +35,7 @@ export function registerWatermarkImage(app: FastifyInstance) {
watermarkBuffer = buf;
} else {
mainBuffer = buf;
filename = part.filename ?? "image";
filename = sanitizeFilename(part.filename ?? "image");
}
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;