fix: harden file serving

This commit is contained in:
Peter Steinberger
2026-01-26 20:05:03 +00:00
parent 8b56f0e68d
commit 5eee991913
5 changed files with 213 additions and 50 deletions

View File

@@ -10,7 +10,8 @@ import { resolvePinnedHostname } from "../infra/net/ssrf.js";
import { detectMime, extensionForMime } from "./mime.js";
const resolveMediaDir = () => path.join(resolveConfigDir(), "media");
const MAX_BYTES = 5 * 1024 * 1024; // 5MB default
export const MEDIA_MAX_BYTES = 5 * 1024 * 1024; // 5MB default
const MAX_BYTES = MEDIA_MAX_BYTES;
const DEFAULT_TTL_MS = 2 * 60 * 1000; // 2 minutes
/**
@@ -19,10 +20,9 @@ const DEFAULT_TTL_MS = 2 * 60 * 1000; // 2 minutes
* Keeps: alphanumeric, dots, hyphens, underscores, Unicode letters/numbers.
*/
function sanitizeFilename(name: string): string {
// Remove: < > : " / \ | ? * and control chars (U+0000-U+001F)
// oxlint-disable-next-line no-control-regex -- Intentionally matching control chars
const unsafe = /[<>:"/\\|?*\x00-\x1f]/g;
const sanitized = name.trim().replace(unsafe, "_").replace(/\s+/g, "_"); // Replace whitespace runs with underscore
const trimmed = name.trim();
if (!trimmed) return "";
const sanitized = trimmed.replace(/[^\p{L}\p{N}._-]+/gu, "_");
// Collapse multiple underscores, trim leading/trailing, limit length
return sanitized.replace(/_+/g, "_").replace(/^_|_$/g, "").slice(0, 60);
}
@@ -56,7 +56,7 @@ export function getMediaDir() {
export async function ensureMediaDir() {
const mediaDir = resolveMediaDir();
await fs.mkdir(mediaDir, { recursive: true });
await fs.mkdir(mediaDir, { recursive: true, mode: 0o700 });
return mediaDir;
}
@@ -123,7 +123,7 @@ async function downloadToFile(
let total = 0;
const sniffChunks: Buffer[] = [];
let sniffLen = 0;
const out = createWriteStream(dest);
const out = createWriteStream(dest, { mode: 0o600 });
res.on("data", (chunk) => {
total += chunk.length;
if (sniffLen < 16384) {
@@ -168,7 +168,7 @@ export async function saveMediaSource(
): Promise<SavedMedia> {
const baseDir = resolveMediaDir();
const dir = subdir ? path.join(baseDir, subdir) : baseDir;
await fs.mkdir(dir, { recursive: true });
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
await cleanOldMedia();
const baseId = crypto.randomUUID();
if (looksLikeUrl(source)) {
@@ -198,7 +198,7 @@ export async function saveMediaSource(
const ext = extensionForMime(mime) ?? path.extname(source);
const id = ext ? `${baseId}${ext}` : baseId;
const dest = path.join(dir, id);
await fs.writeFile(dest, buffer);
await fs.writeFile(dest, buffer, { mode: 0o600 });
return { id, path: dest, size: stat.size, contentType: mime };
}
@@ -213,7 +213,7 @@ export async function saveMediaBuffer(
throw new Error(`Media exceeds ${(maxBytes / (1024 * 1024)).toFixed(0)}MB limit`);
}
const dir = path.join(resolveMediaDir(), subdir);
await fs.mkdir(dir, { recursive: true });
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
const uuid = crypto.randomUUID();
const headerExt = extensionForMime(contentType?.split(";")[0]?.trim() ?? undefined);
const mime = await detectMime({ buffer, headerMime: contentType });
@@ -231,6 +231,6 @@ export async function saveMediaBuffer(
}
const dest = path.join(dir, id);
await fs.writeFile(dest, buffer);
await fs.writeFile(dest, buffer, { mode: 0o600 });
return { id, path: dest, size: buffer.byteLength, contentType: mime };
}