CWE-434: Unrestricted Upload of File with Dangerous Type - Go
Overview
Go's net/http package exposes uploaded files as *multipart.FileHeader after a call to r.ParseMultipartForm(). Two fields are commonly used for validation but are entirely client-controlled: FileHeader.Filename and FileHeader.Header.Get("Content-Type"). Both are copied from the multipart part headers the client sent in the request body - net/http does not verify either against the file's actual bytes.
The standard library already provides a content-sniffing function, http.DetectContentType, which examines the first 512 bytes of a file and returns a MIME type based on their content rather than any client-supplied label. Combine that with a server-generated storage filename and a storage directory outside anything served by http.FileServer/http.Dir, and bound request size with http.MaxBytesReader before parsing.
Common Vulnerable Patterns
Trusting the Content-Type Header and Filename Extension
func uploadHandler(w http.ResponseWriter, r *http.Request) {
r.ParseMultipartForm(10 << 20)
file, header, _ := r.FormFile("file")
defer file.Close()
// VULNERABLE - Content-Type is a client-supplied multipart part header
allowed := map[string]bool{"image/png": true, "image/jpeg": true}
if !allowed[header.Header.Get("Content-Type")] {
http.Error(w, "invalid type", http.StatusBadRequest)
return
}
// VULNERABLE - Filename comes from the client and is used directly as the save path
dst, _ := os.Create(filepath.Join("./static/uploads", header.Filename))
defer dst.Close()
io.Copy(dst, file)
}
// Attack: a multipart part sends Content-Type: image/png and filename="shell.php"
// with PHP web shell bytes as the body. The declared Content-Type is accepted at
// face value; nothing inspects what was actually written to disk.
Why this is vulnerable: header.Header.Get("Content-Type") is read straight from request data the client controls; it has no relationship to the bytes that follow. header.Filename is equally client-controlled and is used unmodified as part of the filesystem path, which also opens the door to path traversal (see below).
Saving Under a Directory Served by http.FileServer
// VULNERABLE - "./static" is also passed to http.FileServer(http.Dir("./static"))
// elsewhere in the program, so anything written here is reachable by URL
dst, _ := os.Create(filepath.Join("./static/uploads", header.Filename))
Why this is vulnerable: If the upload directory is inside the tree served by http.FileServer, a file written there can be requested directly. For most static file types this only exposes stored content, but if the deployment also runs uploaded files through a CGI/FastCGI handler or a script interpreter, this pattern enables remote code execution.
Path Traversal via Filename
// VULNERABLE - filepath.Join does not reject ".." segments in the joined result
dst, _ := os.Create(filepath.Join(uploadDir, header.Filename))
// Attack: Filename = "../../../etc/cron.d/malicious"
// filepath.Join collapses the ".." segments, and the resulting path can land
// outside uploadDir entirely.
Why this is vulnerable: filepath.Join cleans the resulting path (resolving .. segments) but does not confine the result to uploadDir, so a crafted Filename can still produce a path outside the intended directory.
Secure Patterns
Content-Sniffed Validation, Generated Filename, Storage Outside the Web Root
package main
import (
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
)
// SECURE - allowlist of real content types the endpoint accepts
var allowedTypes = map[string]string{
"image/png": ".png",
"image/jpeg": ".jpg",
}
// SECURE - outside any directory passed to http.FileServer/http.Dir
const uploadDir = "/var/app-data/uploads"
func uploadHandler(w http.ResponseWriter, r *http.Request) {
const maxBytes = 5 << 20 // 5 MB
// SECURE - bound request size before the body is read into memory
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
if err := r.ParseMultipartForm(maxBytes); err != nil {
http.Error(w, "request too large or malformed", http.StatusBadRequest)
return
}
file, _, err := r.FormFile("file")
if err != nil {
http.Error(w, "missing file", http.StatusBadRequest)
return
}
defer file.Close()
// SECURE - detect the real type from the file's leading bytes, not the
// client-supplied Content-Type header
buf := make([]byte, 512)
n, _ := io.ReadFull(file, buf)
detectedType := http.DetectContentType(buf[:n])
ext, ok := allowedTypes[detectedType]
if !ok {
http.Error(w, fmt.Sprintf("unsupported file type: %s", detectedType), http.StatusBadRequest)
return
}
// SECURE - generated storage name; the client-supplied Filename never
// reaches a filesystem path
nameBytes := make([]byte, 16)
if _, err := rand.Read(nameBytes); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
storedName := hex.EncodeToString(nameBytes) + ext
targetPath := filepath.Join(uploadDir, storedName)
out, err := os.OpenFile(targetPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
http.Error(w, "failed to store file", http.StatusInternalServerError)
return
}
defer out.Close()
if _, err := file.Seek(0, io.SeekStart); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if _, err := io.Copy(out, file); err != nil {
http.Error(w, "failed to store file", http.StatusInternalServerError)
return
}
fmt.Fprintf(w, "stored as %s", storedName)
}
Why this works: http.DetectContentType classifies the file from its actual bytes using the same signature table browsers use for MIME sniffing, so a Content-Type header the client set has no bearing on the outcome - the file has to genuinely start with recognizable PNG or JPEG bytes to pass. The stored filename is generated with crypto/rand, so nothing derived from header.Filename (traversal sequences, null bytes, double extensions) ever becomes part of a filesystem path. os.O_CREATE|os.O_EXCL refuses to overwrite an existing file if the generated name were ever to collide, and uploadDir sits outside anything registered with http.FileServer, so even a file that reached disk cannot be requested and executed through the web server.
Serving Uploaded Files Back Safely
func downloadHandler(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
// SECURE - id is validated against the exact format the server generates,
// then used only as a lookup key
if !validStoredName.MatchString(id) {
http.Error(w, "not found", http.StatusNotFound)
return
}
if !userCanAccess(r, id) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
path := filepath.Join(uploadDir, id)
w.Header().Set("Content-Disposition", "attachment")
w.Header().Set("X-Content-Type-Options", "nosniff")
http.ServeFile(w, r, path)
}
var validStoredName = regexp.MustCompile(`^[0-9a-f]{32}\.(png|jpg)$`)
Why this works: Forcing Content-Disposition: attachment makes the browser download the file instead of rendering it inline, which prevents a stored file from executing as HTML/SVG/script even if something slipped past the upload-time check. Restricting id to the exact pattern the server itself generates means the value can only resolve to a path inside uploadDir.
Testing
- Normal inputs: upload genuine PNG and JPEG files under the size limit; confirm they are stored and retrievable.
- Double extension: name a file
report.pdf.phpwith real PDF bytes and with real script bytes; confirm acceptance depends on the sniffed content, not the filename. - MIME-type spoofing: set the multipart part's
Content-Typetoimage/pngwhile the body bytes are an executable or script; confirm rejection, since the header is never consulted. - Path traversal: set
Filenameto../../../etc/passwdand its URL-encoded form; confirm the stored path always resolves insideuploadDir. - Oversized file: send a body larger than the
http.MaxBytesReaderlimit; confirm the request is rejected before the full body is read. - Rescan: re-run any scanner or automated check against the fixed endpoint to confirm the finding no longer reproduces.
Common Pitfalls
- Calling
http.DetectContentTypeon the wrong bytes: if the read happens afterio.Copyhas already drained the reader, or the file isn'tSeek'd back to the start before copying to storage, the bytes that get saved are not the bytes that were validated. - Trusting
filepath.Joinalone for containment:filepath.Joincleans..segments but does not stop the resulting path from landing outside the base directory; only a server-generated filename (as shown above) removes the risk entirely, since no client input reaches the path. - Setting
maxMemoryonParseMultipartFormwithout also wrapping the body inhttp.MaxBytesReader:maxMemoryonly bounds what is held in memory versus spilled to a temp file - withoutMaxBytesReader, an oversized request can still exhaust disk space beforeParseMultipartFormreturns an error.
Dependencies and Installation
No third-party package is required - net/http's DetectContentType and crypto/rand are part of the standard library.