- 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
161 lines
4.9 KiB
Go
161 lines
4.9 KiB
Go
package attach
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"mime"
|
|
"net/http"
|
|
"path"
|
|
"strings"
|
|
|
|
"gocloud.dev/blob"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const defaultStaticContentType = "application/octet-stream"
|
|
|
|
// StaticHandler serves GET prefix/<partition>/<filename> from bucket.
|
|
// filename is the original disk_name or a thumb_* sibling stored in the
|
|
// original's partition. The blob key is the validated 4-segment path;
|
|
// unvalidated request segments never reach NewReader (T-05-13).
|
|
//
|
|
// This is the public-disk handler: it does not consult system_files.is_public.
|
|
// Protected files must not be stored in this bucket (Winter uses a second
|
|
// 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)
|
|
})
|
|
}
|
|
|
|
func servePublicBlobs(bucket *blob.Bucket, prefix string, allow func(context.Context, string) (bool, error)) http.Handler {
|
|
prefix = strings.TrimSuffix(prefix, "/")
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
|
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
|
|
}
|
|
rel, ok := stripStaticPrefix(r.URL.Path, prefix)
|
|
if !ok {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
key, ok := parsePublicBlobPath(rel)
|
|
if !ok {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if allow != nil {
|
|
ok, err := allow(r.Context(), path.Base(key))
|
|
if err != nil || !ok {
|
|
http.NotFound(w, r)
|
|
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)
|
|
return
|
|
}
|
|
defer reader.Close()
|
|
ct := reader.ContentType()
|
|
if ct == "" {
|
|
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
|
|
}
|
|
_, _ = io.Copy(w, reader)
|
|
})
|
|
}
|
|
|
|
// 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
|
|
}
|
|
if path == prefix {
|
|
return "", false
|
|
}
|
|
if strings.HasPrefix(path, prefix+"/") {
|
|
return path[len(prefix)+1:], true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// parsePublicBlobPath accepts exactly 3 partition groups plus a filename
|
|
// and returns that path as the blob key. Originals must live in
|
|
// PartitionDirectory(filename); thumb_* names are stored beside the
|
|
// original, so their partition is not derived from the thumb filename.
|
|
// Rejects "..", empty segments, extra slashes, and mismatched original
|
|
// partitions.
|
|
func parsePublicBlobPath(p string) (key string, ok bool) {
|
|
if p == "" || strings.Contains(p, "\\") || strings.Contains(p, "..") || strings.Contains(p, "//") {
|
|
return "", false
|
|
}
|
|
parts := strings.Split(p, "/")
|
|
if len(parts) != 4 {
|
|
return "", false
|
|
}
|
|
for _, part := range parts {
|
|
if part == "" || part == "." || part == ".." {
|
|
return "", false
|
|
}
|
|
}
|
|
filename := parts[3]
|
|
got := strings.Join(parts[:3], "/")
|
|
if !strings.HasPrefix(filename, "thumb_") {
|
|
want := strings.TrimSuffix(PartitionDirectory(filename), "/")
|
|
if got != want {
|
|
return "", false
|
|
}
|
|
}
|
|
return got + "/" + filename, true
|
|
}
|