package surf import ( "context" "database/sql" "net/http" "net/http/httptest" "strings" "testing" "time" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/compass" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/lagoon/attach" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/party" _ "github.com/jackc/pgx/v5/stdlib" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/modules/postgres" "gocloud.dev/blob" "gocloud.dev/blob/memblob" "gorm.io/gorm" ) // uploadsConfig is an http config with the body limits and the given // storage.uploads keys set. func uploadsConfig(t *testing.T, keys map[string]any) *compass.Config { t.Helper() cfg := writeHTTPConfig(t, "body_limits:\n default_bytes: 1024\n upload_bytes: 1024\n") for k, v := range keys { if err := cfg.Set(k, v); err != nil { t.Fatal(err) } } return cfg } // uploadsPathPlugin registers GET under the uploads prefix. type uploadsPathPlugin struct{ path string } func (uploadsPathPlugin) ID() string { return "acme.media" } func (uploadsPathPlugin) Requires() []string { return nil } func (uploadsPathPlugin) Register(*backpack.App) error { return nil } func (uploadsPathPlugin) Boot(*backpack.App) error { return nil } func (p uploadsPathPlugin) Routes(r pact.Router) error { r.Get(p.path, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("plugin")) }) return nil } // uploadsGorm starts a Postgres container with the framework migrations // (system_files). An unavailable Docker daemon fails the test. func uploadsGorm(t *testing.T) *gorm.DB { t.Helper() if testing.Short() { t.Skip("requires testcontainers postgres") } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) t.Cleanup(cancel) ctr, err := postgres.Run(ctx, "postgres:16-alpine", postgres.WithDatabase("uploads"), postgres.WithUsername("uploads"), postgres.WithPassword("uploads"), postgres.BasicWaitStrategies(), ) if err != nil { t.Fatalf("postgres: %v", err) } t.Cleanup(func() { _ = testcontainers.TerminateContainer(ctr) }) dsn, err := ctr.ConnectionString(ctx, "sslmode=disable") if err != nil { t.Fatal(err) } db, err := sql.Open("pgx", dsn) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = db.Close() }) if err := db.PingContext(ctx); err != nil { t.Fatal(err) } gdb, err := lagoon.Use(ctx, db) if err != nil { t.Fatal(err) } if err := lagoon.Migrate(gdb, nil); err != nil { t.Fatal(err) } return gdb } type uploadsResponse struct { code int header http.Header body string } func uploadsDo(h http.Handler, method, target string) uploadsResponse { rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(method, target, nil)) return uploadsResponse{code: rec.Code, header: rec.Header(), body: rec.Body.String()} } // uploadsRoutes returns the routes summercms.surf owns. func uploadsRoutes(t *testing.T, app *backpack.App, plugins []party.Plugin) []RouteInfo { t.Helper() rt, err := BuildRouter(app, plugins) if err != nil { t.Fatal(err) } var out []RouteInfo for _, info := range rt.Routes() { if info.PluginID == uploadsPluginID { out = append(out, info) } } return out } const ( uploadsPublicDisk = "abc123xyz.png" uploadsPrivateDisk = "def456uvw.png" uploadsOrphanDisk = "ghi789rst.png" uploadsPublicBody = "public-png-bytes" ) // TestUploadsMount: with storage.uploads.serve off (or absent) no route is // registered; on, GET /{path...} serves a public blob with its // content type and nosniff, answers HEAD without a body, and 404s a // private row, a missing row, traversal paths and an unpublished bucket or // database. A custom prefix is honoured, and a plugin route at or under // the prefix fails BuildRouter. func TestUploadsMount(t *testing.T) { publicPath := "/storage/uploads/abc/123/xyz/" + uploadsPublicDisk t.Run("off registers nothing", func(t *testing.T) { for _, keys := range []map[string]any{nil, {"storage.uploads.serve": false}} { app := backpack.New(uploadsConfig(t, keys)) if routes := uploadsRoutes(t, app, nil); len(routes) != 0 { t.Fatalf("keys %v: uploads routes %v", keys, routes) } h, err := Assemble(app, nil) if err != nil { t.Fatal(err) } if r := uploadsDo(h, http.MethodGet, publicPath); r.code != http.StatusNotFound { t.Fatalf("keys %v: GET %s = %d, want 404", keys, publicPath, r.code) } } }) t.Run("on registers one GET route", func(t *testing.T) { app := backpack.New(uploadsConfig(t, map[string]any{"storage.uploads.serve": true})) routes := uploadsRoutes(t, app, nil) if len(routes) != 1 || routes[0].Method != http.MethodGet || routes[0].Pattern != "/storage/uploads/{path...}" || !routes[0].Raw { t.Fatalf("uploads routes = %+v", routes) } }) t.Run("a plugin route at or under the prefix fails BuildRouter", func(t *testing.T) { for _, path := range []string{"/storage/uploads/{path...}", "/storage/uploads/avatars/{id}", "/storage/uploads"} { app := backpack.New(uploadsConfig(t, map[string]any{"storage.uploads.serve": true})) _, err := BuildRouter(app, []party.Plugin{uploadsPathPlugin{path: path}}) if err == nil || !strings.Contains(err.Error(), "storage.uploads") { t.Fatalf("plugin route %s: err = %v, want a refusal naming storage.uploads", path, err) } } // A sibling path and the same path with the key off are fine. app := backpack.New(uploadsConfig(t, map[string]any{"storage.uploads.serve": true})) if _, err := BuildRouter(app, []party.Plugin{uploadsPathPlugin{path: "/storage/uploadsx/{id}"}}); err != nil { t.Fatalf("sibling path: %v", err) } app = backpack.New(uploadsConfig(t, nil)) if _, err := BuildRouter(app, []party.Plugin{uploadsPathPlugin{path: "/storage/uploads/avatars/{id}"}}); err != nil { t.Fatalf("key off: %v", err) } }) t.Run("nothing published answers 404", func(t *testing.T) { app := backpack.New(uploadsConfig(t, map[string]any{"storage.uploads.serve": true})) h, err := Assemble(app, nil) if err != nil { t.Fatal(err) } r := uploadsDo(h, http.MethodGet, publicPath) if r.code != http.StatusNotFound || r.header.Get("X-Content-Type-Options") != "nosniff" { t.Fatalf("no bucket, no database: %d nosniff=%q", r.code, r.header.Get("X-Content-Type-Options")) } bucket := memblob.OpenBucket(nil) t.Cleanup(func() { _ = bucket.Close() }) if err := bucket.WriteAll(t.Context(), attach.BlobKey(uploadsPublicDisk), []byte(uploadsPublicBody), nil); err != nil { t.Fatal(err) } if err := app.Publish(bucket); err != nil { t.Fatal(err) } if r := uploadsDo(h, http.MethodGet, publicPath); r.code != http.StatusNotFound { t.Fatalf("bucket without a database: %d, want 404", r.code) } }) gdb := uploadsGorm(t) ctx := t.Context() bucket := memblob.OpenBucket(nil) t.Cleanup(func() { _ = bucket.Close() }) pub, priv := true, false for _, f := range []attach.File{ {DiskName: uploadsPublicDisk, FileName: "cover.png", FileSize: 1, ContentType: "image/png", IsPublic: &pub}, {DiskName: uploadsPrivateDisk, FileName: "secret.png", FileSize: 1, ContentType: "image/png", IsPublic: &priv}, } { if err := gdb.Create(&f).Error; err != nil { t.Fatal(err) } } for disk, body := range map[string]string{ uploadsPublicDisk: uploadsPublicBody, uploadsPrivateDisk: "private-png-bytes", uploadsOrphanDisk: "orphan-png-bytes", } { if err := bucket.WriteAll(ctx, attach.BlobKey(disk), []byte(body), &blob.WriterOptions{ContentType: "image/png"}); err != nil { t.Fatal(err) } } t.Run("database without a bucket answers 404", func(t *testing.T) { app := backpack.New(uploadsConfig(t, map[string]any{"storage.uploads.serve": true})) if err := app.Publish(gdb); err != nil { t.Fatal(err) } h, err := Assemble(app, nil) if err != nil { t.Fatal(err) } if r := uploadsDo(h, http.MethodGet, publicPath); r.code != http.StatusNotFound { t.Fatalf("database without a bucket: %d, want 404", r.code) } }) serving := func(t *testing.T, prefix string) http.Handler { t.Helper() keys := map[string]any{"storage.uploads.serve": true} if prefix != "" { keys["storage.uploads.public_path_prefix"] = prefix } app := backpack.New(uploadsConfig(t, keys)) if err := app.Publish(gdb); err != nil { t.Fatal(err) } if err := app.Publish(bucket); err != nil { t.Fatal(err) } h, err := Assemble(app, nil) if err != nil { t.Fatal(err) } return h } t.Run("serves public blobs and refuses the rest", func(t *testing.T) { h := serving(t, "") r := uploadsDo(h, http.MethodGet, publicPath) if r.code != http.StatusOK || r.body != uploadsPublicBody { t.Fatalf("GET %s = %d %q", publicPath, r.code, r.body) } if ct := r.header.Get("Content-Type"); ct != "image/png" { t.Fatalf("Content-Type = %q, want image/png", ct) } if got := r.header.Get("X-Content-Type-Options"); got != "nosniff" { t.Fatalf("X-Content-Type-Options = %q, want nosniff", got) } head := uploadsDo(h, http.MethodHead, publicPath) if head.code != http.StatusOK || head.body != "" || head.header.Get("Content-Type") != "image/png" || head.header.Get("X-Content-Type-Options") != "nosniff" { t.Fatalf("HEAD %s = %d %q %v", publicPath, head.code, head.body, head.header) } for _, target := range []string{ "/storage/uploads/def/456/uvw/" + uploadsPrivateDisk, // is_public false "/storage/uploads/ghi/789/rst/" + uploadsOrphanDisk, // blob without a row "/storage/uploads/zzz/999/yyy/zzz999yyy.png", // neither "/storage/uploads/abc/123/" + uploadsPublicDisk, // partition too short "/storage/uploads/def/456/xyz/" + uploadsPublicDisk, // wrong partition "/storage/uploads/abc/123/xyz/..%2f..%2f..%2fsecret", // encoded traversal "/storage/uploads/abc/123/xyz/%2e%2e%2f" + uploadsPrivateDisk, "/storage/uploads/abc/123/xyz/..%5c" + uploadsPrivateDisk, // encoded backslash } { r := uploadsDo(h, http.MethodGet, target) if r.code != http.StatusNotFound || r.header.Get("X-Content-Type-Options") != "nosniff" { t.Fatalf("GET %s = %d nosniff=%q, want 404 with nosniff", target, r.code, r.header.Get("X-Content-Type-Options")) } if strings.Contains(r.body, "png-bytes") { t.Fatalf("GET %s leaked a blob: %q", target, r.body) } } // A raw dot-dot path never reaches the handler: ServeMux answers it // with a redirect to the cleaned path, which is then served or // refused by the same rules. for target, want := range map[string]int{ "/storage/uploads/abc/123/../../def/456/uvw/" + uploadsPrivateDisk: http.StatusNotFound, "/storage/uploads/../../../etc/passwd": http.StatusNotFound, } { r := uploadsDo(h, http.MethodGet, target) if strings.Contains(r.body, "png-bytes") { t.Fatalf("GET %s leaked a blob: %d %q", target, r.code, r.body) } if r.code == http.StatusMovedPermanently || r.code == http.StatusTemporaryRedirect || r.code == http.StatusPermanentRedirect { loc := r.header.Get("Location") if strings.Contains(loc, "..") { t.Fatalf("GET %s redirects to %q", target, loc) } if got := uploadsDo(h, http.MethodGet, loc); got.code != want { t.Fatalf("GET %s -> %s = %d, want %d", target, loc, got.code, want) } continue } if r.code != want { t.Fatalf("GET %s = %d, want %d or a redirect", target, r.code, want) } } }) t.Run("a custom prefix is honoured", func(t *testing.T) { h := serving(t, "/media/files/") custom := "/media/files/abc/123/xyz/" + uploadsPublicDisk if r := uploadsDo(h, http.MethodGet, custom); r.code != http.StatusOK || r.body != uploadsPublicBody || r.header.Get("X-Content-Type-Options") != "nosniff" { t.Fatalf("GET %s = %d %q", custom, r.code, r.body) } if r := uploadsDo(h, http.MethodGet, publicPath); r.code != http.StatusNotFound { t.Fatalf("GET %s under the default prefix = %d, want 404", publicPath, r.code) } }) }