fix(lagoon/attach): StaticHandler sends the stored Content-Disposition and nosniff (quick-261105-oc8)

- attach.StaticHandler and attach.StaticHandlerPublic set X-Content-Type-Options: nosniff on every response
- the stored disposition is read with one Attributes call after the is_public gate and sent on GET and HEAD; a value mime.ParseMediaType rejects is sent as attachment
- blobs without a stored disposition are served as before
- lagoon README, storage and attachments docs pages, surf uploads comment updated
This commit is contained in:
Jakub Zych
2026-11-05 18:13:27 +01:00
parent 5aa846c5e8
commit 27c98bf22a
7 changed files with 222 additions and 10 deletions

View File

@@ -3,6 +3,7 @@ package attach
import (
"context"
"io"
"mime"
"net/http"
"path"
"strings"
@@ -23,12 +24,22 @@ const defaultStaticContentType = "application/octet-stream"
// disk). To 404 is_public=false rows, mount StaticHandlerPublic instead.
// Do not mount the ungated handler on the app origin — same-origin
// Content-Type from uploads is XSS-relevant.
//
// Every response carries X-Content-Type-Options: nosniff. A served file gets
// its stored Content-Type and, when the blob has one, its stored
// Content-Disposition: Store stores attachment for every type
// IsInlineContentType rejects, and a stored value mime.ParseMediaType rejects
// is sent as attachment. The handler derives no disposition from the type and
// sends no Content-Security-Policy, so a blob stored without a disposition is
// served inline with its type. Reading the disposition costs one Attributes
// call per request.
func StaticHandler(bucket *blob.Bucket, prefix string) http.Handler {
return servePublicBlobs(bucket, prefix, nil)
}
// StaticHandlerPublic is StaticHandler plus an is_public gate. Missing
// rows and is_public=false both 404. The lookup runs before NewReader.
// It sends the same headers as StaticHandler.
func StaticHandlerPublic(bucket *blob.Bucket, prefix string, db *gorm.DB) http.Handler {
return servePublicBlobs(bucket, prefix, func(ctx context.Context, filename string) (bool, error) {
return fileIsPublic(ctx, db, filename)
@@ -42,6 +53,7 @@ func servePublicBlobs(bucket *blob.Bucket, prefix string, allow func(context.Con
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("X-Content-Type-Options", "nosniff")
if bucket == nil {
http.NotFound(w, r)
return
@@ -63,6 +75,11 @@ func servePublicBlobs(bucket *blob.Bucket, prefix string, allow func(context.Con
return
}
}
attrs, err := bucket.Attributes(r.Context(), key)
if err != nil {
http.NotFound(w, r)
return
}
reader, err := bucket.NewReader(r.Context(), key, nil)
if err != nil {
http.NotFound(w, r)
@@ -74,6 +91,9 @@ func servePublicBlobs(bucket *blob.Bucket, prefix string, allow func(context.Con
ct = defaultStaticContentType
}
w.Header().Set("Content-Type", ct)
if cd := storedDisposition(attrs.ContentDisposition); cd != "" {
w.Header().Set("Content-Disposition", cd)
}
if r.Method == http.MethodHead {
return
}
@@ -81,6 +101,21 @@ func servePublicBlobs(bucket *blob.Bucket, prefix string, allow func(context.Con
})
}
// storedDisposition is the value of the stored Content-Disposition a handler
// sends: empty for none, the trimmed value when mime.ParseMediaType accepts
// it, and attachment for a value it rejects, so malformed or injected
// metadata never reaches a response header.
func storedDisposition(raw string) string {
v := strings.TrimSpace(raw)
if v == "" {
return ""
}
if _, _, err := mime.ParseMediaType(v); err != nil {
return attachmentDisposition
}
return v
}
func stripStaticPrefix(path, prefix string) (string, bool) {
if prefix == "" {
return strings.TrimPrefix(path, "/"), true