How to quote strings for use in HTTP header fields?

Viewed 625

TL;DR: Given an arbitrary filename as a Go string value, what's the best way to create a Content-Disposition header field that specifies that filename?

I'm writing a Go net/http handler, and I want to set the Content-Disposition header field to specify a filename that the browser should use when saving the file. According to MDN, the syntax is:

Content-Disposition: attachment; filename="filename.jpg"

and "filename.jpg" in an HTTP "quoted-string". However, I don't see any mention of "quote" in the net/http docs. Only mentions of HTML and URL escaping.

Is quoted-string the same as or at least compatible with URL escaping? Can I just use url.QueryEscape or url.PathEscape for this? If so, which one should I use, or are they both safe for this purpose? HTTP quoted-string looks similar to URL escaping, but I can't immediately find anything saying whether they're compatible, or if there are edge cases to worry about.

Alternatively, is there a higher-level package I should be using instead that can handle the details of constructing HTTP header field values that contain parameters like this?

4 Answers

HTTP quoted-string is defined in RFC 7230:

 quoted-string  = DQUOTE *( qdtext / quoted-pair ) DQUOTE
 qdtext         = HTAB / SP /%x21 / %x23-5B / %x5D-7E / obs-text
 obs-text       = %x80-FF
 quoted-pair    = "\" ( HTAB / SP / VCHAR / obs-text )
 

where VCHAR is any visible ASCII character.

The following function quotes per the RFC:

// quotedString returns s quoted per quoted-string in RFC 7230.
func quotedString(s string) (string, error) {
    var result strings.Builder
    result.Grow(len(s) + 2) // optimize for case where no \ are added.

    result.WriteByte('"')
    for i := 0; i < len(s); i++ {
        b := s[i]
        if (b < ' ' && b != '\t') || b == 0x7f {
            return "", fmt.Errorf("invalid byte %0x", b)
        }
        if b == '\\' || b == '"' {
            result.WriteByte('\\')
        }
        result.WriteByte(b)
    }
    result.WriteByte('"')
    return result.String(), nil
}

Use the function like this:

qf, err := quotedString(f)
if err != nil {
    // handle invalid byte in filename f
}
header.Set("Content-Disposition", "attachment; filename=" + qf)

It may be convenient to fix invalid bytes instead of reporting an error. It's probably a good idea to clean up invalid UTF8 as well. Here's a quote function that does that:

// cleanQuotedString returns s quoted per quoted-string in RFC 7230 with invalid
// bytes and invalid UTF8 replaced with _.
func cleanQuotedString(s string) string {
    var result strings.Builder
    result.Grow(len(s) + 2) // optimize for case where no \ are added.

    result.WriteByte('"')
    for _, r := range s {
        if (r < ' ' && r != '\t') || r == 0x7f || r == 0xfffd {
            r = '_'
        }
        if r == '\\' || r == '"' {
            result.WriteByte('\\')
        }
        result.WriteRune(r)
    }
    result.WriteByte('"')
    return result.String()
}

If you know that the filename does not contain invalid bytes, then copy the following code from the mime/multipart package source:

var quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"")

func escapeQuotes(s string) string {
    return quoteEscaper.Replace(s)
}

The standard library code is similar to the code in Steven Penny's answer, but the standard library code allocates and builds the replacer once instead of on each invocation of escapeQuotes.

One way is using the multipart package [1]:

package main

import (
   "mime/multipart"
   "strings"
)

func main() {
   b := new(strings.Builder)
   m := multipart.NewWriter(b)
   defer m.Close()
   m.CreateFormFile("attachment", "filename.jpg")
   print(b.String())
}

Result:

--81200ce57413eafde86bb95b1ba47121862043451ba5e55cda9af9573277
Content-Disposition: form-data; name="attachment"; filename="filename.jpg"
Content-Type: application/octet-stream

or you can use this function, based on the Go source code [2]:

package escape
import "strings"

func escapeQuotes(s string) string {
   return strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s)
}
  1. https://golang.org/pkg/mime/multipart
  2. https://github.com/golang/go/blob/go1.16.5/src/mime/multipart/writer.go#L132-L136

I think this just means that you should have normal quotes around the filename. Assuming you want to fix the filename then you can just set the header as per the MDN suggestion.

Without escaping the quotes might be possible, but only if filename does not contain spaces:

w.Header().Set("Content-Disposition", "attachment; filename=testsomething.txt")

The quotes around the filename allow spaces:

w.Header().Set("Content-Disposition", "attachment; filename=\"test  something.txt\"")

Using multiline quote (`) instead

w.Header().Set("Content-Disposition", `attachment; filename="test something.txt"`)

You will want to make sure that the filename does not contain any characters which could be interpreted by the OS in some that would corrupt the path of the file. e.g. Having the / or \ may cause some issues with the download, or having a filename that is too long.

Assuming the filename is not end-user defined, you'll probably be ok. If using user free text, then you might want to restrict and validate in some way.

What is the problem with that, just escaping and adding like other header values like this?

w.Header().Add("Content-Disposition", "attachment; filename=\"flename.txt\"")
Related