Step 3 — Uploading a product image
The endpoint with the most ways to go wrong, and the four defences that fix them.
- Build an upload endpoint that caps the body size with
http.MaxBytesReader. - Check a file's real type with
http.DetectContentType, not its extension. - Stream the file to a server-chosen name with
io.Copyand clean up on failure.
What multipart means
A normal JSON request has one body. Uploading a file usually needs two things at once, the file itself and some fields describing it, so browsers use a format called multipart/form-data, which packs several named parts into one body.
Go parses it for you with r.ParseMultipartForm, and r.FormFile("file") hands back the part you want. The parsing is the easy bit. The safety is not.
None of the defaults are safe
- Size: by default Go will read as much as the client sends. A 4GB upload can fill your memory or disk before any of your checks run.
- Type: the file extension and the
Content-Typeheader both come from the client, so both can lie. - Name: a filename of
../../etc/passwdwrites wherever it likes if you use it to build a path. Recent Go versions strip the directory part from the name, but do not build a path from it anyway. - Cleanup: if the file is written and the database row fails, you have a file nothing points at, forever.
Part 1 — cap the body, then read the file
The size cap has to come first, before anything reads the body. MaxBytesReader enforces the limit. The number passed to ParseMultipartForm is how much it keeps in memory; anything larger spills to temporary files on disk.
const maxBytes = 5 << 20 // 5 MB // DEFENCE 1 — cap the body before anything reads it. r.Body = http.MaxBytesReader(w, r.Body, maxBytes) if err := r.ParseMultipartForm(maxBytes); err != nil { httpx.Error(w, 413, "file too large") return } file, hdr, err := r.FormFile("file") if err != nil { httpx.Error(w, 400, "a file field is required") return } defer file.Close()Part 2 — check what the file really is
Read the first 512 bytes, ask Go what they look like, and reject anything not on your list. Then rewind, because you have just consumed part of the file.
// DEFENCE 2 — sniff the real type from the first 512 bytes. head := make([]byte, 512) n, _ := file.Read(head) mime := http.DetectContentType(head[:n]) if !allowed[mime] { httpx.Error(w, 415, "unsupported file type: "+mime) return } if _, err := file.Seek(0, io.SeekStart); err != nil { // rewind before copying httpx.Error(w, 500, "storage error") return }Part 3 — you choose the filename, and you stream it
The client never gets to influence the path. Generate a random name, join it to your upload directory, and stream the bytes across.
// DEFENCE 3 — we choose the filename. The client never touches the path. var b [16]byte if _, err := rand.Read(b[:]); err != nil { // crypto/rand httpx.Error(w, 500, "storage error") return } stored := hex.EncodeToString(b[:]) + extFor(mime) dst := filepath.Join(h.uploadDir, stored) out, err := os.Create(dst) if err != nil { httpx.Error(w, 500, "storage error") return } defer out.Close() size, err := io.Copy(out, file) // streams; memory stays flat if err != nil { os.Remove(dst) httpx.Error(w, 500, "write failed") return }Part 4 — record it, and clean up if that fails
The file is on disk. If the database row fails now, that file is orphaned: nothing points at it and nothing will delete it. So remove it on the failure path.
img := &Image{StoredName: stored, MIME: mime, SizeBytes: size} // DEFENCE 4 — no orphans. If the row fails, the file goes too. if err := h.svc.Save(r.Context(), img); err != nil { os.Remove(dst) httpx.FromError(w, err) return } _ = hdr.Filename // keep only for display, never for a path httpx.JSON(w, 201, img)The whole file, assembled
Here is how the four parts fit together, with the imports and the two small helpers the parts use. Parts 2 to 4 go where the comment says.
package httpimport ( "crypto/rand" "encoding/hex" "io" "net/http" "os" "path/filepath" "github.com/you/shop/internal/adapter/handler/http/httpx")// httpx.Error, httpx.JSON and httpx.FromError are the JSON helpers you// copied across in step 1. They stay in their own package, httpx, so the// calls read the same as in project two.func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) { // part 1, part 2, part 3, part 4 — in that order}var allowed = map[string]bool{ "image/jpeg": true, "image/png": true, "image/gif": true, "image/webp": true,}// extFor picks the extension from the sniffed type, never from the client.func extFor(mime string) string { switch mime { case "image/jpeg": return ".jpg" case "image/png": return ".png" case "image/gif": return ".gif" case "image/webp": return ".webp" } return ""}Why io.Copy and not io.ReadAll
io.ReadAll pulls the entire file into memory before writing it. Ten people uploading 5MB at once is 50MB of heap, and it grows with every concurrent upload and every megabyte you allow.
io.Copy streams through a small fixed buffer, so its memory use does not grow with the file. This is what io.Reader and io.Writer exist for: an uploaded file and a file on disk are both somewhere bytes come from and go to.
http.DetectContentType reads the file’s first bytes (its magic number), so it reports what the content looks like, whatever the name says. It is a type check, not a malware check. Be careful serving uploads from your main domain: a stored HTML or SVG file that a browser renders is stored cross-site scripting on your own origin. Many sites serve user uploads from a separate domain for that reason.Try to break it yourself
# a real image → 201curl -si -X POST localhost:8080/v1/products/1/image \ -H "Authorization: Bearer $ADMIN" -F "file=@photo.jpg" | head -1# too big → 413head -c 50M /dev/urandom > big.bincurl -si -X POST localhost:8080/v1/products/1/image \ -H "Authorization: Bearer $ADMIN" -F "file=@big.bin" | head -1# a script wearing a .jpg name → 415echo '#!/bin/sh' > evil.sh && cp evil.sh evil.jpgcurl -si -X POST localhost:8080/v1/products/1/image \ -H "Authorization: Bearer $ADMIN" -F "file=@evil.jpg" | head -1# a path in the filename → stored under a name WE generatedcurl -si -X POST localhost:8080/v1/products/1/image \ -H "Authorization: Bearer $ADMIN" \ -F "file=@photo.jpg;filename=../../../tmp/evil.jpg" | head -1ls /tmp/evil.jpg # must not exist# a customer trying to upload → 403curl -si -X POST localhost:8080/v1/products/1/image \ -H "Authorization: Bearer $CUST" -F "file=@photo.jpg" | head -1MaxBytesReaderbefore anything reads the body, thenParseMultipartFormwith the same limit.- Sniff the type with
http.DetectContentType. Never trust the extension or the client header. - Generate the stored filename with
crypto/rand. The client filename is display text only. io.Copystreams; if the database insert fails,os.Removethe file.
- 1Replace
io.Copywithio.ReadAlland watch memory while uploading a large file. - 2Remove the MIME check and upload an SVG containing a script tag. Then put the check back.
- 3Make the database insert fail on purpose and confirm the file is removed from disk.
- 4Add SHA-256 dedup with
io.TeeReader, so the same photo uploaded twice is stored once.