5

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 Answers4

7

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.

blackgreen
  • 34,072
  • 23
  • 111
  • 129
Charlie Tumahai
  • 113,709
  • 12
  • 249
  • 242
  • Thanks. Nit: Shouldn't %7F (DEL) be rejected as an invalid byte too? I think it's usually treated as non-visible too, and it's omitted from the qdtext grammar. – Matthew Dempsky Jun 27 '21 at 20:41
  • @MatthewDempsky Good catch. Fixed. – Charlie Tumahai Jun 27 '21 at 20:44
  • I accepted Steven's answer because it's the one that I've gone with (as it's shorter, and I'm actually only concerned about filenames with valid, visible characters). I appreciate the technical precision of your answer though, with regard to rejecting/fixing other characters, so I've upvoted it too. – Matthew Dempsky Jun 27 '21 at 20:58
5

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
Zombo
  • 1
  • 62
  • 391
  • 407
  • Thanks. That's a rather roundabout solution, but it actually addresses the "arbitrary filename" part of my question. – Matthew Dempsky Jun 27 '21 at 20:18
  • Thanks. That is much simpler. However, isn't that mime/multipart code actually non-spec-compliant? https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.6 makes it sounds like all delimiters need to be escaped, not just backslash and DQUOTE. – Matthew Dempsky Jun 27 '21 at 20:36
0

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.

  • 1
    "I think this just means that you should have normal quotes around the filename." I don't think so? E.g., https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.6 mentions I can't use "delimiter" characters in the quoted-string format. But what if I want those in the filename? How do I quote them? What function will quote them for me? – Matthew Dempsky Jun 27 '21 at 20:15
-3

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\"")
tkircsi
  • 315
  • 3
  • 14