Skip to content

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.php with 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-Type to image/png while the body bytes are an executable or script; confirm rejection, since the header is never consulted.
  • Path traversal: set Filename to ../../../etc/passwd and its URL-encoded form; confirm the stored path always resolves inside uploadDir.
  • Oversized file: send a body larger than the http.MaxBytesReader limit; 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.DetectContentType on the wrong bytes: if the read happens after io.Copy has already drained the reader, or the file isn't Seek'd back to the start before copying to storage, the bytes that get saved are not the bytes that were validated.
  • Trusting filepath.Join alone for containment: filepath.Join cleans .. 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 maxMemory on ParseMultipartForm without also wrapping the body in http.MaxBytesReader: maxMemory only bounds what is held in memory versus spilled to a temp file - without MaxBytesReader, an oversized request can still exhaust disk space before ParseMultipartForm returns an error.

Dependencies and Installation

No third-party package is required - net/http's DetectContentType and crypto/rand are part of the standard library.

Additional Resources