From ebbe533c6a8c1876732d71385c2571673977ede5 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 00:51:13 -0400 Subject: [PATCH] fix(artwork): reject malformed hashes in ImageStore operations Known-absent states carry an empty hash and malformed persisted hashes could panic path sharding or inject separators; Write/Open/Remove now return an error for anything but 16 lowercase hex chars. --- core/artwork/image_store.go | 23 +++++++++++++++++++++++ core/artwork/image_store_test.go | 9 +++++++++ 2 files changed, 32 insertions(+) diff --git a/core/artwork/image_store.go b/core/artwork/image_store.go index 178c19d10..4b4e482c9 100644 --- a/core/artwork/image_store.go +++ b/core/artwork/image_store.go @@ -47,11 +47,28 @@ func extForMime(m string) string { return ".img" } +// validHash rejects anything but 16 lowercase hex chars: known-absent states carry "", +// and malformed persisted hashes must never reach path sharding (slice panics, separators). +func validHash(hash string) bool { + if len(hash) != 16 { + return false + } + for _, c := range []byte(hash) { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { + return false + } + } + return true +} + func (s *ImageStore) path(hash, mimeType string) string { return filepath.Join(s.root, hash[0:2], hash[2:4], hash+extForMime(mimeType)) } func (s *ImageStore) Write(hash, mimeType string, r io.Reader) error { + if !validHash(hash) { + return fmt.Errorf("imagestore: invalid hash %q", hash) + } dst := s.path(hash, mimeType) if _, err := os.Stat(dst); err == nil { // A touched mtime marks the file live so a concurrent prune spares it. @@ -80,12 +97,18 @@ func (s *ImageStore) Write(hash, mimeType string, r io.Reader) error { } func (s *ImageStore) Open(hash, mimeType string) (io.ReadCloser, error) { + if !validHash(hash) { + return nil, fmt.Errorf("imagestore: invalid hash %q", hash) + } return os.Open(s.path(hash, mimeType)) } // Remove deletes the store file unless it is newer than olderThan, in which case // an overlapping acquisition may have just touched it and be about to commit its row. func (s *ImageStore) Remove(hash, mimeType string, olderThan time.Time) error { + if !validHash(hash) { + return fmt.Errorf("imagestore: invalid hash %q", hash) + } path := s.path(hash, mimeType) info, err := os.Stat(path) if errors.Is(err, fs.ErrNotExist) { diff --git a/core/artwork/image_store_test.go b/core/artwork/image_store_test.go index 0bf721623..214cab3ad 100644 --- a/core/artwork/image_store_test.go +++ b/core/artwork/image_store_test.go @@ -90,6 +90,15 @@ var _ = Describe("ImageStore", func() { Expect(store.Remove("beefbeefbeefbeef", "image/jpeg", time.Now())).To(Succeed()) }) + It("rejects invalid hashes instead of panicking", func() { + for _, h := range []string{"", "ab", "BEEFBEEFBEEFBEEF", "../../../../etcpw", "beefbeefbeefbee/"} { + Expect(store.Write(h, "image/jpeg", bytes.NewReader([]byte("x")))).To(MatchError(ContainSubstring("invalid hash"))) + _, err := store.Open(h, "image/jpeg") + Expect(err).To(MatchError(ContainSubstring("invalid hash"))) + Expect(store.Remove(h, "image/jpeg", time.Now())).To(MatchError(ContainSubstring("invalid hash"))) + } + }) + It("spares a file newer than the cutoff, removes an aged one", func() { fresh := []byte("fresh") hf, _ := HashImage(bytes.NewReader(fresh))