I found a potential security issue in Go's mime/multipart package and I'm sharing this report to propose hardening.
Note: I initially reported this via the process in SECURITY.md. The Go Security Team reviewed it and classified it as a flaw rather than a vulnerability. I agree and am opening this public issue.
Summary
The mime/multipart.Writer does not escape CR/LF when serializing the name/filename parameters of the Content-Disposition header. If these fields are attacker-controlled, this can lead to CRLF injection.
Details
FileContentDisposition / CreateFormField functions call escapeQuotes, which only backslash-escapes " and \ and leaves \r and \n untouched.
- Source code: https://github.com/golang/go/blob/go1.25.0/src/mime/multipart/writer.go#L128-L157
If these fields are attacker-controlled, they can inject additional headers or force an early end of the header section (CRLF injection).
Proof of Concept
Tested in Go 1.25.1 (latest).
main.go:
package main
import (
"bytes"
"fmt"
"io"
"log"
"mime/multipart"
"net/http"
"strings"
)
// user-controllable
const MALICIOUS_FILENAME = "evil.txt\r\nContent-Type: evil/injected\r\n\r\ninjected_body"
func requestFile() {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", MALICIOUS_FILENAME)
if err != nil {
log.Fatal(err)
}
_, _ = part.Write([]byte("testtest"))
_ = writer.Close()
req, _ := http.NewRequest("POST", "http://localhost:3000/upload", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
client := &http.Client{}
resp, _ := client.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
}
func serve() {
http.HandleFunc("/upload", func(w http.ResponseWriter, r *http.Request) {
reader, _ := r.MultipartReader()
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
fmt.Println("Header:")
for k, vals := range part.Header {
fmt.Printf("- %s: %s\n", k, strings.Join(vals, ", "))
}
fmt.Println()
fmt.Println("Body:")
body, _ := io.ReadAll(part)
fmt.Println(string(body))
fmt.Println()
part.Close()
}
})
http.ListenAndServe(":3000", nil)
}
func main() {
go serve()
requestFile()
}
Run:
$ go run main.go
Header:
- Content-Disposition: form-data; name="file"; filename="evil.txt
- Content-Type: evil/injected
Body:
injected_body"
Content-Type: application/octet-stream
testtest
The injected Content-Type: evil/injected appears as a legitimate part header and the original header Content-Type: application/octet-stream is pushed into the body.
Fix (Suggestion)
Use WHATWG-compatible serialization for name/filename (align with browsers)
For field names and filenames for file fields, the result of the encoding in the previous bullet point must be escaped by replacing any 0x0A (LF) bytes with the byte sequence
%0A, 0x0D (CR) with%0Dand 0x22 (") with%22. The user agent must not perform any other escapes. Source: https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#multipart-form-data
Concretely, percent-encode:
\n->%0A\r->%0D"->%22
Most major HTTP clients already adopt percent-encoding:
- Chrome/Firefox
- undici (Node.js): https://github.com/nodejs/undici/blob/v7.15.0/lib/web/fetch/body.js#L128-L129
- urllib3 (Python): https://github.com/urllib3/urllib3/blob/2.5.0/src/urllib3/fields.py#L112-L114
- curl (with default option): https://github.com/curl/curl/blob/curl-8_15_0/lib/mime.c#L299-L310
Edge case note
Even with the above fix, a trailing backslash (\) in name/filename can still confuse non-robust quoted-string parsers (e.g., interpreting \" as an escaped quote and failing to close).
E.g.:
- filename: foobar\
- Header: Content-Disposition: form-data; name="file"; filename="foobar\"
Browsers do not escape backslashes per the HTML algorithm, but given ecosystem variance, I think it's reasonable to discuss additionally escaping \ (e.g., %5C) or rejecting a trailing \ to avoid parser differentials.
- Related discussion (curl): https://github.com/curl/curl/issues/7789
Comment From: gabyhelp
Related Issues
- proposal: mime/multipart: add method Reader.SetRejectContentTransferEncoding(bool) to support net/http rejecting multipart form parts with "Content-Transfer-Encoding" header which is explicitly deprecated by RFC 7578 Section 4.7 #66434
- mime/multipart: Failure to round trip leading to potential bypass of content based checks #74087 (closed)
- proposal: mime/multipart: allow specifying content type in Writer.CreateFormFile #49329 (closed)
- mime: bypassing multi-boundary check via Parameter Value Continuations #47602
Related Code Changes
(Emoji vote if this was helpful or unhelpful; more detailed feedback welcome in this discussion.)
Comment From: seankhliao
is this a dupe of #33801 ?
Comment From: neild
@seankhliao A bit different than #33801.
33801 is about representation of non-ASCII values in Content-Disposition headers.
Currently, multipart.FileContentDisposition("field", "École") (assuming UTF-8 source) sets a Content-Disposition header of form-data; name="field"; filename="\xc3\x89\x63\x6f\x6c\x65". That is, filename="École" in UTF-8. However, HTTP headers are specified as containing ISO-8859-1, so this should actually be interpreted as the bakemoji filename="Ã\x89cole".
RFC 6266 says that we should actually encode "École" as: filename*=UTF-8''%C3%89cole, possibly including a filename="ecole" parameter as a fallback.
Figuring that out is #33801.
This issue here (#75557) is about what happens when you call multipart.FileContentDisposition("field", "name\"\nEvil-Header: \"evil"), which currently returns:
form-data; name="field"; filename="name"
Evil-Header: "evil"
Under some circumstances, this could be used to inject additional headers into places they do not belong.
We don't consider this to be a vulnerability, since FileContentDisposition doesn't document itself as being hardened against adversarial inputs, nor does it parse a plausibly attacker-provided input. However, we should still do some more input sanitization here as a hardening measure.
Comment From: gopherbot
Change https://go.dev/cl/706677 mentions this issue: mime/multipart: don't include \r or \n in header values
Comment From: odeke-em
Nice work and thank you @arkark! I am adding here an adaptation of your proof of concept but as a regression test so that the fixer can have an end-to-end test that we can commit to trivially detect future problems
package http_test
import (
"bytes"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestMultipartHeaderCRLFInjection_h1(t *testing.T) {
testMultipartHeaderInjection(t, false)
}
func TestMultipartHeaderCRLFInjection_h2(t *testing.T) {
testMultipartHeaderInjection(t, true)
}
// Ensures that we don't have CRLF injection from mime/multipart.
// See https://golang.org/issue/75557
func testMultipartHeaderCRLFInjection(t *testing.T, enableHTTP2 bool) {
bodyOnServer := new(bytes.Buffer)
server := httptest.NewUnstartedServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
reader, _ := req.MultipartReader()
bodyOnServer.Reset()
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
for k, vals := range part.Header {
fmt.Fprintf(bodyOnServer, "- %s: %s\n", k, strings.Join(vals, ", "))
}
io.Copy(bodyOnServer, part)
part.Close()
}
}))
server.EnableHTTP2 = enableHTTP2
server.StartTLS()
body := new(bytes.Buffer)
writer := multipart.NewWriter(body)
// user-controllable
const MALICIOUS_FILENAME = "evil.txt\r\nContent-Type: evil/injected\r\n\r\ninjected_body"
part, err := writer.CreateFormFile("file", MALICIOUS_FILENAME)
if err != nil {
t.Fatal(err)
}
_, _ = part.Write([]byte("testtest"))
_ = writer.Close()
req, _ := http.NewRequest("POST", server.URL, body)
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := server.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
// Now assert against expectations.
want := `....`
if g, w := want, bodyOnServer.String(); g != w {
t.Fatalf("Bug still exists!\n\nGot: %s\nWant: %s", g, w)
}
}
Comment From: arkark
@neild @odeke-em Thanks for the detailed description and the revised end-to-end test.