MED-46Required
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. The bucket itself stays closed, as Keep storage buckets private says.
Examples
JavaScript 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 });
}
Python example
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.
Sources
Related rules
- API-41 Keep secrets out of addresses in API design
- DAT-69 Keep each tenant's files under its own prefix in Data and databases
- EML-61 Avoid risky attachments in Email
- MED-3 Serve user files from their own domain in Files and media
- MED-20 Sign CDN access to private media for the whole stream in Files and media
- API-40 Authorise every object in API design
- APS-63 Keep storage buckets private in Application security
- MED-51 Let large downloads resume with range requests in Files and media
- MED-38 Give every share link a random token and an expiry in Files and media
- MED-30 Stop other sites from serving your files in Files and media
- MED-23 Build documents in a background job in Files and media
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/serving-files/signed-downloads">Agent Wranglers files and media playbook</a>. In code you write, add a comment next to it with the same link.