Forms and File Uploads

Parsing multipart form uploads in Go, capping their size, and why the filename a client sends is not a filename.

Syntaxf, fh, err := r.FormFile(key string) // (multipart.File, *multipart.FileHeader, error)

A multipart body is how a browser sends a form containing a file. The body is cut into parts, one per form field, each with its own headers. A boundary string, named in the request's Content-Type, separates them.

Parsing it

r.ParseMultipartForm reads the whole body. Its one argument decides how many bytes of file data stay in memory; the rest is written to temporary files on disk. Text fields land in r.PostForm. Files land in r.MultipartForm.File, keyed by field name. The HTTP/1 server deletes those temporary files for you when the request completes, but that cleanup is not guaranteed on HTTP/2, so calling r.MultipartForm.RemoveAll() yourself releases the disk space as soon as you are finished with them.

Two limits, not one

That argument is not a size limit. It only chooses memory or disk. To actually refuse a large upload, wrap the body before parsing: r.Body = http.MaxBytesReader(w, r.Body, n). Past the limit, reads fail with an error of type *http.MaxBytesError, and 413 is the code to answer with.

The filename came from a stranger

FileHeader.Filename is whatever the client put in its Content-Disposition header. Go passes it through filepath.Base, which strips directories, but filepath follows the rules of the machine you are running on, so a backslash path from Windows survives on Linux. Never join it onto a directory. Generate your own name and keep theirs for display. The part's declared Content-Type is client-supplied too; use http.DetectContentType on the first 512 bytes when the real type matters.

Shorthand and longhand

r.FormFile("avatar") is shorthand. If nothing has parsed yet it calls ParseMultipartForm(32 << 20) for you, then opens the first file under that key.

Example

Example · go
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
)

func upload(w http.ResponseWriter, r *http.Request) {
	r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // refuse anything over 1 MB
	if err := r.ParseMultipartForm(32 << 10); err != nil {
		http.Error(w, "bad upload", http.StatusBadRequest)
		return
	}
	defer r.MultipartForm.RemoveAll() // delete the temporary files it wrote

	f, fh, err := r.FormFile("avatar")
	if err != nil {
		http.Error(w, "avatar is required", http.StatusBadRequest)
		return
	}
	defer f.Close()
	// fh.Filename is for display only. Storage names are ours to choose.
	fmt.Fprintf(w, "saved as 7.bin, claimed name %q, size %d\n", fh.Filename, fh.Size)
}

func main() {
	// A hand-written multipart body, boundary "b". Note the directory traversal.
	body := "--b\r\n" +
		"Content-Disposition: form-data; name=\"avatar\"; filename=\"../../etc/passwd\"\r\n\r\n" +
		"GIF89a\r\n--b--\r\n"
	req := httptest.NewRequest(http.MethodPost, "/upload", strings.NewReader(body))
	req.Header.Set("Content-Type", "multipart/form-data; boundary=b")
	rec := httptest.NewRecorder()
	upload(rec, req)
	fmt.Print(rec.Body.String())
}

// Output:
// saved as 7.bin, claimed name "passwd", size 6

When to use it

  • Accept a profile picture from a browser form, cap it at 2 MB, and store it under a generated name.
  • Forward a large CSV upload straight to S3 without writing it to the server's disk first.
  • Reject an upload that claims to be a PNG but whose first bytes say otherwise.

More examples

The longhand that FormFile stands in for

r.FormFile returns only the first file under a key, so multi-file inputs need the long form. It also parses with a fixed 32 MB maxMemory, which you cannot change; the long form lets you pick. Writing it out once makes clear that FormFile is two steps rolled into one: parse the form, then open the first matching header.

Example · go
// 8 MB of file data in memory, the rest spilled to temporary files.
if err := r.ParseMultipartForm(8 << 20); err != nil {
	http.Error(w, "bad upload", http.StatusBadRequest)
	return
}
defer r.MultipartForm.RemoveAll()

// File is a map from field name to every file sent under that name, so a
// form with <input name="photos" multiple> gives you more than one header.
headers := r.MultipartForm.File["photos"]
if len(headers) == 0 {
	http.Error(w, "photos is required", http.StatusBadRequest)
	return
}
for _, fh := range headers {
	f, err := fh.Open() // Open gives you the reader; you must Close it
	if err != nil {
		http.Error(w, "could not open upload", http.StatusInternalServerError)
		return
	}
	if err := store(f, fh.Size); err != nil {
		f.Close()
		http.Error(w, "could not store upload", http.StatusInternalServerError)
		return
	}
	f.Close()
}

Pick the name and the type yourself

A client can label a shell script image/png, so sniff the first bytes instead of trusting the header, and reject anything not on your list. Generating the stored name removes the whole class of path-traversal and overwrite bugs at once. multipart.File supports Seek, which is why you can read the head and then rewind to store the file from byte zero.

Example · go
// Read at most 512 bytes: that is all DetectContentType ever looks at.
head, err := io.ReadAll(io.LimitReader(f, 512))
if err != nil {
	http.Error(w, "unreadable upload", http.StatusBadRequest)
	return
}
// The bytes decide the type, not the client's Content-Type header.
ext, ok := map[string]string{
	"image/png":  ".png",
	"image/jpeg": ".jpg",
	"image/gif":  ".gif",
}[http.DetectContentType(head)]
if !ok {
	http.Error(w, "only PNG, JPEG and GIF are accepted", http.StatusUnsupportedMediaType)
	return
}
// rand.Text (crypto/rand, added in Go 1.24) returns 26 random base32
// characters: unguessable, and safe as a path segment.
name := rand.Text() + ext
if _, err := f.Seek(0, io.SeekStart); err != nil { // rewind past the sniffed bytes
	http.Error(w, "unreadable upload", http.StatusInternalServerError)
	return
}

Streaming a large upload without temporary files

Use this when uploads can be hundreds of megabytes and you are forwarding them to object storage anyway. The cost is that you lose r.PostForm and random access to fields: parts arrive in the order the client sent them, so a text field placed after the file is read after it. Still wrap r.Body in MaxBytesReader, because streaming removes the disk cost, not the bandwidth cost.

Example · go
// MultipartReader hands you the parts one at a time. Nothing is buffered to
// disk, and ParseMultipartForm must not have been called on this request.
mr, err := r.MultipartReader()
if err != nil {
	http.Error(w, "expected a multipart body", http.StatusBadRequest)
	return
}
for {
	part, err := mr.NextPart()
	if errors.Is(err, io.EOF) {
		break // no parts left
	}
	if err != nil {
		http.Error(w, "malformed multipart body", http.StatusBadRequest)
		return
	}
	// FormName is the field name; FileName is empty for a plain text field.
	if part.FormName() == "archive" && part.FileName() != "" {
		// part is an io.Reader, so it can be copied straight to its destination.
		if err := uploadToS3(r.Context(), part); err != nil {
			part.Close()
			http.Error(w, "upload failed", http.StatusBadGateway)
			return
		}
	}
	part.Close()
}

Discussion

  • Be the first to comment on this lesson.