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 %0D and 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

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.