mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 16:41:36 +00:00
fix: harden file serving
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user