ASVS 5.0 V5.3.1 (level 1) requires that files uploaded by users, or generated from untrusted input, and stored in a public folder, are not executed as server side program code when someone requests them directly over HTTP.
The classic failure is an upload form that saves avatar.php into /var/www/html/uploads/. The attacker requests that URL, the web server hands it to the PHP interpreter, and they now run code on your server.
Why it matters
This is one of the oldest paths to full server compromise, and it still happens. Upload handlers that check only that "a file arrived" and then save it under the web root turn a feature into a remote code execution hole. See CWE-434.
It is not limited to PHP. Any handler the server maps by extension can execute: .jsp, .aspx, .cgi, .pl, server side includes in .shtml, and configuration overrides such as .htaccess. And files that do not run on the server can still cause trouble in the browser, for example an uploaded HTML or SVG file that executes script in your origin. That is outside this requirement's exact wording, but the same storage and delivery choices fix both.
How to implement it
Break the link between "user supplied" and "executable" in three ways, and use more than one.
1. Store outside the web root. The best storage is not served by the web server at all. Use private object storage, or a directory the web server cannot map. Serve through an application route that streams the file with a fixed content type.
app.get('/files/:id', requireAuth, async (req, res) => {
const meta = await db.files.get(req.params.id); // lookup by random id
if (!meta || !canRead(req.user, meta)) return res.sendStatus(404);
res.set({
'Content-Type': meta.safeType, // your own value
'Content-Disposition': 'attachment',
'X-Content-Type-Options': 'nosniff',
});
fs.createReadStream(path.join(UPLOAD_DIR, meta.storageName)).pipe(res);
});
2. Turn off execution where files must be served directly. For a static uploads directory, tell the server to treat everything as plain files:
location /uploads/ {
alias /srv/uploads/;
types { } # no script mappings
default_type application/octet-stream;
add_header X-Content-Type-Options nosniff;
}
For Apache, remove handlers and block overrides in that directory with RemoveHandler .php .phtml and AllowOverride None.
3. Do not keep the user supplied name or extension. Generate a random name and set the extension from the validated type (see V5.2.2). Serve user content from a separate domain so scripts in it have no access to your cookies.
Delivery headers and a worked checklist
Where a file can be requested directly, the response headers matter as much as the storage location. Set these on every user content response:
Content-Type: image/webp
Content-Disposition: attachment; filename="download"
X-Content-Type-Options: nosniff
Content-Security-Policy: default-src 'none'; sandbox
Cache-Control: private, max-age=0
nosniff stops browsers from guessing a script type for a file labeled as an image. The restrictive policy with sandbox limits what a document can do even if a browser renders it. For inline images that you really want shown in a page, serve only the re encoded output of your own pipeline, and use the inline disposition only for types you have verified.
Use this checklist when you review any upload feature:
- Where do files land, and can the web server map that path to a handler?
- Is the stored name random, with the extension chosen from your allowlist?
- Does the directory allow per directory configuration files? Turn that off.
- Do the storage permissions allow execute? Remove them, and mount upload volumes with
noexecwhere you control the host. - Does the process that writes files have rights to write anywhere except the upload location?
- Is the content served from a domain separate from your login cookies?
Containers help here. Run the web process with a read only root filesystem, and mount the upload path as the only writable volume with noexec,nosuid. Then even a mistake in the application cannot place runnable code beside your own.
How to test it
Try to run something you uploaded.
- Upload a harmless script that prints a marker, for example a
.phpfile containingecho "EXEC_OK";, renamed several ways:a.php,a.PHP,a.php.png,a.phtml,a.php%00.png. - Request each stored URL. The body must be the raw file or a 404, never the text
EXEC_OKproduced by running the code. - Upload an
.htaccessorweb.configfile and check it is rejected or ignored. - Upload an HTML file containing a script tag and request it. It should download, not render, or be served from a separate domain.
- List the upload directory and confirm names are random and no execute bits are set.
Include steps 1 and 2 as an automated test so a server config change cannot undo the control.
Common mistakes
- Saving uploads under the web root with the original name.
- Blocking
.phpwith a blocklist and missing.phtml,.php5or mixed case. - Allowing
.htaccessorweb.configuploads. - Relying on content type checks while the server maps handlers by extension.
- Serving user files from the main domain, so a hostile HTML file reads your users' sessions.
- Running the web server as a user that can write to the code directory.
Quick check
What is the safest place to store uploads?
Outside the web root or in private object storage, served through a route that sets a fixed content type and a download disposition.
Why is a blocklist of script extensions weak?
Servers map many extensions to handlers, with variants in case and format. An allowlist plus no execution in the upload directory is far stronger.
Sources
- OWASP Application Security Verification Standard, requirement V5.3.1 (level 1)
- OWASP File Upload Cheat Sheet