ASVS V5.4.1 says that when your application serves a file, it must either validate or ignore any filename the user supplied, and it must set the filename itself in the Content-Disposition response header. It is a Level 2 requirement.
The filename can arrive in many places: a query string, a JSON field, a JSONP callback, or a path segment. Whatever the source, treat it as hostile.
Two ideas sit inside this requirement. The first is about input: if the user can influence the filename, you validate it or ignore it. The second is about output: your server states the filename in a header, so the browser does not infer one from the URL or the content.
Why it matters
A filename that flows straight into a response header can inject extra header lines, change the file extension the browser sees, or turn a harmless download into an inline page that runs script. An attacker who controls the name report.html on a download endpoint may get your domain to render attacker markup.
Setting the header yourself also stops browsers from guessing. Without an explicit disposition, some clients render the body inline.
Download endpoints are a favorite for attackers because they sit right between untrusted input and the browser. Here are the problems that show up in practice.
Header injection happens when a carriage return and line feed in the name ends the header and starts a new one. An attacker can then set cookies or add a second response body in older stacks.
Extension confusion happens when the name says one thing and the content type says another. A file named invoice.pdf that is really HTML, served inline from your domain, runs script in your origin. This is stored cross site scripting delivered as a download.
Path traversal happens when the same parameter is used to build a filesystem path, so ../../etc/passwd becomes a file read.
Reflected file download is the quieter cousin. A JSON or JSONP endpoint echoes attacker text, the URL ends in .bat or .cmd, and the browser offers to save it under that name. The victim then runs what looks like a trusted file from your site.
How to implement it
Look up files by an internal identifier, not by a name the user typed. Keep the display name in your database and send that.
from flask import Flask, send_file, abort
import re
app = Flask(__name__)
SAFE = re.compile(r"[^A-Za-z0-9._ ]")
@app.get("/files/<int:file_id>")
def download(file_id):
rec = db_get_file(file_id) # your lookup, enforces access control
if rec is None:
abort(404)
name = SAFE.sub("_", rec.display_name)[:100] or "download"
resp = send_file(rec.path, as_attachment=True, download_name=name)
resp.headers["X-Content-Type-Options"] = "nosniff"
return resp
The path comes from the record, never from the request. The name is reduced to a small safe alphabet and capped in length.
The simplest way to meet the requirement is to ignore user names completely. The client asks for a document by id. Your server decides the name from stored metadata, builds the header with your framework helper, and sets a fixed content type from an allowlist, not from the upload.
// Express: serve by id, set everything explicitly
app.get("/files/:id", requireAuth, async (req, res) => {
const rec = await files.findOwned(req.params.id, req.user.id);
if (!rec) return res.sendStatus(404);
const type = ALLOWED_TYPES.get(rec.ext) ?? "application/octet-stream";
res.set({
"Content-Type": type,
"Content-Disposition": contentDisposition(rec.displayName),
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "sandbox",
});
fs.createReadStream(rec.storagePath).pipe(res);
});
If your product needs the user to choose the name, for example a custom export title, validate it. Allow a short list of characters, cap the length, remove path separators, and force the extension from the format you generated. Store the result and treat it as your own data from then on.
For JSON and JSONP endpoints, never let a path or query value control the downloaded name. Return JSON with Content-Type: application/json, restrict JSONP callbacks to a strict identifier pattern, or drop JSONP and use CORS.
How to test it
Request the download with a hostile name, such as a.html%0d%0aSet-Cookie:x=1 in whatever parameter your API accepts. Confirm that the response has exactly one Content-Disposition header, no injected header, and a name you chose. Also confirm X-Content-Type-Options: nosniff is present.
Build a short checklist and run it for every endpoint that serves a file.
- Send a name that contains
%0d%0aand a second header. The response must contain one set of headers and no injected line. - Send
../../secretand an absolute path as the name or id. The server must return 400 or 404 and never read outside the storage folder. - Request a download of an HTML or SVG upload. The response must have
attachment, a safe content type andnosniff. - Open the saved file name in the browser and confirm it matches what the application chose, not the query string.
A dynamic scan can automate much of this by fuzzing parameters on download routes and flagging reflected values in headers.
Common mistakes
-
Echoing a
filenamequery parameter into the header. -
Stripping
..once instead of ignoring the path entirely. -
Using
inlinefor user uploaded content. -
Validating the name but opening the file by that same name on disk.
-
Treating the original upload name as safe because it was checked at upload time. The check at download time still matters when other code paths store names.
-
Setting the header only for some file types.
Try it yourself
Click through it at your own pace, or open it full screen.
Quick check
Is it enough to validate the filename and still use it to open the file?
No. Open files by internal id. The filename should only affect what the user sees in the header.