diff --git a/README.md b/README.md index 5cb0c5c..2959af3 100644 --- a/README.md +++ b/README.md @@ -270,10 +270,12 @@ paths or stack traces. (salted), never in plaintext. Successful entry mints a short-lived HMAC-signed cookie, so access is granted by the server and cannot be forged. - **Path safety.** Filesystem paths are built only from server-generated, - regex-validated ids (`^[a-z0-9]{12}$`). The original filename is metadata + regex-validated ids (`^[a-z0-9]{8}$`). The original filename is metadata only and is sanitized (basename, control/null bytes stripped) before being - stored or emitted in `Content-Disposition`. Path traversal, `../`, encoded - traversal, null bytes, and symlink injection via user input are not possible. + stored; downloads are sent as `.`, so the basename in + `Content-Disposition` is always the server-generated key. Path traversal, + `../`, encoded traversal, null bytes, and symlink injection via user input are + not possible. Downloads additionally refuse symlinks (`lstat`) as defense in depth. - **MIME / XSS.** Downloads are sent as `Content-Disposition: attachment` (browsers download rather than render) with `X-Content-Type-Options: nosniff`, so a spoofed diff --git a/src/routes/download.ts b/src/routes/download.ts index df20d08..e8b9bfe 100644 --- a/src/routes/download.ts +++ b/src/routes/download.ts @@ -2,7 +2,7 @@ import { lstat } from "node:fs/promises"; import { hasValidAuth, renderPasswordForm } from "../auth"; import { notFound } from "../http"; import { log } from "../log"; -import { contentDisposition, fileIdFromKey, KEY_PATTERN, secretMatches } from "../security"; +import { contentDisposition, downloadFileName, fileIdFromKey, KEY_PATTERN, secretMatches } from "../security"; import { isExpired, type Storage } from "../storage"; export async function handleDownload( @@ -41,7 +41,7 @@ export async function handleDownload( const headers = new Headers({ "content-type": meta.mimeType || "application/octet-stream", "content-length": String(fileStat.size), - "content-disposition": contentDisposition(meta.originalName), + "content-disposition": contentDisposition(downloadFileName(rawKey, meta.originalName)), "cache-control": "private, no-store", "x-content-type-options": "nosniff", "referrer-policy": "no-referrer", diff --git a/src/security.ts b/src/security.ts index dfe5e00..de4e240 100644 --- a/src/security.ts +++ b/src/security.ts @@ -59,6 +59,12 @@ export function contentDisposition(name: string): string { return `attachment; filename="${ascii}"; filename*=UTF-8''${encoded}`; } +export function downloadFileName(key: string, originalName: string): string { + const dot = originalName.lastIndexOf("."); + const ext = dot > 0 && dot < originalName.length - 1 ? originalName.slice(dot + 1) : ""; + return ext ? `${key}.${ext}` : key; +} + export function hashPassword(password: string): string { const salt = randomBytes(16).toString("hex"); const hash = scryptSync(password, salt, 64).toString("hex");