{"course":{"slug":"building-apps-with-ai","title":"Building Apps with AI","url":"https://addaly.com/learn/building-apps-with-ai","level":"beginner","track":"build"},"lesson":{"slug":"uploads-that-run","title":"Uploads that run","url":"https://addaly.com/learn/building-apps-with-ai/uploads-that-run","position":75,"of":87,"minutes":8,"answer":"A file's extension and declared type are claims the uploader made, so the server reads the first bytes to decide what it is, serves it from a different origin.","keyIdea":"A file's extension and declared type are claims the uploader made, so the server reads the first bytes to decide what it is, serves it from a different origin, and never lets the uploader choose the name on disk."},"body":{"format":"markdown","text":"## The file is not what it says\n\nAn upload arrives with a name, an extension, and a declared content type, and the uploader chose all three. `invoice.pdf` can contain a Windows executable; `photo.jpg` can be an HTML page; `logo.svg` can be a document with script in it. The uploads lesson in the data block set the size and type limits. This lesson is about what a limit has to check, and what happens to the file once it is stored.\n\n## Read the bytes\n\nFile formats begin with a signature. A JPEG starts with `FF D8 FF`, a PNG with `89 50 4E 47`, a PDF with `%PDF-`. A library such as `file-type` reads the first few bytes and reports what the file actually is, regardless of its name:\n\n```typescript\nimport { fileTypeFromBuffer } from \"file-type\";\nconst kind = await fileTypeFromBuffer(buffer);\nif (!kind || ![\"image/jpeg\", \"image/png\", \"image/webp\"].includes(kind.mime)) {\n  return new Response(\"Unsupported file type\", { status: 415 });\n}\n```\n\nAn allowlist of what you accept, decided by content. Everything else refused, including the types you did not think about. If the feature is avatars, the list is three image formats and nothing else; the day someone needs a PDF is the day the list grows by one, deliberately.\n\n## SVG is a document\n\nSVG is the one image format that is also a program. It is XML, it can contain script and external references, and a browser opening an SVG file directly treats it as a page. Served from your domain, that page runs on your origin, which is the question above: an avatar that steals sessions.\n\nEither refuse SVG for user uploads, the common choice, or sanitise it with a tool built for it, and serve it, like everything else uploaded, from somewhere that is not your app's origin.\n\n## Serve from somewhere else\n\nUploaded files go to object storage, and object storage gives them an address on its own domain: `pub-a1b2.r2.dev/...`, or a bucket domain you configure. Script in a file served from that domain runs on that domain, where there is no session cookie and nothing to steal. This single arrangement neutralises most upload-borne XSS whatever the file contains, which is why the uploads lesson insisted on it before security came up.\n\nTwo headers complete it. `Content-Type` set by your code from the detected type, not from the upload. And `Content-Disposition: attachment` on anything that is not an image meant to display inline, so that the browser downloads rather than renders it.\n\n## The name on disk\n\nThe uploader's filename is used for one thing: showing it back to them. The key in the bucket is generated by the server: a random id under the user's own prefix, as the earlier lesson showed. A filename used as a path allows `../../.env` and similar; a filename used as a key allows overwriting another user's file by guessing its name. Neither is possible when the server names everything.\n\n## Processing is an attack surface\n\nEvery tool that opens an uploaded file is code parsing hostile input. Image libraries have a long record of vulnerabilities triggered by crafted files; ImageMagick's history is the famous one. Three habits reduce the exposure. Keep the library current: `npm audit` and the dependency lesson apply. Process in a separate function or service, so that a compromise of the resizer is a compromise of a box with no database access. And set limits before parsing: a maximum pixel dimension, because a 1 KB PNG can declare itself to be 100,000 by 100,000 pixels and ask the resizer for 40 GB of memory.\n\nZip files are the same idea: a small archive that expands to terabytes. Cap the expanded size and the file count, or refuse archives.\n\n## The size limit that matters\n\nThe browser-side check on size is a courtesy. The server-side check has to happen before the whole body is read (through the signed-URL upload's declared length, or the host's body limit) because a route that reads a 2 GB body to discover it is too large has already spent the memory and the time.\n\n## Two minutes per feature\n\nFor every upload feature: send a file with a wrong extension and a right signature, and vice versa. Send an SVG. Send a file named with `../`. Open the stored file's URL directly and look at the domain in the address bar and the headers in the Network tab. If the domain is your app's and the type came from the upload, the finding is written before the page has finished loading."},"check":{"question":"An avatar upload accepts image/svg+xml, checks the extension, stores the file, and serves it from /uploads/ on the app's own domain. What can a user do with an SVG that contains script?","options":[{"text":"Nothing, because SVG files are images and the browser renders them as pixels rather than as a document, so any script inside is ignored the same way it would be in a JPEG.","correct":false,"tempting":"An SVG is an XML document that may contain script; opened directly in a tab it is a page, not a picture."},{"text":"Overwrite another user's avatar by choosing the same filename.","correct":false,"tempting":"A real hole when the uploader names the file, but a different one; the question is about what the SVG's content does when served."},{"text":"Crash the image resizer, since resizers cannot parse SVG.","correct":false,"tempting":"Most resizers handle or reject SVG cleanly; the harm is in serving it, not in processing it."},{"text":"Anyone who opens the avatar's URL directly runs the script on the app's origin, with their session, which is cross-site scripting delivered by upload.","correct":true}]},"prev":{"slug":"rate-limits-and-the-sign-up-flood","title":"Rate limits, and the sign-up flood","url":"https://addaly.com/learn/building-apps-with-ai/rate-limits-and-the-sign-up-flood"},"next":{"slug":"the-admin-page-nobody-protected","title":"The admin page nobody protected","url":"https://addaly.com/learn/building-apps-with-ai/the-admin-page-nobody-protected"},"authorship":"Founder and series editor: Dr Mustafa Mun. Written by AI models to a written standard. Exam keys checked blind by a second model: 3,665 of 3,666 agreed. Academic review invited.","review":{"status":"Draft, open for academic review"},"cite":"Addaly (2026) 'Uploads that run', in Building Apps with AI. Edited by M. Mun. Addaly. Available at: https://addaly.com/learn/building-apps-with-ai/uploads-that-run (Accessed: 13 October 2026).","licence":{"name":"CC BY-NC-SA 4.0","url":"https://creativecommons.org/licenses/by-nc-sa/4.0/"},"reader":{"schema":1,"title":"Uploads that run","courseTitle":"Building Apps with AI","module":{"index":8,"slug":"the-holes-ai-code-has-by-default","title":"The security holes AI code has by default"},"answer":null,"keyIdea":"A file’s extension and declared type are claims the uploader made, so the server reads the first bytes to decide what it is, serves it from a different origin, and never lets the uploader choose the name on disk.","hero":{"kind":"plate","image":null,"alt":null,"caption":null},"blocks":[{"t":"h2","id":"the-file-is-not-what-it-says","runs":[{"text":"The file is not what it says"}]},{"t":"p","runs":[{"text":"An upload arrives with a name, an extension, and a declared content type, and the uploader chose all three. "},{"text":"invoice.pdf","code":true},{"text":" can contain a Windows executable; "},{"text":"photo.jpg","code":true},{"text":" can be an HTML page; "},{"text":"logo.svg","code":true},{"text":" can be a document with script in it. The uploads lesson in the data block set the size and type limits. This lesson is about what a limit has to check, and what happens to the file once it is stored."}]},{"t":"h2","id":"read-the-bytes","runs":[{"text":"Read the bytes"}]},{"t":"p","runs":[{"text":"File formats begin with a signature. A JPEG starts with "},{"text":"FF D8 FF","code":true},{"text":", a PNG with "},{"text":"89 50 4E 47","code":true},{"text":", a PDF with "},{"text":"%PDF-","code":true},{"text":". A library such as "},{"text":"file-type","code":true},{"text":" reads the first few bytes and reports what the file actually is, regardless of its name:"}]},{"t":"code","lang":"typescript","label":"TypeScript","text":"import { fileTypeFromBuffer } from \"file-type\";\nconst kind = await fileTypeFromBuffer(buffer);\nif (!kind || ![\"image/jpeg\", \"image/png\", \"image/webp\"].includes(kind.mime)) {\n  return new Response(\"Unsupported file type\", { status: 415 });\n}"},{"t":"p","runs":[{"text":"An allowlist of what you accept, decided by content. Everything else refused, including the types you did not think about. If the feature is avatars, the list is three image formats and nothing else; the day someone needs a PDF is the day the list grows by one, deliberately."}]},{"t":"h2","id":"svg-is-a-document","runs":[{"text":"SVG is a document"}]},{"t":"p","runs":[{"text":"SVG is the one image format that is also a program. It is XML, it can contain script and external references, and a browser opening an SVG file directly treats it as a page. Served from your domain, that page runs on your origin, which is the question above: an avatar that steals sessions."}]},{"t":"p","runs":[{"text":"Either refuse SVG for user uploads, the common choice, or sanitise it with a tool built for it, and serve it, like everything else uploaded, from somewhere that is not your app’s origin."}]},{"t":"h2","id":"serve-from-somewhere-else","runs":[{"text":"Serve from somewhere else"}]},{"t":"p","runs":[{"text":"Uploaded files go to object storage, and object storage gives them an address on its own domain: "},{"text":"pub-a1b2.r2.dev/...","code":true},{"text":", or a bucket domain you configure. Script in a file served from that domain runs on that domain, where there is no session cookie and nothing to steal. This single arrangement neutralises most upload-borne XSS whatever the file contains, which is why the uploads lesson insisted on it before security came up."}]},{"t":"p","runs":[{"text":"Two headers complete it. "},{"text":"Content-Type","code":true},{"text":" set by your code from the detected type, not from the upload. And "},{"text":"Content-Disposition: attachment","code":true},{"text":" on anything that is not an image meant to display inline, so that the browser downloads rather than renders it."}]},{"t":"h2","id":"the-name-on-disk","runs":[{"text":"The name on disk"}]},{"t":"p","runs":[{"text":"The uploader’s filename is used for one thing: showing it back to them. The key in the bucket is generated by the server: a random id under the user’s own prefix, as the earlier lesson showed. A filename used as a path allows "},{"text":"../../.env","code":true},{"text":" and similar; a filename used as a key allows overwriting another user’s file by guessing its name. Neither is possible when the server names everything."}]},{"t":"h2","id":"processing-is-an-attack-surface","runs":[{"text":"Processing is an attack surface"}]},{"t":"p","runs":[{"text":"Every tool that opens an uploaded file is code parsing hostile input. Image libraries have a long record of vulnerabilities triggered by crafted files; ImageMagick’s history is the famous one. Three habits reduce the exposure. Keep the library current: "},{"text":"npm audit","code":true},{"text":" and the dependency lesson apply. Process in a separate function or service, so that a compromise of the resizer is a compromise of a box with no database access. And set limits before parsing: a maximum pixel dimension, because a 1 KB PNG can declare itself to be 100,000 by 100,000 pixels and ask the resizer for 40 GB of memory."}]},{"t":"p","runs":[{"text":"Zip files are the same idea: a small archive that expands to terabytes. Cap the expanded size and the file count, or refuse archives."}]},{"t":"h2","id":"the-size-limit-that-matters","runs":[{"text":"The size limit that matters"}]},{"t":"p","runs":[{"text":"The browser-side check on size is a courtesy. The server-side check has to happen before the whole body is read (through the signed-URL upload’s declared length, or the host’s body limit) because a route that reads a 2 GB body to discover it is too large has already spent the memory and the time."}]},{"t":"h2","id":"two-minutes-per-feature","runs":[{"text":"Two minutes per feature"}]},{"t":"p","runs":[{"text":"For every upload feature: send a file with a wrong extension and a right signature, and vice versa. Send an SVG. Send a file named with "},{"text":"../","code":true},{"text":". Open the stored file’s URL directly and look at the domain in the address bar and the headers in the Network tab. If the domain is your app’s and the type came from the upload, the finding is written before the page has finished loading."}]}],"check":{"question":[{"text":"An avatar upload accepts image/svg+xml, checks the extension, stores the file, and serves it from /uploads/ on the app’s own domain. What can a user do with an SVG that contains script?"}],"options":[{"text":[{"text":"Nothing, because SVG files are images and the browser renders them as pixels rather than as a document, so any script inside is ignored the same way it would be in a JPEG."}],"correct":false,"tempting":[{"text":"An SVG is an XML document that may contain script; opened directly in a tab it is a page, not a picture."}]},{"text":[{"text":"Overwrite another user’s avatar by choosing the same filename."}],"correct":false,"tempting":[{"text":"A real hole when the uploader names the file, but a different one; the question is about what the SVG’s content does when served."}]},{"text":[{"text":"Crash the image resizer, since resizers cannot parse SVG."}],"correct":false,"tempting":[{"text":"Most resizers handle or reject SVG cleanly; the harm is in serving it, not in processing it."}]},{"text":[{"text":"Anyone who opens the avatar’s URL directly runs the script on the app’s origin, with their session, which is cross-site scripting delivered by upload."}],"correct":true,"tempting":null}],"order":[0,2,1,3],"why":null},"prev":{"slug":"rate-limits-and-the-sign-up-flood","title":"Rate limits, and the sign-up flood","minutes":8},"next":{"slug":"the-admin-page-nobody-protected","title":"The admin page nobody protected","minutes":8}}}