fix(admin): keep account photos out of the media library
Test / test (push) Successful in 8m35s

This commit is contained in:
2026-09-29 23:47:27 +02:00
parent ba6416623f
commit 81d22b24a7
8 changed files with 233 additions and 15 deletions
+72 -2
View File
@@ -5,6 +5,7 @@ package store
import (
"os"
"path"
"path/filepath"
"strings"
"sync"
@@ -364,8 +365,8 @@ func TestStoreUploadAndListMedia(t *testing.T) {
if _, err := s.MediaPath(name); err != nil {
t.Fatalf("MediaPath cannot find the upload: %v", err)
}
if _, err := s.MediaPath("../" + name); err != nil {
t.Fatalf("a base name should still resolve: %v", err)
if _, err := s.MediaPath("../" + name); err == nil {
t.Fatal("MediaPath accepted a name that climbs out of the media directory")
}
if _, err := s.MediaPath("notes.txt"); err == nil {
t.Fatal("MediaPath accepted a name that is not an image")
@@ -383,6 +384,75 @@ func TestStoreUploadAndListMedia(t *testing.T) {
if s.DeleteMedia("/etc/passwd") {
t.Fatal("DeleteMedia accepted non-media URL")
}
// A URL that resolves to a directory removes nothing: the media and
// avatar directories are namespaces, not deletable content.
if s.DeleteMedia("/media/") {
t.Fatal("DeleteMedia accepted the media directory itself")
}
}
// A profile photo is account state, not library content: it lives under
// avatars/ inside the media directory, which the listing skips, while
// the public route serves it and the delete path removes it.
func TestStoreAvatarLivesOutsideTheLibrary(t *testing.T) {
s, _ := newStore(t)
webpData := append([]byte("RIFF"), 0, 0, 0, 0)
webpData = append(webpData, []byte("WEBPVP8 ")...)
url, err := s.StoreAvatar(webpData)
if err != nil {
t.Fatalf("StoreAvatar: %v", err)
}
if !strings.HasPrefix(url, "/media/avatars/") || !strings.HasSuffix(url, ".webp") {
t.Fatalf("url = %q", url)
}
name := strings.TrimPrefix(url, "/media/") // avatars/<uuid>.webp
if got, err := s.MediaPath(name); err != nil {
t.Fatalf("MediaPath cannot find the avatar: %v", err)
} else if !strings.Contains(filepath.ToSlash(got), "/media/avatars/") {
t.Fatalf("avatar path = %q", got)
}
if _, err := s.MediaPath("avatars/" + path.Base(name)); err != nil {
t.Fatalf("MediaPath cannot find the avatar by its route shape: %v", err)
}
if media := s.ListMedia(); len(media) != 0 {
t.Fatalf("the avatar leaked into the library: %v", media)
}
if !s.DeleteMedia(url) {
t.Fatal("DeleteMedia failed for the avatar")
}
if s.DeleteMedia(url) {
t.Fatal("DeleteMedia succeeded twice")
}
if _, err := s.MediaPath(name); err == nil {
t.Fatal("the avatar survived its deletion")
}
}
// The avatar namespace is exactly one level below the media directory:
// anything deeper, anything outside it and anything without an image
// extension is refused, so the public route gains no reach.
func TestMediaPathRefusesNonMediaNames(t *testing.T) {
s, _ := newStore(t)
for _, name := range []string{
"avatars/../x.webp",
"avatars/a/b.webp",
"sub/x.webp",
"avatarss/x.webp",
"avatars/x.txt",
"avatars/",
"avatars",
"..",
"a/../b.webp",
} {
if got, err := s.MediaPath(name); err == nil {
t.Fatalf("MediaPath(%q) = %q, want a refusal", name, got)
}
}
// The avatar directory itself is not deletable content, with or
// without a trailing slash.
if s.DeleteMedia("/media/avatars") || s.DeleteMedia("/media/avatars/") {
t.Fatal("DeleteMedia removed the avatar directory")
}
}
func TestStoreUploadSVGAndDimensions(t *testing.T) {