package server import ( "net/http" "strings" "uncensored-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() }