Decide the type from the bytes, and never serve uploads from the web root. Cap the request with http.MaxBytesReader, require the extension, declared Content-Type and PNG/JPEG signature to agree, write under a crypto/rand name with O_EXCL outside the web root, and serve it from GET /avatars/{name} with a fixed Content-Type, Content-Disposition: attachment and X-Content-Type-Options: nosniff.
- Find every handler that reads r.FormFile or r.MultipartReader and where the file goes: a path under the directory an http.FileServer serves, a name taken from header.Filename, or a check that only reads header.Header.Get("Content-Type").
- Cap the body with r.Body = http.MaxBytesReader(w, r.Body, limit) before parsing, then read at most limit+1 bytes from the file part and answer 413 if there were more.
- Accept the file only if the extension (strings.ToLower(filepath.Ext(header.Filename))), the declared Content-Type and the first bytes (the signature) all name the same image type; answer 415 otherwise.
- Write it under a name you generate (crypto/rand plus the verified type's extension) with os.OpenFile and O_CREATE|O_EXCL so an existing file is never overwritten, in a folder outside the web root.
- Serve it from GET /avatars/{name} that accepts only names your code generated (a strict regexp), sets the verified Content-Type, X-Content-Type-Options: nosniff and Content-Disposition: attachment, or serve uploads from a separate origin.
- Add regression tests with this card's payloads: an HTML page and an SVG with script, HTML named avatar.png, avatar.png.html, a PNG that is also a page, a file one byte over the limit, and two users uploading the same name.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
mux.HandleFunc("POST /avatars", func(w http.ResponseWriter, r *http.Request) {
file, header, err := r.FormFile("avatar")
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": `attach the image in the "avatar" field`})
return
}
defer file.Close()
// header.Filename is the client's name (net/http applies filepath.Base, so there is no
// "../", but the extension and collisions are still the client's to choose).
dst := filepath.Join(webRoot, "uploads", header.Filename)
out, err := os.Create(dst) // truncates: a repeated name overwrites the previous file
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not store the file"})
return
}
defer out.Close()
if _, err := io.Copy(out, file); err != nil { // no size limit: copy whatever arrives
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not store the file"})
return
}
// The app serves the web root as static files, so the file is public as soon as it is saved.
WriteJSON(w, http.StatusCreated, map[string]string{"url": "/uploads/" + url.PathEscape(header.Filename)})
})With IMPL=vulnerable, 7 of 11 tests fail (7 exploit tests).
The fix: check and store the upload
mux.HandleFunc("POST /avatars", func(w http.ResponseWriter, r *http.Request) {
// Stop an oversized body before it is read; the file's exact limit is checked below.
r.Body = http.MaxBytesReader(w, r.Body, MaxBytes+16*1024)
file, header, err := r.FormFile("avatar")
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": `attach the image in the "avatar" field`})
return
}
defer file.Close()
content, over, err := readCapped(file, MaxBytes)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not read the file"})
return
}
if over {
WriteJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "avatars must be at most 512 KiB"})
return
}
// The extension, the declared Content-Type and the signature must agree on one type.
imageType, ok := TypesByExt[strings.ToLower(filepath.Ext(header.Filename))]
declared := strings.TrimSpace(strings.SplitN(header.Header.Get("Content-Type"), ";", 2)[0])
if !ok || declared != imageType.Mime || !bytes.HasPrefix(content, imageType.Signature) {
WriteJSON(w, http.StatusUnsupportedMediaType, map[string]string{"error": "avatars must be PNG or JPEG images"})
return
}
// A name the server makes up, in a folder the app does not serve; O_EXCL never overwrites.
name := randomHex() + imageType.Extension
out, err := os.OpenFile(filepath.Join(storageDir, name), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not store the file"})
return
}
defer out.Close()
if _, err := out.Write(content); err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not store the file"})
return
}
WriteJSON(w, http.StatusCreated, map[string]string{"url": "/avatars/" + name})
})With IMPL=fixed, 11 of 11 tests pass.
The fix: serve it as a download
mux.HandleFunc("GET /avatars/{name}", func(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
// Only names this code generated; anything else, "../" included, is simply not found.
if !storedName.MatchString(name) {
http.NotFound(w, r)
return
}
content, err := os.ReadFile(filepath.Join(storageDir, name))
if err != nil {
http.NotFound(w, r)
return
}
mime := PNG.Mime
if strings.HasSuffix(name, ".jpg") {
mime = JPEG.Mime
}
w.Header().Set("Content-Type", mime)
w.Header().Set("X-Content-Type-Options", "nosniff") // do not second-guess the type
w.Header().Set("Content-Disposition", "attachment") // a direct visit downloads it
_, _ = w.Write(content)
})With IMPL=fixed, 11 of 11 tests pass.
A fix that looks safe but is not
mux.HandleFunc("POST /avatars", func(w http.ResponseWriter, r *http.Request) {
file, header, err := r.FormFile("avatar")
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": `attach the image in the "avatar" field`})
return
}
defer file.Close()
// The declared Content-Type is whatever the client wrote; a script can send image/png with
// an HTML body.
if !wrongAllowed[header.Header.Get("Content-Type")] {
WriteJSON(w, http.StatusUnsupportedMediaType, map[string]string{"error": "avatars must be PNG or JPEG images"})
return
}
content, over, err := readCapped(file, MaxBytes)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not read the file"})
return
}
if over {
WriteJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "avatars must be at most 512 KiB"})
return
}
// A random name, but it keeps the client's extension, which is what decides how the file is
// served.
name := randomHex() + strings.ToLower(filepath.Ext(header.Filename))
if err := os.WriteFile(filepath.Join(webRoot, "uploads", name), content, 0o644); err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not store the file"})
return
}
WriteJSON(w, http.StatusCreated, map[string]string{"url": "/uploads/" + name})
})With IMPL=wrong, 3 of 11 tests fail (3 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
// Exploit: what an attacker sends instead of a picture.
func TestExploitHtmlPageWithAScriptIsRefused(t *testing.T) {
f := newServer(t)
f.assertRefused(t, f.upload(t, "profile.html", "text/html", f.html), http.StatusUnsupportedMediaType)
}
func TestExploitSvgWithAScriptIsRefused(t *testing.T) {
f := newServer(t)
f.assertRefused(t, f.upload(t, "avatar.svg", "image/svg+xml", f.svg), http.StatusUnsupportedMediaType)
}
func TestExploitHtmlNamedPngAndDeclaredImagePngIsRefused(t *testing.T) {
f := newServer(t)
f.assertRefused(t, f.upload(t, "avatar.png", "image/png", f.html), http.StatusUnsupportedMediaType)
}
func TestExploitDoubleExtensionIsRefusedEvenWhenTheBytesStartLikeAPng(t *testing.T) {
f := newServer(t)
f.assertRefused(t, f.upload(t, "avatar.png.html", "image/png", f.polyglot), http.StatusUnsupportedMediaType)
}
func TestExploitPngThatIsAlsoAnHtmlPageIsServedOnlyAsAnImageDownload(t *testing.T) {
f := newServer(t)
res := f.upload(t, "avatar.png", "image/png", f.polyglot)
if res.status != http.StatusCreated {
t.Fatalf("a valid PNG must be accepted, got %d", res.status)
}
s := f.get(t, res.url)
if s.contentType != "image/png" || s.nosniff != "nosniff" || !strings.HasPrefix(s.disposition, "attachment") {
t.Fatalf("the polyglot must come back as an image download with nosniff, got type=%q nosniff=%q disposition=%q", s.contentType, s.nosniff, s.disposition)
}
}
func TestExploitFileOneByteOverTheLimitIsRefusedWith413(t *testing.T) {
f := newServer(t)
f.assertRefused(t, f.upload(t, "avatar.png", "image/png", pad(f.png, f.seed.LimitBytes+1)), http.StatusRequestEntityTooLarge)
}
func TestExploitUploadCannotReplaceAnotherUsersFileByReusingItsName(t *testing.T) {
f := newServer(t)
alice := f.upload(t, "avatar.png", "image/png", f.png)
mallory := f.upload(t, "avatar.png", "image/png", f.otherPNG)
if alice.status != http.StatusCreated || mallory.status != http.StatusCreated {
t.Fatalf("both uploads should be accepted, got %d and %d", alice.status, mallory.status)
}
if s := f.get(t, alice.url); !bytes.Equal(s.body, f.png) {
t.Fatalf("Alice's URL must still serve her own image")
}
}- header.Header.Get("Content-Type") is the Content-Type the client wrote into the multipart part, not a fact about the file. A script sends image/png with an HTML body, which is how avatar.png.html gets past the wrong implementation.
- net/http runs the client's file name through filepath.Base before it reaches header.Filename, so "../escaped.png" becomes "escaped.png" on this stack. Do not rely on that: still generate the stored name yourself, because the base name can still collide and its extension still decides how a static handler serves it.
- http.FileServer(http.Dir(webRoot)) picks the Content-Type from the extension and serves inline with no X-Content-Type-Options, so a stored .html or .svg is served as a page. Keep uploads out of the served tree.
- io.Copy with no limit reads whatever arrives; use http.MaxBytesReader on the body and an io.LimitReader on the file part. http.MaxBytesReader also makes the server close the connection once the limit is hit.
- os.Create truncates, so a repeated name overwrites another user's file. Open with O_CREATE|O_EXCL and a name you generate so an upload can never replace an existing file.
- A signature check proves how a file starts, not that it is harmless: the polyglot test is a valid PNG that is also a working page. The serving headers (nosniff, attachment) are what keep it from rendering; re-encode images with a maintained library if you also need to strip trailing data.
- An upload endpoint behind cookie authentication needs CSRF protection like any other form post, and a private file needs an authorization check on the serving route; an unguessable name is not access control.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs file-upload/go-nethttp does this for you, in Docker if you do not have Go).
- Against your own endpoint, upload an HTML file named avatar.png with Content-Type image/png and an .svg with a <script>; both must be refused with nothing stored. Open the URL of an accepted avatar and check for Content-Disposition: attachment and X-Content-Type-Options: nosniff.
- No scanner here notices a missing type check or the serving headers; the regression tests are the control.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | p/golang, p/owasp-top-ten | No findings about uploads on any implementation. semgrep-rules has no rule for r.FormFile, the declared Content-Type, a file signature or a missing size cap, so the vulnerable, wrong and fixed handlers look the same to every rule.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3); semgrep scan --metrics=off --config p/golang --config p/owasp-top-ten . |
| CodeQL | go/path-injection | The vulnerable handler joins header.Filename onto the uploads folder, but net/http returns header.Filename already run through filepath.Base, so there is no traversal for go/path-injection to report. CodeQL has no query for an uploaded file's type or size, so it says nothing about the missing checks on any implementation.Checked: github/codeql main at b5b8165; query and Go models read, not executed |
| gosec | G304, default rule setgosec 2.29.0 | G304 (file path from a variable) flagged the os.Create/os.OpenFile calls in all three implementations, the fixed one included, because the path is built at run time; it cannot tell the generated name from the client's. gosec has no rule for the upload's type or size.Checked: gosec 2.29.0 (run with Go 1.26.9: gosec 2.29.0 cannot read Go 1.27 export data); gosec ./... |
No Go scanner rule checked here notices a missing type or size check or the serving headers. net/http's header.Filename is already filepath.Base'd, so the client cannot traverse out of the uploads folder by name on this stack, unlike Flask or Spring. The regression tests are the control.
| Component | Version |
|---|---|
| Language | Go |
| Runtime | Go 1.27.2 (golang:1.27.2-trixie, linux/arm64); also passes on Go 1.26.9 |
| Framework | Go standard library 1.27.2 (net/http multipart, http.MaxBytesReader, crypto/rand) |
| gotest.tools/gotestsum (JUnit report, a go.mod tool) | 1.13.0 |
| golang.org/x/text (raised for gotestsum) | 0.42.0 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the file-upload/go-nethttpfolder.
go mod download
go build ./... tool# Expected: every test passes
IMPL=fixed go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...
# Expected: an exploit test fails
IMPL=vulnerable go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...
# Expected: a test fails
IMPL=wrong go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...Without Go installed, run the same commands with Docker in the image the card was verified in, pinned by its digest. The named volumes keep the downloaded dependencies and the build cache between runs, so the test runs need no network.
docker run --rm --init \
-v "$PWD":/card -w /card \
-v fix-go-gomod:/go/pkg/mod \
-v fix-go-gocache:/root/.cache/go-build \
golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
sh -c 'go mod download && go build ./... tool'# Expected: every test passes
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-v fix-go-gomod:/go/pkg/mod \
-v fix-go-gocache:/root/.cache/go-build \
-e IMPL=fixed \
golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
sh -c 'go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...'
# Expected: an exploit test fails
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-v fix-go-gomod:/go/pkg/mod \
-v fix-go-gocache:/root/.cache/go-build \
-e IMPL=vulnerable \
golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
sh -c 'go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...'
# Expected: a test fails
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-v fix-go-gomod:/go/pkg/mod \
-v fix-go-gocache:/root/.cache/go-build \
-e IMPL=wrong \
golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
sh -c 'go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...'Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs file-upload/go-nethttp runs all three modes and checks the expected results, in the container, so it needs Docker and Node.js but not the toolchain.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64); tests in Docker Desktop (Engine 29.4.3), linux/arm64
- Toolchain
- go 1.27.2 (golang:1.27.2-trixie image); also run on 1.26.9 (golang:1.26.9-trixie)
- gotestsum 1.13.0
- semgrep 1.180.0
- gosec 2.29.0
- codeql read, not executed
- Matrix
- fixed: 11 of 11 tests pass; vulnerable: 7 of 11 tests fail (7 exploit tests); wrong: 3 of 11 tests fail (3 exploit tests)
- Test results
- fixed 11/11 pass; vulnerable 7/11 fail (7 exploit tests: the HTML page, the SVG, HTML named avatar.png, avatar.png.html, the polyglot's serving headers, the over-limit file, and the reused name overwriting another user's file); wrong 3/11 fail (3 exploit tests: HTML named avatar.png, avatar.png.html served as text/html, and the polyglot's serving headers). The card has no ../ or absolute-name test: net/http runs header.Filename through filepath.Base (mime/multipart Part.FileName), so a client cannot steer the stored path by name on this stack, unlike Flask or Spring. Each test run in a container with --network=none after install.
- Dependency freshness
- The card needs only the standard library. gotestsum 1.13.0 (2025-09-11) asks for golang.org/x/text 0.17.0, which GO-2026-5970 affects in a package gotestsum builds (x/text/unicode/norm), so go.mod raises it to 0.42.0 (2026-09-08). Go 1.27.2 (2026-10-08) is under seven days old but is a security release.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP File Upload Cheat Sheet
- OWASP ASVS 5.0.0, V5 File Handling
- CWE-434: Unrestricted Upload of File with Dangerous Type
- Go standard library: net/http Request.FormFile
- Go standard library: mime/multipart Part.FileName (filepath.Base is applied)
- Go standard library: net/http MaxBytesReader
- Go standard library: net/http FileServer (Content-Type from extension, served inline)
- MDN: X-Content-Type-Options
Attack patterns: