Files
SnapOtter/apps/api/src/lib/filename.ts
T

63 lines
1.7 KiB
TypeScript

import { basename } from "node:path";
const SAFE_IMAGE_EXTENSIONS = new Set([
".jpg",
".jpeg",
".png",
".webp",
".gif",
".bmp",
".tiff",
".tif",
".avif",
".svg",
".pdf",
]);
/**
* Sanitize a filename to prevent path traversal and double-extension attacks.
*
* 1. Strips directory separators (basename only).
* 2. Removes ".." sequences and null bytes.
* 3. Truncates after the first recognised image extension so that
* "photo.png.php" becomes "photo.png".
*/
export function sanitizeFilename(raw: string): string {
let name = basename(raw);
name = name.replace(/\.\./g, "");
name = name.replace(/\0/g, "");
if (!name || name === "." || name === "..") {
name = "upload";
}
// Guard against double-extension attacks (e.g. "image.png.php").
// Walk the dot-separated parts and truncate after the first safe image extension.
const dotIndex = name.indexOf(".");
if (dotIndex !== -1) {
const parts = name.split(".");
for (let i = 1; i < parts.length; i++) {
const ext = `.${parts[i].toLowerCase()}`;
if (SAFE_IMAGE_EXTENSIONS.has(ext)) {
// Keep everything up to and including this extension, drop the rest
name = parts.slice(0, i + 1).join(".");
break;
}
}
}
// Truncate to filesystem-safe length (255 byte NAME_MAX minus margin for _toolId suffix)
const MAX_NAME_BYTES = 200;
const enc = new TextEncoder();
if (enc.encode(name).length > MAX_NAME_BYTES) {
const dotIdx = name.lastIndexOf(".");
const ext = dotIdx > 0 ? name.slice(dotIdx) : "";
let base = dotIdx > 0 ? name.slice(0, dotIdx) : name;
while (enc.encode(base + ext).length > MAX_NAME_BYTES) {
base = base.slice(0, -1);
}
name = base + ext;
}
return name;
}