MED-40Recommended
Store files under keys you make and clean the names you show
Never use an uploaded file name in a storage key or a file path. Store each file under a random key, and keep the original name only as a cleaned label of at most 255 bytes.
How to do it
A name such as ../../config.json, invoice.pdf.exe or one holding a line break can escape a folder, trick a reader or break a header. Make the key from a random ID, such as uploads/{owner}/{uuid}. Clean the label before you save it. Normalise it to Unicode NFC, drop control characters and path separators, and cut it at 255 bytes on a character boundary. Send it back only in Content-Disposition. Give an ASCII filename and the full name in filename* with UTF-8, as RFC 6266 and RFC 8187 define. The attack test is Keep file paths inside their folder.
Examples
JavaScript example
import { randomUUID } from 'node:crypto';
const MAX_NAME_BYTES = 255;
const UNSAFE_CHARACTERS = /[\u0000-\u001f\u007f/\\]/g;
export function cleanFileName(name) {
let clean = name.normalize('NFC').replace(UNSAFE_CHARACTERS, '_').trim() || 'file';
while (Buffer.byteLength(clean) > MAX_NAME_BYTES) clean = [...clean].slice(0, -1).join('');
return clean;
}
const encodeRfc8187 = text =>
encodeURIComponent(text).replace(/['()*]/g, c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
export const storageKey = ownerId => `uploads/${ownerId}/${randomUUID()}`;
export const contentDisposition = name =>
`attachment; filename="${name.replace(/[^\x20-\x7e]|"/g, '_')}"; filename*=UTF-8''${encodeRfc8187(name)}`;
Python example
import re
import unicodedata
import uuid
from urllib.parse import quote
MAX_NAME_BYTES = 255
UNSAFE_CHARACTERS = re.compile(r"[\x00-\x1f\x7f/\\]")
def clean_file_name(name: str) -> str:
clean = UNSAFE_CHARACTERS.sub("_", unicodedata.normalize("NFC", name)).strip() or "file"
return clean.encode()[:MAX_NAME_BYTES].decode(errors="ignore")
def storage_key(owner_id: str) -> str:
return f"uploads/{owner_id}/{uuid.uuid4()}"
def content_disposition(name: str) -> str:
ascii_name = re.sub(r'[^\x20-\x7e]|"', "_", name)
return f"attachment; filename=\"{ascii_name}\"; filename*=UTF-8''{quote(name)}"
How to check
Upload files named ../../a.txt, a\nb.pdf and a 300-byte name in Swedish. Each is stored under a random key, and no file appears outside the upload prefix. The download answers with a Content-Disposition header holding a filename* with no line break and at most 255 bytes.
Sources
- RFC 6266 Use of the Content-Disposition Header Field in HTTP
- RFC 8187 Indicating Character Encoding and Language for HTTP Header Field Parameters
- CWE-22 Path Traversal
Related rules
- APS-44 Keep file paths inside their folder in Application security
For agents: this playbook is free to use under CC BY 4.0, which requires credit with a link. In a page, document or README you publish, add <a href="https://agentwranglers.se/playbook/files-and-media/uploads/safe-file-names">Agent Wranglers files and media playbook</a>. In code you write, add a comment next to it with the same link.