Files
summercms/modules/lagoon/attach/url_test.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

494 lines
17 KiB
Go

package attach
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"hash/crc32"
"image"
"image/gif"
"image/jpeg"
"image/png"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/compass"
"gocloud.dev/blob"
"gocloud.dev/blob/memblob"
"gorm.io/gorm"
)
// winterLayoutBucket opens a mem:// bucket with the WinterCMS public prefix
// and restores the framework default afterwards.
func winterLayoutBucket(t *testing.T) *blob.Bucket {
t.Helper()
dir := t.TempDir()
body := "uploads:\n bucket_url: \"mem://\"\n public_path_prefix: \"/storage/app/uploads/public\"\n"
if err := os.WriteFile(filepath.Join(dir, "storage.yaml"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{Dir: dir, Env: "development", Environ: []string{"SUMMER_ENV=development"}})
if err != nil {
t.Fatal(err)
}
bucket, err := OpenBucket(t.Context(), cfg)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = bucket.Close()
setPublicPathPrefix(defaultPublicPathPrefix)
})
return bucket
}
func TestFileURLWinterLayout(t *testing.T) {
bucket := winterLayoutBucket(t)
// A WinterCMS disk name: uniqid('', true) without the dot, plus the
// extension.
f := &File{ID: 12, DiskName: "651a2b3c4d5e61234567.png"}
if got, want := f.URL(), "/storage/app/uploads/public/651/a2b/3c4/651a2b3c4d5e61234567.png"; got != want {
t.Fatalf("URL = %q, want %q", got, want)
}
if got, want := PublicURL("/651/a2b/3c4/x.png"), "/storage/app/uploads/public/651/a2b/3c4/x.png"; got != want {
t.Fatalf("PublicURL = %q, want %q", got, want)
}
if err := bucket.WriteAll(t.Context(), BlobKey(f.DiskName), testJPEG(t), &blob.WriterOptions{ContentType: "image/png"}); err != nil {
t.Fatal(err)
}
thumb, err := f.Thumb(t.Context(), bucket, 200, 200, "crop")
if err != nil {
t.Fatal(err)
}
// Winter getThumbFilename: implode('_', [thumb, id, w, h, ox, oy, mode.ext]).
if want := "/storage/app/uploads/public/651/a2b/3c4/thumb_12_200_200_0_0_crop.png"; thumb != want {
t.Fatalf("thumb = %q, want %q", thumb, want)
}
var nilFile *File
if nilFile.URL() != "" {
t.Fatal("nil file URL must be empty")
}
}
// webpFixture is a 16x12 lossless WebP: blue left half, red right half.
var webpFixture = []byte{
0x52, 0x49, 0x46, 0x46, 0x2a, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, 0x4c,
0x1d, 0x00, 0x00, 0x00, 0x2f, 0x0f, 0xc0, 0x02, 0x00, 0x0f, 0x70, 0x14, 0xfb, 0x53, 0xd0, 0x5e,
0x88, 0x7b, 0xfe, 0x83, 0x07, 0x62, 0xc1, 0x64, 0xfe, 0xd2, 0xbd, 0x21, 0x44, 0xf4, 0x3f, 0x74,
0x01, 0x00,
}
func TestThumbWebP(t *testing.T) {
cfg, format, err := image.DecodeConfig(bytes.NewReader(webpFixture))
if err != nil {
t.Fatalf("DecodeConfig: %v", err)
}
if format != "webp" || cfg.Width != 16 || cfg.Height != 12 {
t.Fatalf("config = %s %dx%d", format, cfg.Width, cfg.Height)
}
bucket := winterLayoutBucket(t)
f := &File{ID: 5, DiskName: "abcdef123456789012345.webp"}
if err := bucket.WriteAll(t.Context(), BlobKey(f.DiskName), webpFixture, &blob.WriterOptions{ContentType: "image/webp"}); err != nil {
t.Fatal(err)
}
url, err := f.Thumb(t.Context(), bucket, 200, 200, "crop")
if err != nil {
t.Fatalf("Thumb: %v", err)
}
if want := "/storage/app/uploads/public/abc/def/123/thumb_5_200_200_0_0_crop.webp"; url != want {
t.Fatalf("thumb url = %q, want %q", url, want)
}
raw, err := bucket.ReadAll(t.Context(), "abc/def/123/thumb_5_200_200_0_0_crop.webp")
if err != nil {
t.Fatal(err)
}
// imaging cannot encode webp: the thumb is JPEG bytes under the .webp name.
img, err := jpeg.Decode(bytes.NewReader(raw))
if err != nil {
t.Fatalf("thumb is not JPEG: %v", err)
}
if b := img.Bounds(); b.Dx() != 200 || b.Dy() != 200 {
t.Fatalf("thumb size = %v", b)
}
}
// pngHeaderOnly is a PNG holding only a valid IHDR (w x h, 8-bit RGBA) and
// IEND: image.DecodeConfig accepts it, a full decode would allocate w*h*4
// bytes.
func pngHeaderOnly(w, h uint32) []byte {
var buf bytes.Buffer
buf.WriteString("\x89PNG\r\n\x1a\n")
chunk := func(typ string, data []byte) {
_ = binary.Write(&buf, binary.BigEndian, uint32(len(data)))
buf.WriteString(typ)
buf.Write(data)
sum := crc32.NewIEEE()
sum.Write([]byte(typ))
sum.Write(data)
_ = binary.Write(&buf, binary.BigEndian, sum.Sum32())
}
ihdr := make([]byte, 13)
binary.BigEndian.PutUint32(ihdr[0:], w)
binary.BigEndian.PutUint32(ihdr[4:], h)
ihdr[8], ihdr[9] = 8, 6
chunk("IHDR", ihdr)
chunk("IEND", nil)
return buf.Bytes()
}
// TestThumbBrokenSourceServesPlaceholder: as WinterCMS's File::makeThumb
// does, an original that is missing, does not decode, or declares more
// pixels than the thumbnailer accepts gets WinterCMS's 200x200 broken-image
// picture as its thumbnail instead of an error, so one bad upload can never
// make every later listing fail (T-12-16). The placeholder is stored under
// the thumbnail key and reused. Invalid arguments are still errors.
func TestThumbBrokenSourceServesPlaceholder(t *testing.T) {
ctx := t.Context()
bucket := memblob.OpenBucket(nil)
t.Cleanup(func() { _ = bucket.Close() })
cases := []struct {
name string
original []byte // nil: no blob at all
}{
{"missing", nil},
{"undecodable", []byte("plain text, not an image")},
{"huge-canvas", pngHeaderOnly(30000, 30000)},
}
for i, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := &File{ID: uint(100 + i), DiskName: fmt.Sprintf("abc%03ddefghij.png", i)}
if tc.original != nil {
if err := bucket.WriteAll(ctx, BlobKey(f.DiskName), tc.original, nil); err != nil {
t.Fatal(err)
}
}
start := time.Now()
url, err := f.Thumb(ctx, bucket, 200, 200, "crop")
if err != nil {
t.Fatalf("Thumb: %v", err)
}
if time.Since(start) > 2*time.Second {
t.Fatalf("Thumb took %s", time.Since(start))
}
key := PartitionDirectory(f.DiskName) + ThumbFilename(f.ID, 200, 200, 0, 0, "crop", "png")
if url != PublicURL(key) {
t.Fatalf("url %q, want %q", url, PublicURL(key))
}
stored, err := bucket.ReadAll(ctx, key)
if err != nil {
t.Fatal(err)
}
cfg, format, err := image.DecodeConfig(bytes.NewReader(stored))
if err != nil || format != "png" || cfg.Width != 200 || cfg.Height != 200 {
t.Fatalf("placeholder is not the 200x200 PNG: %s %dx%d %v", format, cfg.Width, cfg.Height, err)
}
again, err := f.Thumb(ctx, bucket, 200, 200, "crop")
if err != nil || again != url {
t.Fatalf("second call: %q %v", again, err)
}
})
}
f := &File{ID: 1, DiskName: "abcdefghijkl.png"}
if _, err := f.Thumb(ctx, bucket, 200, 200, "../x"); err == nil {
t.Fatal("an invalid mode must stay an error")
}
if _, err := f.Thumb(ctx, bucket, 5000, 200, "crop"); err == nil {
t.Fatal("an out-of-range size must stay an error")
}
}
// TestThumbModesAndFormats generates crop, exact, auto and fit thumbnails
// from PNG, GIF and JPEG originals and checks the stored bytes decode to
// the requested box in the original's format.
func TestThumbModesAndFormats(t *testing.T) {
ctx := t.Context()
bucket := memblob.OpenBucket(nil)
t.Cleanup(func() { _ = bucket.Close() })
src := image.NewRGBA(image.Rect(0, 0, 120, 60))
encoders := map[string]func(*bytes.Buffer) error{
"png": func(b *bytes.Buffer) error { return png.Encode(b, src) },
"gif": func(b *bytes.Buffer) error { return gif.Encode(b, src, nil) },
"jpg": func(b *bytes.Buffer) error { return jpeg.Encode(b, src, nil) },
}
id := uint(500)
for ext, enc := range encoders {
var buf bytes.Buffer
if err := enc(&buf); err != nil {
t.Fatal(err)
}
for _, mode := range []string{"crop", "exact", "auto", "fit", "CROP"} {
id++
f := &File{ID: id, DiskName: fmt.Sprintf("m%02dmodes%03d.%s", id%100, id, ext)}
if err := bucket.WriteAll(ctx, BlobKey(f.DiskName), buf.Bytes(), nil); err != nil {
t.Fatal(err)
}
url, err := f.Thumb(ctx, bucket, 40, 40, mode)
if err != nil {
t.Fatalf("%s %s: %v", ext, mode, err)
}
key := strings.TrimPrefix(url, PublicPathPrefix()+"/")
raw, err := bucket.ReadAll(ctx, key)
if err != nil {
t.Fatal(err)
}
cfg, format, err := image.DecodeConfig(bytes.NewReader(raw))
if err != nil {
t.Fatal(err)
}
wantFormat := map[string]string{"png": "png", "gif": "gif", "jpg": "jpeg"}[ext]
if format != wantFormat {
t.Errorf("%s %s: thumbnail format %s", ext, mode, format)
}
switch strings.ToLower(mode) {
case "crop", "exact":
if cfg.Width != 40 || cfg.Height != 40 {
t.Errorf("%s %s: %dx%d, want 40x40", ext, mode, cfg.Width, cfg.Height)
}
default:
if cfg.Width != 40 || cfg.Height != 20 {
t.Errorf("%s %s: %dx%d, want 40x20 (fit)", ext, mode, cfg.Width, cfg.Height)
}
}
}
}
f := &File{ID: 1, DiskName: "noextension"}
if got := fileExt(f.DiskName); got != "jpg" {
t.Fatalf("fileExt without extension = %q", got)
}
if got := fileExt("A.PNG"); got != "png" {
t.Fatalf("fileExt upper case = %q", got)
}
if _, err := (*File)(nil).Thumb(ctx, bucket, 10, 10, "crop"); err == nil {
t.Fatal("nil file")
}
if _, err := f.Thumb(ctx, nil, 10, 10, "crop"); err == nil {
t.Fatal("nil bucket")
}
if _, err := (&File{ID: 2, DiskName: "abcdefghi.p-ng"}).Thumb(ctx, bucket, 10, 10, "crop"); err == nil {
t.Fatal("unsafe extension")
}
}
// TestBucketAndURLEdges covers bucket URL normalisation, OpenBucket and
// Publish refusals, partition and URL helpers and static prefix stripping.
func TestBucketAndURLEdges(t *testing.T) {
for raw, want := range map[string]string{
"mem://": "mem://",
"memory://anything": "mem://",
"fileblob:///tmp/x": "file:///tmp/x?create_dir=true",
"file:///tmp/y": "file:///tmp/y?create_dir=true",
"file:///tmp/z?create_dir=false": "file:///tmp/z?create_dir=false",
"s3://bucket?region=eu-central-1": "s3://bucket?region=eu-central-1",
} {
got, err := normalizeBucketURL(raw)
if err != nil || got != want {
t.Errorf("normalizeBucketURL(%q) = %q %v, want %q", raw, got, err, want)
}
}
if _, err := normalizeBucketURL("%zz"); err == nil {
t.Error("an unparsable bucket URL must be an error")
}
if _, err := OpenBucket(t.Context(), nil); err == nil {
t.Error("nil config")
}
open := func(body string) error {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "storage.yaml"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{Dir: dir, Env: "development", Environ: []string{"SUMMER_ENV=development"}})
if err != nil {
t.Fatal(err)
}
b, err := OpenBucket(t.Context(), cfg)
if err == nil {
_ = b.Close()
}
return err
}
if err := open("uploads:\n bucket_url: \"\"\n"); err == nil {
t.Error("an empty bucket_url must fail")
}
if err := open("uploads:\n bucket_url: \"nope://x\"\n"); err == nil {
t.Error("an unknown scheme must fail")
}
dir := t.TempDir()
if err := open("uploads:\n bucket_url: \"file://" + dir + "/up\"\n"); err != nil {
t.Fatalf("file bucket: %v", err)
}
t.Cleanup(func() { setPublicPathPrefix(defaultPublicPathPrefix) })
if got := PublicPathPrefix(); got != defaultPublicPathPrefix {
t.Fatalf("default prefix = %q", got)
}
if err := Publish(nil, memblob.OpenBucket(nil)); err == nil {
t.Error("Publish with a nil app")
}
if err := Publish(backpack.New(nil), nil); err == nil {
t.Error("Publish with a nil bucket")
}
if got := PartitionDirectory("ab"); got != "ab/" {
t.Errorf("short partition = %q", got)
}
if got := PartitionDirectory("abcdefghijkl"); got != "abc/def/ghi/" {
t.Errorf("partition = %q", got)
}
setPublicPathPrefix("")
if got := PublicURL("/a/b.png"); got != "/a/b.png" {
t.Errorf("PublicURL without prefix = %q", got)
}
setPublicPathPrefix("/files/")
if got := PublicURL("a/b.png"); got != "/files/a/b.png" {
t.Errorf("PublicURL with a trailing slash prefix = %q", got)
}
setPublicPathPrefix(defaultPublicPathPrefix)
if (*File)(nil).URL() != "" {
t.Error("nil File URL")
}
for _, tc := range []struct {
path, prefix, key string
ok bool
}{
{"/abc/def.png", "", "abc/def.png", true},
{"/storage", "/storage", "", false},
{"/storage/a.png", "/storage", "a.png", true},
{"/storagex/a.png", "/storage", "", false},
} {
key, ok := stripStaticPrefix(tc.path, tc.prefix)
if key != tc.key || ok != tc.ok {
t.Errorf("stripStaticPrefix(%q, %q) = %q %v", tc.path, tc.prefix, key, ok)
}
}
}
// TestPublicURLBase (plan 05 of phase 19.3, D-12) builds direct URLs from
// storage.uploads.public_url: trailing slashes on the base and leading
// slashes on the key collapse to one, File.URL and thumbnails use the base,
// and clearing it restores the proxy prefix.
func TestPublicURLBase(t *testing.T) {
t.Cleanup(func() {
setPublicPathPrefix(defaultPublicPathPrefix)
setPublicURLBase("")
})
key := BlobKey("651a2b3c4d5e61234567.png")
for _, tc := range []struct{ base, key, want string }{
{"https://cdn.example.com", key, "https://cdn.example.com/" + key},
{"https://cdn.example.com/", key, "https://cdn.example.com/" + key},
{"https://cdn.example.com/uploads//", "/" + key, "https://cdn.example.com/uploads/" + key},
{"http://bucket.s3.example.com/a/b", "//" + key, "http://bucket.s3.example.com/a/b/" + key},
} {
setPublicURLBase(tc.base)
if got := PublicURL(tc.key); got != tc.want {
t.Errorf("base %q key %q: PublicURL = %q, want %q", tc.base, tc.key, got, tc.want)
}
}
bucket := memblob.OpenBucket(nil)
t.Cleanup(func() { _ = bucket.Close() })
setPublicURLBase("https://cdn.example.com/uploads/")
if PublicURLBase() != "https://cdn.example.com/uploads/" {
t.Fatalf("PublicURLBase = %q", PublicURLBase())
}
f := &File{ID: 31, DiskName: "651a2b3c4d5e61234567.png"}
if got, want := f.URL(), "https://cdn.example.com/uploads/651/a2b/3c4/651a2b3c4d5e61234567.png"; got != want {
t.Fatalf("File.URL = %q, want %q", got, want)
}
if err := bucket.WriteAll(t.Context(), BlobKey(f.DiskName), testJPEG(t), nil); err != nil {
t.Fatal(err)
}
thumb, err := f.Thumb(t.Context(), bucket, 40, 30, "crop")
if err != nil {
t.Fatal(err)
}
if want := "https://cdn.example.com/uploads/651/a2b/3c4/thumb_31_40_30_0_0_crop.png"; thumb != want {
t.Fatalf("thumb = %q, want %q", thumb, want)
}
setPublicURLBase("")
if got := f.URL(); got != "/storage/uploads/651/a2b/3c4/651a2b3c4d5e61234567.png" {
t.Fatalf("without a base File.URL = %q", got)
}
}
// TestPublicURLValidation refuses every malformed storage.uploads.public_url
// at OpenBucket, naming the key and never echoing credentials; an unset or
// blank value keeps the proxy prefix.
func TestPublicURLValidation(t *testing.T) {
t.Cleanup(func() {
setPublicPathPrefix(defaultPublicPathPrefix)
setPublicURLBase("")
})
for _, bad := range []string{
"ftp://cdn.example.com/uploads",
"file:///srv/uploads",
"https://user:secret@cdn.example.com",
"https://token@cdn.example.com",
"https://cdn.example.com/uploads?v=1",
"https://cdn.example.com/uploads?",
"https://cdn.example.com/uploads#top",
"/storage/uploads",
"cdn.example.com/uploads",
"https:///uploads",
"https://cdn.example.com:bad/",
} {
setPublicURLBase("https://stale.example.com")
cfg := openTestConfig(t, "uploads:\n bucket_url: \"mem://\"\n public_url: \""+bad+"\"\n")
bucket, err := OpenBucket(t.Context(), cfg)
if err == nil {
_ = bucket.Close()
t.Fatalf("public_url %q accepted", bad)
}
if !strings.Contains(err.Error(), "storage.uploads.public_url") || strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "token@") {
t.Fatalf("public_url %q: error %q", bad, err)
}
if _, perr := parsePublicURL(bad); perr == nil {
t.Fatalf("parsePublicURL(%q) accepted", bad)
}
}
for _, unset := range []string{"", " "} {
setPublicURLBase("https://stale.example.com")
cfg := openTestConfig(t, "uploads:\n bucket_url: \"mem://\"\n public_url: \""+unset+"\"\n")
bucket, err := OpenBucket(t.Context(), cfg)
if err != nil {
t.Fatalf("public_url %q: %v", unset, err)
}
_ = bucket.Close()
if PublicURLBase() != "" || PublicURL("a/b.png") != "/storage/uploads/a/b.png" {
t.Fatalf("public_url %q: base %q URL %q", unset, PublicURLBase(), PublicURL("a/b.png"))
}
}
for _, ok := range []string{"https://cdn.example.com", "HTTP://cdn.example.com:8080/u/", " https://cdn.example.com/uploads "} {
u, err := parsePublicURL(ok)
if err != nil || u == nil {
t.Fatalf("parsePublicURL(%q) = %v, %v", ok, u, err)
}
}
}
func TestStoreProtectedNeedsPrivateBucket(t *testing.T) {
// Not parallel: the public URL base is package state.
t.Cleanup(func() { setPublicURLBase("") })
bucket := memblob.OpenBucket(nil)
t.Cleanup(func() { _ = bucket.Close() })
db := &gorm.DB{}
setPublicURLBase("https://cdn.example.com/uploads/")
_, err := Store(t.Context(), db, bucket, Upload{FileName: "notes.txt", Body: bytes.NewReader([]byte("secret"))}, Limits{})
if !errors.Is(err, ErrProtectedNeedsPrivateBucket) {
t.Fatalf("protected upload with public_url: error = %v, want ErrProtectedNeedsPrivateBucket", err)
}
for _, want := range []string{"storage.uploads.public_url", "private bucket"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error %q lacks %q", err, want)
}
}
iter := bucket.List(nil)
if obj, err := iter.Next(t.Context()); err == nil {
t.Fatalf("a blob was written: %s", obj.Key)
}
}