slopware 101 · step 20 of 35
What could go wrong with an upload button?
by Damla Su Bilge · 4 min read
Ownership checks tell us whether a person may change a record. They do not tell us whether the change itself is acceptable. Someone allowed to edit their own note can still send an enormous title, unexpected fields or a file the app cannot handle.
The form runs on a machine they control, so its checks can be skipped by sending the request another way. The server has to inspect what actually arrived.
For ordinary fields, three habits cover most of what this means. Validate: before using a field, check that it has the type, length and shape you expect, and reject it if not. Parameterize: never build SQL by gluing strings together. Pass values separately, so the database treats them as data and never as part of the query.
await db.query(
'insert into receipts (owner_id, original_name) values ($1, $2)',
[req.session.userId, originalName]
);
Escape for the place where the value lands: the same string needs different treatment inside HTML, inside an attribute, inside a URL and inside a shell command. Templating libraries that escape by default handle HTML for you. The danger is the one spot where you turned escaping off.
Files test these habits hardest, because a file is several untrusted inputs at once.
What the browser tells you
Imagine a small expenses app where people photograph receipts. An upload request tells your server three things about the file, and the sender chose all three.
The name. A filename like ../../config.json can escape the folder you meant to write into if you paste it into a path. Generate your own storage name, such as a random id. Keep the original name only as display text, and escape it when you show it.
The type. The declared content type and the file extension are announcements, not proof. An executable can call itself image/png. Check the actual bytes against the formats you accept, and decode the image with a maintained library instead of trusting it.
The size. Content-Length is declared too. Rejecting a request whose declared length exceeds your limit is a cheap first gate, but the header can be wrong or missing. The real limit has to be enforced on the bytes as you read them: count them, and stop as soon as you pass the ceiling.
Decoding has its own trap. A small compressed image can expand into an enormous bitmap. Check the pixel dimensions before fully decoding, and run the decode somewhere that running out of memory cannot take the rest of the app down. Configure re-encoding to discard metadata you do not need, such as location tags, and check the output. Re-encoding is not a promise that every possible malicious payload disappears.
Before reading on, think of one upload route you already have. Which of those three facts does it trust?
Where the file lives afterwards
Once a file is accepted, it has to sit somewhere, and that somewhere is usually not the database row. It is a bucket: a separate storage service with its own permissions. Row level security on your receipts table says nothing about the bucket. A locked table and an open bucket can sit in the same project, and from inside the app the two look identical.
Keep the bucket private. When a page needs to show a file, your server first checks that this session owns the receipt, then mints a signed URL, which is a link that carries its own permission and an expiry. Be clear about what that link is. It is a bearer token: whoever holds it can open the file until it expires, logged in or not. So a signed link is not proof that the viewer is your user. Keep expiries short, and do not put signed links anywhere long-lived, such as an email.
There is also a storage decision before the bucket decision. If an image is needed only to perform one operation, ask whether your service needs to retain it afterwards. Include temporary files, logs and any processing provider in that answer; avoiding your own bucket does not establish that no copy exists elsewhere.
Try it on your own app
On a local or staging copy, rename a text file to receipt.png and upload it. Then use a test request with a crafted multipart filename containing ../; a normal operating-system filename chooser may not let you type path separators. Verify that storage uses its own generated name rather than the submitted path. Finally, open a stored file's address in a private window. If a fresh unauthenticated request loads a supposedly private file, investigate which permission allowed it. A public bucket is one possibility; an application proxy is another. For a signed link, wait past expiry and make a fresh request with caching disabled, because expiry cannot recall bytes already downloaded.


