Files
summercms/modules/lagoon/attach/bucket.go
Jakub Zych caa5b5d8ff fix(19.3-06): refuse protected uploads into a public_url bucket (WR-07)
- attach.Store returns attach.ErrProtectedNeedsPrivateBucket for an
  is_public false upload while storage.uploads.public_url is set, before
  any blob is written
- OpenBucket's public_url line becomes a warning naming the refusal
2026-10-15 22:30:00 +02:00

170 lines
5.3 KiB
Go

package attach
import (
"context"
"fmt"
"log/slog"
"net/url"
"strings"
"sync"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/compass"
"gocloud.dev/blob"
_ "gocloud.dev/blob/fileblob"
_ "gocloud.dev/blob/memblob"
)
const defaultPublicPathPrefix = "/storage/uploads"
// S3BuildTag is the Go build tag that links the gocloud S3 driver
// (s3blob, imported only by s3_on.go). List it under build.tags in summer.yaml so
// summer build passes it to go build; a raw go build needs -tags summercms_s3.
const S3BuildTag = "summercms_s3"
var (
prefixMu sync.RWMutex
publicPathPrefix = defaultPublicPathPrefix
publicURLBase string
)
func setPublicPathPrefix(prefix string) {
prefixMu.Lock()
publicPathPrefix = prefix
prefixMu.Unlock()
}
func setPublicURLBase(base string) {
prefixMu.Lock()
publicURLBase = base
prefixMu.Unlock()
}
// PublicURLBase is storage.uploads.public_url as recorded by OpenBucket: an
// absolute http or https base (a public bucket or a CDN) that file and
// thumbnail URLs start with instead of PublicPathPrefix. Empty when unset.
func PublicURLBase() string {
prefixMu.RLock()
defer prefixMu.RUnlock()
return publicURLBase
}
// parsePublicURL validates storage.uploads.public_url: empty means unset,
// anything else must be an absolute http or https URL with a host and
// without user info, query or fragment.
func parsePublicURL(raw string) (*url.URL, error) {
value := strings.TrimSpace(raw)
if value == "" {
return nil, nil
}
u, err := url.Parse(value)
bad := err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" ||
(!strings.EqualFold(u.Scheme, "http") && !strings.EqualFold(u.Scheme, "https"))
if bad {
return nil, fmt.Errorf("attach: storage.uploads.public_url must be an absolute http or https URL such as https://cdn.example.com/uploads, without user info, query or fragment (set SUMMER_STORAGE__UPLOADS__PUBLIC_URL)")
}
return u, nil
}
// PublicPathPrefix is the URL prefix prepended to partition+filename.
func PublicPathPrefix() string {
prefixMu.RLock()
defer prefixMu.RUnlock()
return publicPathPrefix
}
func normalizeBucketURL(raw string) (string, error) {
u, err := url.Parse(raw)
if err != nil {
return "", fmt.Errorf("attach: bucket_url: %w", err)
}
switch strings.ToLower(u.Scheme) {
case "mem", "memory":
return "mem://", nil
case "fileblob":
u.Scheme = "file"
fallthrough
case "file":
q := u.Query()
if q.Get("create_dir") == "" {
q.Set("create_dir", "true")
u.RawQuery = q.Encode()
}
return u.String(), nil
default:
return raw, nil
}
}
// checkS3Driver refuses an s3:// bucket URL when the binary was built
// without S3BuildTag, naming the tag and where to set it.
func checkS3Driver(bucketURL string) error {
if s3Enabled {
return nil
}
scheme, _, ok := strings.Cut(bucketURL, "://")
if !ok || !strings.EqualFold(scheme, "s3") {
return nil
}
return fmt.Errorf("attach: storage.uploads.bucket_url uses s3:// but this binary was built without the %s build tag; "+
"list %s under build.tags in summer.yaml and rebuild with summer build (a raw go build needs -tags %s, or GOFLAGS=-tags=%s)",
S3BuildTag, S3BuildTag, S3BuildTag, S3BuildTag)
}
// OpenBucket opens storage.uploads.bucket_url (file://, mem://, or s3:// in a
// binary built with S3BuildTag) and records
// storage.uploads.public_path_prefix and storage.uploads.public_url. An
// empty bucket_url or a malformed public_url fails boot; a set public_url
// logs one warning saying the bucket behind it must hold only public files
// and that Store refuses protected uploads (ErrProtectedNeedsPrivateBucket).
func OpenBucket(ctx context.Context, cfg *compass.Config) (*blob.Bucket, error) {
if cfg == nil {
return nil, fmt.Errorf("attach: config is nil")
}
raw := strings.TrimSpace(cfg.String("storage.uploads.bucket_url"))
if raw == "" {
return nil, fmt.Errorf("attach: storage.uploads.bucket_url is empty (set SUMMER_STORAGE__UPLOADS__BUCKET_URL)")
}
bucketURL, err := normalizeBucketURL(raw)
if err != nil {
return nil, err
}
if err := checkS3Driver(bucketURL); err != nil {
return nil, err
}
publicURL, err := parsePublicURL(cfg.String("storage.uploads.public_url"))
if err != nil {
return nil, err
}
bucket, err := blob.OpenBucket(ctx, bucketURL)
if err != nil {
return nil, fmt.Errorf("attach: open bucket %q: %w", raw, err)
}
prefix := strings.TrimSpace(cfg.String("storage.uploads.public_path_prefix"))
if prefix == "" {
prefix = defaultPublicPathPrefix
}
setPublicPathPrefix(prefix)
if publicURL == nil {
setPublicURLBase("")
return bucket, nil
}
setPublicURLBase(publicURL.String())
slog.Default().WarnContext(ctx, "attach: storage.uploads.public_url is set; every file and thumbnail URL points at this host, "+
"which serves any key without the is_public check, so the bucket behind it must hold only public files: "+
"protected (is_public false) uploads are refused",
slog.String("host", publicURL.Host))
return bucket, nil
}
// Publish stores the opened bucket once on the app, matching lagoon.Publish.
func Publish(app *backpack.App, bucket *blob.Bucket) error {
if app == nil {
return fmt.Errorf("attach: app is nil")
}
if bucket == nil {
return fmt.Errorf("attach: bucket is nil")
}
return app.Publish(bucket)
}