File upload endpoints are one of the most reliable ways to compromise a server. A single misconfigured handler can let an attacker upload a web shell, exhaust disk space with a multi-gigabyte payload, or path-traverse into sensitive directories. Most Go tutorials show you how to receive a file. Almost none show you how to do it safely. This one does.
The attack surface you're actually defending against
Before writing any handler code, it helps to be explicit about what can go wrong:
-
MIME type spoofing:
Content-Typeis set by the client. It's user-controlled input. Trusting it is not validation. -
Malicious filenames:
../../../etc/cron.d/backdoor,index.php, filenames with null bytes, or a 300-character Unicode mess designed to overflow a path buffer. - Oversized payloads: a 10 GB file sent to a handler that reads without limits will eat your disk — or memory — in seconds.
- Web root storage: saving uploads inside your static file directory makes any uploaded script directly executable over HTTP.
- Partial writes: a failed upload that leaves a half-written file at a predictable path can be exploited if cleanup is missing.
Each problem has a straightforward mitigation. The critical detail is applying them in the right order, before reading the full body.
Step 1 – Enforce size limits before anything else
The first thing your handler must do is cap how much it will read. http.MaxBytesReader wraps the request body and returns an error the moment the threshold is crossed — it never buffers the full payload.
package main
import (
"fmt"
"net/http"
)
const maxUploadSize = 10 << 20 // 10 MB
func uploadHandler(w http.ResponseWriter, r *http.Request) {
// Wrap the body BEFORE calling ParseMultipartForm
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
// 2 MB held in memory, the rest spilled to temp files
if err := r.ParseMultipartForm(2 << 20); err != nil {
http.Error(w, "file too large or malformed", http.StatusBadRequest)
return
}
file, header, err := r.FormFile("upload")
if err != nil {
http.Error(w, "missing upload field", http.StatusBadRequest)
return
}
defer file.Close()
fmt.Fprintf(w, "received: %s (%d bytes)\n", header.Filename, header.Size)
}
func main() {
http.HandleFunc("/upload", uploadHandler)
http.ListenAndServe(":8080", nil)
}
The order matters. Wrap the body first, then parse. If you call ParseMultipartForm before MaxBytesReader, Go reads the entire body to memory before your size check ever runs — the opposite of what you want.
Step 2 – Validate file type from magic bytes, not headers
HTTP Content-Type is client-supplied noise. The real signal is the first 512 bytes of the file itself. Go's net/http package ships DetectContentType, which implements the WHATWG MIME Sniffing algorithm and reads the actual byte signatures:
package main
import (
"errors"
"io"
"net/http"
)
var allowedTypes = map[string]bool{
"image/jpeg": true,
"image/png": true,
"image/webp": true,
}
func detectAndValidateMIME(r io.ReadSeeker) (string, error) {
buf := make([]byte, 512)
n, err := r.Read(buf)
if err != nil && err != io.EOF {
return "", err
}
mimeType := http.DetectContentType(buf[:n])
// Rewind so the full file can be read afterward
if _, err := r.Seek(0, io.SeekStart); err != nil {
return "", err
}
if !allowedTypes[mimeType] {
return "", errors.New("file type not allowed: " + mimeType)
}
return mimeType, nil
}
The Seek(0, io.SeekStart) call is mandatory. Forget it, and the next reader sees an empty stream — a common bug that produces silently truncated uploads with no error.
Step 3 – Sanitize the filename
Never use header.Filename directly as a filesystem path. filepath.Base strips directory components, and a regex removes everything else that could cause trouble:
import (
"path/filepath"
"regexp"
"strings"
)
var nonSafeChars = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
func sanitizeFilename(name string) string {
// Strip any directory traversal attempt
base := filepath.Base(name)
// Keep only alphanumeric characters, dots, dashes, and underscores
safe := nonSafeChars.ReplaceAllString(base, "_")
// Enforce the ext4/APFS filename length limit
if len(safe) > 255 {
safe = safe[:255]
}
return strings.ToLower(safe)
}
Even after sanitization, don't use the original filename as your storage key. Generate a random ID, store it, and record the original name in a database column. This prevents collisions, keeps storage paths unpredictable, and stops an attacker from guessing where a file landed.
Step 4 – Store outside the web root with restrictive permissions
Uploads written inside your static file directory become directly accessible over HTTP. Move them elsewhere and route downloads through a handler that enforces authorization:
import (
"crypto/rand"
"encoding/hex"
"io"
"os"
"path/filepath"
)
// NOT inside your web root
const uploadDir = "/var/app/uploads"
func saveFile(src io.Reader, ext string) (string, error) {
idBytes := make([]byte, 16)
if _, err := rand.Read(idBytes); err != nil {
return "", err
}
id := hex.EncodeToString(idBytes)
dest := filepath.Join(uploadDir, id+ext)
// O_EXCL fails if a file with this name already exists
out, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if err != nil {
return "", err
}
defer out.Close()
if _, err := io.Copy(out, src); err != nil {
os.Remove(dest) // remove the partial write on failure
return "", err
}
return id, nil
}
os.O_EXCL makes the open fail if the path already exists — a lightweight guard against the astronomically unlikely UUID collision that would otherwise silently overwrite a file. The 0600 permission means only the process owner can read or write it, limiting exposure if the directory is ever accidentally exposed.
Putting it all together
A correct secure upload flow applies these steps in strict sequence:
-
MaxBytesReaderwraps the body (beforeParseMultipartForm) -
ParseMultipartFormparses with an in-memory cap -
detectAndValidateMIMEreads 512 bytes from the actual content -
sanitizeFilenamestrips traversal and dangerous characters -
saveFilewrites to a path outside the web root using a random ID and0600permissions
Skipping any single step leaves a meaningful gap. These controls are designed to layer — not to be cherry-picked.
If you run a DevSecOps pipeline, consider adding a malware scanning step (ClamAV or a cloud API) between steps 3 and 5 for truly untrusted uploads. The full checklist for hardening web APIs, including upload endpoints, is part of our free security hardening checklists.
The takeaway
Secure file upload in Go is not complicated, but it requires deliberate ordering and an allowlist mindset. Size limits come first. MIME validation reads bytes, not headers. Filenames get sanitized and then discarded in favor of random storage IDs. Uploads never land inside the web root.
The Go standard library gives you every primitive you need. The patterns above add roughly 80 lines to your handler. There is no excuse for an upload endpoint that trusts the client.
I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.
Top comments (0)