Agent Wranglers

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

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.