Why a Go Download Handler Serves the Wrong File Type
If you never set Content-Type in a Go download handler, net/http picks one for you, and the way it picks is less stable than it looks. The same code can return text/plain on your laptop and application/octet-stream in a slim container. Worse, if users supply the files, the fallback can hand a browser an HTML page it will happily execute.
The three-step guess
When you call http.ServeContent (and ServeFile, which wraps it) without a Content-Type already set, it works through this list:
- Look up the file name's extension with
mime.TypeByExtension. - If that returns nothing, read the first 512 bytes and call
http.DetectContentType. - Seek back to the start and send the body.
Each step has a catch. Step one depends on the machine, and step two depends on the file's contents, which are whatever the uploader decided they were.
Step one: the answer depends on the host
Go ships a small built-in extension table (things like .html, .css, .js, .json, .png, .pdf, .svg). On Unix it then loads extra entries from system files such as /etc/mime.types; on Windows it reads the registry. The result of TypeByExtension is therefore a property of the machine, not of your program.
package main
import (
"fmt"
"mime"
)
func main() {
for _, ext := range []string{".csv", ".md", ".webp", ".heic", ".log"} {
fmt.Printf("%-6s %q\n", ext, mime.TypeByExtension(ext))
}
}
Run that on a desktop with a full mime.types, then in a scratch or distroless image. Expect some of those lines to flip to an empty string. An empty string is the cue for step two.
Quick detour: this is also why "works on my machine" shows up with .js on Windows. A mangled registry entry can make a script come back as text/plain, and browsers refuse to run it under strict MIME checking. The bug lives outside your repo, which makes it a miserable one to find.
Step two: sniffing trusts the bytes
http.DetectContentType implements the WhatWG MIME sniffing algorithm. It looks at the first 512 bytes only and always returns something: a recognised signature, text/plain; charset=utf-8 for text-looking data, or application/octet-stream as the last resort.
Among the signatures it knows is HTML. A file that starts with <!DOCTYPE html> or <html> (after optional whitespace) is reported as text/html; charset=utf-8. So take a user upload saved as report, with no extension, containing a page of JavaScript. Your handler serves it as HTML, on your origin, with your cookies in reach.
That is stored XSS, and the code that caused it contains no mention of HTML at all.
Step three: the browser sniffs too
Even with a correct header, browsers may second-guess a response that looks wrong, for instance a text/plain body that looks like markup. The defence is the X-Content-Type-Options: nosniff response header, which tells the browser to believe what you sent. It is cheap, so send it on every download.
The fix: decide the type yourself
Do not let the server infer anything from user-controlled input. Keep an allowlist of types you intend to serve, set the header before calling ServeContent (it only guesses when the header is absent), and force a download.
package main
import (
"log"
"mime"
"net/http"
"os"
"path"
"strings"
)
var allowed = map[string]string{
".png": "image/png",
".pdf": "application/pdf",
".csv": "text/csv; charset=utf-8",
".txt": "text/plain; charset=utf-8",
}
func download(root *os.Root) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
f, err := root.Open(name)
if err != nil {
http.NotFound(w, r)
return
}
defer f.Close()
fi, err := f.Stat()
if err != nil || fi.IsDir() {
http.NotFound(w, r)
return
}
ct, ok := allowed[strings.ToLower(path.Ext(name))]
if !ok {
ct = "application/octet-stream"
}
h := w.Header()
h.Set("Content-Type", ct)
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Content-Disposition",
mime.FormatMediaType("attachment", map[string]string{"filename": name}))
http.ServeContent(w, r, name, fi.ModTime(), f)
}
}
func main() {
root, err := os.OpenRoot("./files")
if err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
mux.HandleFunc("GET /files/{name}", download(root))
log.Fatal(http.ListenAndServe(":8080", mux))
}
Things worth noticing in there:
- Unknown extensions fall to
application/octet-stream, which browsers save rather than render. attachmentinContent-Dispositionmeans even a misjudged type will not be displayed inline.ServeContentstill gives you range requests and conditionalIf-Modified-Sincehandling for free.os.Root(Go 1.24 and later) stops a clever name escaping the directory, a separate problem but the same handler.
When you really do want inline display
Images and PDFs shown in the page are the usual reason to drop attachment. Then the allowlist matters even more: serve user content from a separate origin (a different registrable domain, not just a subdomain you share cookies with) and keep the exact-type map. Do not include text/html or image/svg+xml from untrusted users in it; SVG can carry script.
Better still, record the type once at upload time, after you have validated it, and store it beside the file. Then the download path is a lookup rather than a guess. The extension is a hint from the client; the stored value is a decision you made.
Checking it works
Test with curl -i and read the headers, not the body:
curl -si localhost:8080/files/report | sed -n '1,/^\r$/p'
You want Content-Type: application/octet-stream, X-Content-Type-Options: nosniff and an attachment disposition for a file with no extension. Then drop an HTML payload under that name and confirm the response is unchanged. If the type depends on what is inside the file, the guess is still in the loop.