Unrestricted file upload
CWE-434CWE-400OWASP A04:2021Updated September 4, 20265 min read
An upload function that accepts any file type and stores the result inside the web root can give an attacker the ability to execute code on your server. Checking the filename or the declared content type is insufficient, because the user controls both. What counts is where the file lands and how it is served back.
An upload function is one of the few places where you let a user put a file on your server. Everything then depends on two questions: what exactly has been stored, and what does the server do with it when someone requests it. The usual checks on name and type are not enough; a handful of other measures are what make the difference.
What is an unrestricted file upload?
We speak of an unrestricted file upload when an application accepts files without adequately checking what is supplied, and then stores those files in a way that lets the web server process them. The severity rarely lies in the uploading itself; it lies in the combination with the storage location and how the file is later served back.
That distinction matters, because it determines where the fix belongs. A PHP file sitting on a disk the web server does nothing with is a harmless text file. That same file in a directory the web server serves is a remote control for your server.
Think of a mailroom that accepts parcels. The problem is not that a parcel arrives; the problem is that it is forwarded unopened to a room where its contents take effect automatically. The check belongs before the parcel reaches that place, and the place itself should be chosen so that nothing can take effect at all.
How does a file upload go wrong?
Vulnerable:
const multer = require('multer');
// Stores under the name the user supplied, inside the web root
const upload = multer({ dest: './public/uploads' });
app.post('/profile/photo', upload.single('file'), (req, res) => {
const target = path.join('./public/uploads', req.file.originalname);
if (req.file.mimetype.startsWith('image/')) { // determined by the client
fs.renameSync(req.file.path, target);
return res.send('Uploaded to /uploads/' + req.file.originalname);
}
res.status(400).send('Images only');
});
Three mistakes here reinforce one another. The check uses mimetype, a value the client sends and can therefore choose freely. The filename comes unchanged from the user. And the result lands in public/, where the web server serves it. An attacker sends:
POST /profile/photo HTTP/1.1
Content-Type: multipart/form-data; boundary=x
--x
Content-Disposition: form-data; name="file"; filename="shell.php"
Content-Type: image/png
<?php system($_GET['c']); ?>
--x--
The declared type is image/png, so the check lets it through. The file lands at /uploads/shell.php, and a request to https://portal.example/uploads/shell.php?c=whoami executes commands on your server. From profile photo to full control in one request.
Safe:
const ALLOWED = new Map([
['image/jpeg', 'jpg'],
['image/png', 'png'],
['application/pdf', 'pdf'],
]);
const upload = multer({
dest: '/var/storage/temp', // outside the web root
limits: { fileSize: 5 * 1024 * 1024, files: 1 },
});
app.post('/profile/photo', upload.single('file'), async (req, res) => {
// Determine the type from the actual contents, not from what the client claims
const type = await detectTypeFromContents(req.file.path);
const extension = ALLOWED.get(type?.mime);
if (!extension) {
await fs.promises.unlink(req.file.path);
return res.status(400).send('File type not permitted');
}
// The user's name is discarded entirely
const name = `${randomUUID()}.${extension}`;
await fs.promises.rename(req.file.path, `/var/storage/uploads/${name}`);
await files.register({ name, owner: req.user.id, type: type.mime });
res.json({ id: name });
});
// Serve through a route that authorises and executes nothing
app.get('/file/:id', async (req, res) => {
const file = await files.find(req.params.id);
if (!file || !maySee(req.user, file)) return res.sendStatus(404);
res.set('Content-Type', file.type);
res.set('X-Content-Type-Options', 'nosniff');
res.set('Content-Disposition', 'attachment');
res.set('Content-Security-Policy', "default-src 'none'; sandbox");
res.sendFile(`/var/storage/uploads/${file.name}`);
});
The defence here consists of layers that each break the attack on their own. The type is determined from the file’s actual contents. The user’s name is fully replaced by a random value, which immediately rules out path traversal and overwriting. Storage lies outside the web root, so the web server cannot execute the file. And when serving, access is authorised, sniffing is disabled and the file is offered as a download.
Content-Type in an upload are fully under the attacker’s control. The only reliable sources are the actual contents of the file and the place where you put it.What is the impact of an unrestricted file upload?
The severity is high to critical, which makes this one of the heavier findings in a web application. The reason is the direct route to code execution on the server.
If an attacker manages to upload an executable file to a place the web server processes, the outcome is full control over the application server: reading the database, reaching configuration files with credentials, and a base for further exploration of the internal network. From that point the question is no longer what can happen to the application, but what can happen to the infrastructure.
Even without code execution, real consequences remain. A file served back as HTML yields cross-site scripting on your own domain. An unlimited file size makes it possible to fill your storage. A filename with path characters can overwrite existing files. And your platform can be abused to distribute malware, with your domain name supplying the credibility.
How do you detect an unrestricted file upload?
A tester starts by establishing which checks exist: is a file refused based on the extension, on the declared content type, or on the contents? That is probed by offering an innocuous image with an altered name, and conversely an executable file with an image type.
Then it turns on where the file ends up and whether it is reachable. Can the URL be discovered or guessed? Is the original name preserved? Can path characters in the name write outside the target directory? Is the file returned with a content type derived from the name? The classic detours are tested too: double extensions, capitalisation variants, extensions the server does recognise but the check list does not, and files that are simultaneously a valid image and valid code. AssistSec explicitly assesses the serving route, because a strict upload check is worth little when the files are served from the same domain and without authorisation.
How do you prevent an unrestricted file upload?
- Work with an allowed list of file types and determine the type from the actual contents, not from the name or header.
- Replace the user-supplied filename entirely with a value you generate yourself.
- Store uploads outside the web root, or in object storage with no execution capability.
- Serve files through a route that authorises, rather than exposing the directory directly.
- Send
X-Content-Type-Options: nosniffandContent-Disposition: attachmentwhen serving. - Serve user-supplied files from a separate domain where possible.
- Limit file size, the number of files and the total storage volume per user.
- Re-process images (by resizing them, for instance), which removes embedded code.
- Disable script execution in the upload directory as an extra layer on top of storing outside the web root.
- Scan uploads with a virus scanner as a supplement, not as a foundation.
Sources
Frequently asked questions
Is checking the extension not enough?
No, for two reasons. The user controls the filename, so it says nothing about the contents. And depending on the server configuration, names such as file.php.jpg or file.php%00.jpg can still be executed as code. Work with an allowed list and check the actual contents.
Can I trust the content type from the browser?
No. The Content-Type header in an upload is sent by the client and fully under the attacker's control. They can offer an executable file declared as image/png. Determine the type yourself on the server based on the actual contents.
Why does storing outside the web root matter so much?
Because a file not reachable through a URL cannot be executed by the web server either. Even if an attacker manages to upload a script file, they cannot invoke it. Serve files through a route that reads and forwards them, rather than exposing the directory.
Does a virus scanner help?
As an extra layer, yes, but it is not a foundation. A scanner recognises known malicious files and misses targeted or new variants. It also does not solve the core problem: a perfectly clean PHP file is not a virus, but it is fatal if the server executes it.
Related articles
- VulnerabilitiesCWE-434A04:2021Uploading malicious files is possibleAn upload function without content checking turns your platform into a malware carrier, with your domain name providing the credibility.
- VulnerabilitiesCWE-430A05:2021MIME sniffing not disabledWithout X-Content-Type-Options the browser may guess what a file is. Learn how an uploaded image can end up running as script.
- VulnerabilitiesCWE-22A01:2021Path traversal (directory traversal)Path traversal (directory traversal) explained: how attackers use ../ to escape the intended folder and read sensitive server files, and how to prevent it.
- VulnerabilitiesCWE-94A03:2021Remote code execution (RCE)Remote code execution (RCE) explained: how attackers run their own commands or code on your server through unvalidated input, and how to prevent it.
- VulnerabilitiesCWE-79A03:2021Cross-site scripting through file uploadAn uploaded SVG or HTML served from your own domain runs script within your origin. Learn how to close that off.