The prefix was only trimmed. Without a leading slash ServeMux read its first segment as a host pattern, so every upload silently answered 404, and a CDN URL panicked in ServeMux and surfaced as a misleading route conflict. With storage.uploads.serve on, BuildRouter now refuses a prefix that is not an absolute URL path (scheme, host, query, fragment, whitespace, wildcard braces, empty, . or .. segments, or a bare /) with an error naming the key.
172 lines
6.4 KiB
Go
172 lines
6.4 KiB
Go
package surf
|
|
|
|
import (
|
|
"fmt"
|
|
"mime"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"gocloud.dev/blob"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// uploadsPluginID owns the framework uploads route in the route table.
|
|
const uploadsPluginID = "summercms.surf"
|
|
|
|
// defaultUploadsPrefix matches the prefix attach.OpenBucket records when
|
|
// storage.uploads.public_path_prefix is empty.
|
|
const defaultUploadsPrefix = "/storage/uploads"
|
|
|
|
// uploadsContentSecurityPolicy goes on every uploads response: a stored
|
|
// file opened as a document runs no script, loads nothing but same-origin
|
|
// images and media, and gets an opaque origin (sandbox), so it cannot read
|
|
// the app's cookies or storage. It does not affect an <img> or <video> that
|
|
// embeds the file.
|
|
const uploadsContentSecurityPolicy = "default-src 'none'; img-src 'self'; media-src 'self'; sandbox"
|
|
|
|
// uploadsInlineTypes are the content types the uploads route lets a browser
|
|
// show inline: raster images. Every other successful response, SVG and PDF
|
|
// included, is served with Content-Disposition: attachment.
|
|
var uploadsInlineTypes = map[string]bool{
|
|
"image/avif": true,
|
|
"image/gif": true,
|
|
"image/jpeg": true,
|
|
"image/png": true,
|
|
"image/webp": true,
|
|
}
|
|
|
|
// mountUploads registers GET {storage.uploads.public_path_prefix}/{path...}
|
|
// when storage.uploads.serve is true. The handler delegates to
|
|
// attach.StaticHandlerPublic, so missing rows and is_public=false rows 404.
|
|
// Because the files are served on the app origin, every response carries
|
|
// X-Content-Type-Options: nosniff and uploadsContentSecurityPolicy, and a
|
|
// file whose stored type is not an inline raster image (uploadsInlineTypes)
|
|
// is served as an attachment: attach.Store records the type sniffed from
|
|
// the bytes, so an uploaded text file that starts with <html> is text/html. The bucket and the database are looked up per request:
|
|
// boot and route:list build the router before serve publishes either.
|
|
// The route owns the whole prefix: a plugin route at or under it fails
|
|
// BuildRouter, as a route under the admin prefix does. With the key false
|
|
// or absent nothing is registered and nothing is checked.
|
|
func mountUploads(r *Router, app *backpack.App) error {
|
|
if r == nil || app == nil || app.Config == nil {
|
|
return nil
|
|
}
|
|
if !app.Config.Bool("storage.uploads.serve") {
|
|
return nil
|
|
}
|
|
prefix, err := uploadsPrefix(app.Config.String("storage.uploads.public_path_prefix"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, rt := range r.routes {
|
|
if rt.path == prefix || strings.HasPrefix(rt.path, prefix+"/") {
|
|
return fmt.Errorf("surf: route %s %s (plugin %q) is under the uploads prefix storage.uploads.public_path_prefix %s, which storage.uploads.serve mounts", rt.method, rt.path, rt.pluginID, prefix)
|
|
}
|
|
}
|
|
handler := uploadsHandler(app, prefix)
|
|
r.BindPlugin(uploadsPluginID)
|
|
r.GroupRaw(prefix, nil, func(g pact.Router) {
|
|
g.Get("/{path...}", handler)
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// uploadsPrefix returns the route prefix for a storage.uploads.public_path_prefix
|
|
// value: trimmed, without a trailing slash, defaultUploadsPrefix when empty.
|
|
// The route needs an absolute URL path: a leading slash, no scheme or host,
|
|
// no empty, "." or ".." segment, no query, fragment, whitespace or ServeMux
|
|
// wildcard braces. Anything else is refused, because ServeMux would read a
|
|
// value without a leading slash as a host pattern (every upload then 404s)
|
|
// and panic on a URL.
|
|
func uploadsPrefix(raw string) (string, error) {
|
|
value := strings.TrimSpace(raw)
|
|
if value == "" {
|
|
return defaultUploadsPrefix, nil
|
|
}
|
|
prefix := strings.TrimSuffix(value, "/")
|
|
bad := !strings.HasPrefix(prefix, "/") || strings.ContainsAny(prefix, "{}?# \t\r\n\\")
|
|
if !bad {
|
|
for _, seg := range strings.Split(prefix[1:], "/") {
|
|
if seg == "" || seg == "." || seg == ".." {
|
|
bad = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if bad {
|
|
return "", fmt.Errorf("surf: storage.uploads.serve needs storage.uploads.public_path_prefix to be an absolute URL path such as /storage/uploads (no scheme, host, query or wildcard), got %q", raw)
|
|
}
|
|
return prefix, nil
|
|
}
|
|
|
|
// uploadsHandler resolves the published bucket and database on every request
|
|
// and answers 404 while either is missing.
|
|
func uploadsHandler(app *backpack.App, prefix string) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, req *http.Request) {
|
|
serveUploads(w, req, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
|
bucket, ok := app.Lookup[*blob.Bucket]()
|
|
if !ok || bucket == nil {
|
|
http.NotFound(w, req)
|
|
return
|
|
}
|
|
gdb, ok := app.Lookup[*gorm.DB]()
|
|
if !ok || gdb == nil {
|
|
http.NotFound(w, req)
|
|
return
|
|
}
|
|
attach.StaticHandlerPublic(bucket, prefix, gdb).ServeHTTP(w, req)
|
|
}))
|
|
}
|
|
}
|
|
|
|
// serveUploads runs next with the uploads response headers: nosniff and the
|
|
// sandboxing CSP always, and Content-Disposition: attachment on a successful
|
|
// response whose Content-Type is not in uploadsInlineTypes. The disposition
|
|
// is decided when the status is written (or, for a HEAD that writes
|
|
// nothing, when next returns), after next has set the Content-Type.
|
|
func serveUploads(w http.ResponseWriter, req *http.Request, next http.Handler) {
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.Header().Set("Content-Security-Policy", uploadsContentSecurityPolicy)
|
|
dw := &uploadsDispositionWriter{ResponseWriter: w}
|
|
next.ServeHTTP(dw, req)
|
|
dw.decide(http.StatusOK)
|
|
}
|
|
|
|
// uploadsDispositionWriter adds Content-Disposition: attachment before the
|
|
// first status of a non-inline successful response goes out.
|
|
type uploadsDispositionWriter struct {
|
|
http.ResponseWriter
|
|
decided bool
|
|
}
|
|
|
|
func (w *uploadsDispositionWriter) decide(status int) {
|
|
if w.decided {
|
|
return
|
|
}
|
|
w.decided = true
|
|
if status >= 200 && status < 300 && !uploadsInline(w.Header().Get("Content-Type")) {
|
|
w.Header().Set("Content-Disposition", "attachment")
|
|
}
|
|
}
|
|
|
|
func (w *uploadsDispositionWriter) WriteHeader(status int) {
|
|
w.decide(status)
|
|
w.ResponseWriter.WriteHeader(status)
|
|
}
|
|
|
|
func (w *uploadsDispositionWriter) Write(p []byte) (int, error) {
|
|
w.decide(http.StatusOK)
|
|
return w.ResponseWriter.Write(p)
|
|
}
|
|
|
|
func (w *uploadsDispositionWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
|
|
|
|
// uploadsInline reports whether contentType is an inline raster image.
|
|
func uploadsInline(contentType string) bool {
|
|
mediaType, _, err := mime.ParseMediaType(contentType)
|
|
return err == nil && uploadsInlineTypes[strings.ToLower(mediaType)]
|
|
}
|