Initial commit
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"send/internal/store"
|
||||
)
|
||||
|
||||
// downloadCSP is as inert as a policy gets. Combined with the attachment
|
||||
// disposition and nosniff, an uploaded HTML file cannot execute anything in
|
||||
// this origin even if a browser were talked into rendering it.
|
||||
const downloadCSP = "default-src 'none'; sandbox"
|
||||
|
||||
func (s *Server) handleDownload(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := store.CleanID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
s.fail(w, r, http.StatusNotFound, "No such file.")
|
||||
return
|
||||
}
|
||||
// Expiry is checked here, on every read, not just by the sweeper.
|
||||
m, f, err := s.store.OpenBlob(id, s.now())
|
||||
if err != nil {
|
||||
// Missing and expired are answered identically, so the response says
|
||||
// nothing about what used to exist.
|
||||
s.fail(w, r, http.StatusNotFound, "No such file.")
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
h := w.Header()
|
||||
// Set explicitly, which also stops ServeContent from sniffing the content.
|
||||
h.Set("Content-Type", "application/octet-stream")
|
||||
h.Set("Content-Disposition", contentDisposition(m.Filename))
|
||||
h.Set("Content-Security-Policy", downloadCSP)
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("Cache-Control", "private, no-transform, max-age=0, must-revalidate")
|
||||
h.Set("ETag", `"`+m.SHA256+`"`)
|
||||
|
||||
// ServeContent brings Range, If-Range and If-None-Match with it, which is
|
||||
// what makes a half-finished 400 MB download resumable. The empty name
|
||||
// keeps it from guessing a type from the extension.
|
||||
http.ServeContent(w, r, "", m.Created, f)
|
||||
}
|
||||
|
||||
// contentDisposition builds an attachment header that is safe by construction.
|
||||
//
|
||||
// The ASCII form is built from a character whitelist, so no quote, backslash or
|
||||
// control character can reach the header regardless of what was uploaded. The
|
||||
// RFC 5987 form carries the real name for anything that survived that filter.
|
||||
func contentDisposition(name string) string {
|
||||
ascii := asciiFilename(name)
|
||||
d := `attachment; filename="` + ascii + `"`
|
||||
if ascii != name {
|
||||
d += "; filename*=UTF-8''" + encodeRFC5987(name)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// asciiFilename reduces a name to printable ASCII minus the characters that
|
||||
// would need quoting.
|
||||
func asciiFilename(name string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range name {
|
||||
switch {
|
||||
case r < 0x20 || r > 0x7e, r == '"', r == '\\':
|
||||
b.WriteByte('_')
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
out := b.String()
|
||||
if strings.Trim(out, "_. ") == "" {
|
||||
return "download.bin"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// encodeRFC5987 percent-encodes everything outside the attr-char set of
|
||||
// RFC 5987, which is what the filename* parameter requires.
|
||||
//
|
||||
// The loop is over bytes, and each escaped byte is written as hex directly:
|
||||
// url.PathEscape would widen a byte to a rune first and so encode UTF-8 twice,
|
||||
// and it leaves several characters unescaped that attr-char does not allow.
|
||||
func encodeRFC5987(s string) string {
|
||||
const attrChars = "!#$&+-.^_`|~"
|
||||
const hexDigits = "0123456789ABCDEF"
|
||||
var b strings.Builder
|
||||
for i := range len(s) {
|
||||
c := s[i]
|
||||
switch {
|
||||
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9',
|
||||
strings.IndexByte(attrChars, c) >= 0:
|
||||
b.WriteByte(c)
|
||||
default:
|
||||
b.WriteByte('%')
|
||||
b.WriteByte(hexDigits[c>>4])
|
||||
b.WriteByte(hexDigits[c&0x0f])
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
Reference in New Issue
Block a user