fileName now take the upload code

This commit is contained in:
Anaïs Saraiva
2026-08-15 11:45:20 +02:00
parent bdb418f613
commit adf37d43a3
3 changed files with 13 additions and 5 deletions
+5 -3
View File
@@ -270,10 +270,12 @@ paths or stack traces.
(salted), never in plaintext. Successful entry mints a short-lived HMAC-signed (salted), never in plaintext. Successful entry mints a short-lived HMAC-signed
cookie, so access is granted by the server and cannot be forged. cookie, so access is granted by the server and cannot be forged.
- **Path safety.** Filesystem paths are built only from server-generated, - **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 only and is sanitized (basename, control/null bytes stripped) before being
stored or emitted in `Content-Disposition`. Path traversal, `../`, encoded stored; downloads are sent as `<upload-key>.<extension>`, so the basename in
traversal, null bytes, and symlink injection via user input are not possible. `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. Downloads additionally refuse symlinks (`lstat`) as defense in depth.
- **MIME / XSS.** Downloads are sent as `Content-Disposition: attachment` (browsers - **MIME / XSS.** Downloads are sent as `Content-Disposition: attachment` (browsers
download rather than render) with `X-Content-Type-Options: nosniff`, so a spoofed download rather than render) with `X-Content-Type-Options: nosniff`, so a spoofed
+2 -2
View File
@@ -2,7 +2,7 @@ import { lstat } from "node:fs/promises";
import { hasValidAuth, renderPasswordForm } from "../auth"; import { hasValidAuth, renderPasswordForm } from "../auth";
import { notFound } from "../http"; import { notFound } from "../http";
import { log } from "../log"; 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"; import { isExpired, type Storage } from "../storage";
export async function handleDownload( export async function handleDownload(
@@ -41,7 +41,7 @@ export async function handleDownload(
const headers = new Headers({ const headers = new Headers({
"content-type": meta.mimeType || "application/octet-stream", "content-type": meta.mimeType || "application/octet-stream",
"content-length": String(fileStat.size), "content-length": String(fileStat.size),
"content-disposition": contentDisposition(meta.originalName), "content-disposition": contentDisposition(downloadFileName(rawKey, meta.originalName)),
"cache-control": "private, no-store", "cache-control": "private, no-store",
"x-content-type-options": "nosniff", "x-content-type-options": "nosniff",
"referrer-policy": "no-referrer", "referrer-policy": "no-referrer",
+6
View File
@@ -59,6 +59,12 @@ export function contentDisposition(name: string): string {
return `attachment; filename="${ascii}"; filename*=UTF-8''${encoded}`; 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 { export function hashPassword(password: string): string {
const salt = randomBytes(16).toString("hex"); const salt = randomBytes(16).toString("hex");
const hash = scryptSync(password, salt, 64).toString("hex"); const hash = scryptSync(password, salt, 64).toString("hex");