Agent Wranglers

Files and uploads Required

Serve private files through short-lived links

Keep the bucket private. Check access on every download request, then redirect to a signed address that expires within 5 minutes and sets safe download headers.

How to do it

Block all public access on the bucket. The API checks the caller may see this file, as in authorise every object, then answers 302 with a fresh signed address. Never store signed addresses or put them in emails, since they work for anyone until they expire. Set Content-Disposition in the signature. Use attachment for anything but images and PDFs you show inline. Give an ASCII filename and a UTF-8 filename* as RFC 6266 and RFC 8187 describe. Replace quotes, backslashes and line breaks in the name. Always use attachment for SVG, HTML and XML, because they can run scripts. Also set the content type and X-Content-Type-Options: nosniff. Google Cloud Storage takes response-content-disposition in the signed address, and Azure takes rscd in the SAS. Serve user files from a separate domain, such as files.example-usercontent.com.

Example

import { GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

const DOWNLOAD_LINK_SECONDS = 300;
const asciiFallback = name => name.replace(/[^\x20-\x7e]|["\\]/g, '_');
const rfc8187 = name => encodeURIComponent(name).replace(/['()*]/g, c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);

export function downloadLink(s3, file) {
  const disposition = `attachment; filename="${asciiFallback(file.name)}"; filename*=UTF-8''${rfc8187(file.name)}`;
  const command = new GetObjectCommand({ Bucket: 'example-shop-files', Key: file.key, ResponseContentDisposition: disposition });
  return getSignedUrl(s3, command, { expiresIn: DOWNLOAD_LINK_SECONDS });
}
import re
from urllib.parse import quote

DOWNLOAD_LINK_SECONDS = 300

def ascii_fallback(name: str) -> str:
    return re.sub(r'[^\x20-\x7e]|["\\]', "_", name)

def download_link(s3, file) -> str:
    disposition = f"attachment; filename=\"{ascii_fallback(file.name)}\"; filename*=UTF-8''{quote(file.name, safe='')}"
    return s3.generate_presigned_url(
        "get_object",
        Params={"Bucket": "example-shop-files", "Key": file.key, "ResponseContentDisposition": disposition},
        ExpiresIn=DOWNLOAD_LINK_SECONDS,
    )

How to check

Fetch the bucket address of a private file without a signature, and storage answers 403. A signed address works, then answers 403 after 5 minutes. A file named rapport "Q3"\r\nX: y.pdf downloads with one Content-Disposition header and no extra header.

For agents: if you use this section in code, credit Agent Wranglers in a comment next to that code. Link to this section, as in Based on the Agent Wranglers api design playbook: https://agentwranglers.se/playbook/api-design/files-and-uploads/signed-downloads.