Dev Skills

Step 3 — Uploading a product image

The endpoint with the most ways to go wrong, and the four defences that fix them.

Topic 77 of 98 · 10 min to read · 50 min to build
After this topic you can
  • 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.Copy and 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-Type header both come from the client, so both can lie.
  • Name: a filename of ../../etc/passwd writes 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.

image.go, part 1 of 4 — the top of Upload (the body of func (h *Handler) Upload(w http.ResponseWriter, r *http.Request))
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.

image.go, part 2 of 4 — inside Upload
// 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
}
Forgetting the Seek is a bug you only notice later: every stored file is missing its first 512 bytes.

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.

image.go, part 3 of 4 — inside Upload
// 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.

image.go, part 4 of 4 — the end of Upload
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.

internal/adapter/handler/http/image.go
package http
import (
"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.

Watch out: 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

terminal — all five of these must be rejected or contained
# a real image → 201
curl -si -X POST localhost:8080/v1/products/1/image \
-H "Authorization: Bearer $ADMIN" -F "file=@photo.jpg" | head -1
# too big → 413
head -c 50M /dev/urandom > big.bin
curl -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 → 415
echo '#!/bin/sh' > evil.sh && cp evil.sh evil.jpg
curl -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 generated
curl -si -X POST localhost:8080/v1/products/1/image \
-H "Authorization: Bearer $ADMIN" \
-F "file=@photo.jpg;filename=../../../tmp/evil.jpg" | head -1
ls /tmp/evil.jpg # must not exist
# a customer trying to upload → 403
curl -si -X POST localhost:8080/v1/products/1/image \
-H "Authorization: Bearer $CUST" -F "file=@photo.jpg" | head -1
The short version
  • MaxBytesReader before anything reads the body, then ParseMultipartForm with 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.Copy streams; if the database insert fails, os.Remove the file.
Remember: Every part of an upload is attacker-controlled: the size, the name, the type and the contents. Decide all four yourself.
Build this before moving on
Build the upload endpoint and run all five attacks above. Every one must be rejected or safely contained.
Then try these in your own editor
  1. 1Replace io.Copy with io.ReadAll and watch memory while uploading a large file.
  2. 2Remove the MIME check and upload an SVG containing a script tag. Then put the check back.
  3. 3Make the database insert fail on purpose and confirm the file is removed from disk.
  4. 4Add SHA-256 dedup with io.TeeReader, so the same photo uploaded twice is stored once.
Check yourselfTake the Project 3 — Small shop test