Files
multica/server/internal/storage/local_test.go
Tom Qiao 295df8d928 fix(security): force attachment disposition for SVG uploads (#3023)
SVG files are XML and can carry <script>, <foreignObject>, or onload=
attributes that execute in the document's origin when rendered inline.
The upload handler maps .svg to image/svg+xml, and storage backends
(local + S3) previously set Content-Disposition: inline based on the
image/ prefix in isInlineContentType. A workspace member could upload
a crafted SVG, share its attachment URL in an issue or comment, and any
teammate who clicks the link would execute attacker-controlled JS in
the application's first-party origin (reading auth cookies, posting to
authenticated endpoints).

Exclude image/svg+xml from isInlineContentType so both storage paths
serve SVG with Content-Disposition: attachment.

Test coverage:
- New util_test.go covers the inline/attachment matrix including SVG.
- Existing local_test.go ContentDisposition table gains an SVG case.

Co-authored-by: Tom Qiao <tomqiaozc@users.noreply.github.com>
2026-05-22 12:51:43 +08:00

521 lines
15 KiB
Go

package storage
import (
"context"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
)
func TestLocalStorage_Upload(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
os.Unsetenv("LOCAL_UPLOAD_BASE_URL")
// No LOCAL_UPLOAD_BASE_URL set - should return relative path
store := NewLocalStorageFromEnv()
if store == nil {
t.Fatal("NewLocalStorageFromEnv returned nil")
}
ctx := context.Background()
data := []byte("hello world")
contentType := "text/plain"
filename := "test.txt"
link, err := store.Upload(ctx, "test-key.txt", data, contentType, filename)
if err != nil {
t.Fatalf("Upload failed: %v", err)
}
expectedLink := "/uploads/test-key.txt"
if link != expectedLink {
t.Errorf("link = %q, want %q", link, expectedLink)
}
filePath := filepath.Join(tmpDir, "test-key.txt")
stored, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("failed to read uploaded file: %v", err)
}
if string(stored) != string(data) {
t.Errorf("stored data = %q, want %q", stored, data)
}
}
func TestLocalStorage_Upload_WithBaseURL(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
t.Setenv("LOCAL_UPLOAD_BASE_URL", "http://localhost:8080")
store := NewLocalStorageFromEnv()
if store == nil {
t.Fatal("NewLocalStorageFromEnv returned nil")
}
ctx := context.Background()
data := []byte("hello world")
contentType := "text/plain"
filename := "test.txt"
link, err := store.Upload(ctx, "test-key.txt", data, contentType, filename)
if err != nil {
t.Fatalf("Upload failed: %v", err)
}
// When LOCAL_UPLOAD_BASE_URL is set, should return full URL
expectedLink := "http://localhost:8080/uploads/test-key.txt"
if link != expectedLink {
t.Errorf("link = %q, want %q", link, expectedLink)
}
filePath := filepath.Join(tmpDir, "test-key.txt")
stored, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("failed to read uploaded file: %v", err)
}
if string(stored) != string(data) {
t.Errorf("stored data = %q, want %q", stored, data)
}
}
func TestLocalStorage_Delete(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
store := NewLocalStorageFromEnv()
if store == nil {
t.Fatal("NewLocalStorageFromEnv returned nil")
}
ctx := context.Background()
data := []byte("hello world")
_, err := store.Upload(ctx, "delete-me.txt", data, "text/plain", "delete-me.txt")
if err != nil {
t.Fatalf("Upload failed: %v", err)
}
filePath := filepath.Join(tmpDir, "delete-me.txt")
if _, err := os.ReadFile(filePath); err != nil {
t.Fatalf("file should exist: %v", err)
}
store.Delete(ctx, "delete-me.txt")
if _, err := os.ReadFile(filePath); !os.IsNotExist(err) {
t.Errorf("file should be deleted")
}
}
func TestLocalStorage_KeyFromURL(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
// No baseURL set
store := NewLocalStorageFromEnv()
if store == nil {
t.Fatal("NewLocalStorageFromEnv returned nil")
}
tests := []struct {
name string
rawURL string
expected string
}{
{"local URL format", "/uploads/abc123.png", "abc123.png"},
{"local URL with subdir", "/uploads/2024/01/image.jpg", "2024/01/image.jpg"},
{"local URL with workspace prefix", "/uploads/workspaces/ws-123/abc.png", "workspaces/ws-123/abc.png"},
{"just filename", "abc123.png", "abc123.png"},
{"full path", "/some/path/to/file.pdf", "file.pdf"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := store.KeyFromURL(tc.rawURL)
if got != tc.expected {
t.Errorf("KeyFromURL(%q) = %q, want %q", tc.rawURL, got, tc.expected)
}
})
}
}
func TestLocalStorage_KeyFromURL_WithBaseURL(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
t.Setenv("LOCAL_UPLOAD_BASE_URL", "http://localhost:8080")
store := NewLocalStorageFromEnv()
if store == nil {
t.Fatal("NewLocalStorageFromEnv returned nil")
}
tests := []struct {
name string
rawURL string
expected string
}{
{"full URL format", "http://localhost:8080/uploads/abc123.png", "abc123.png"},
{"full URL with subdir", "http://localhost:8080/uploads/2024/01/image.jpg", "2024/01/image.jpg"},
{"local URL format still works", "/uploads/abc123.png", "abc123.png"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := store.KeyFromURL(tc.rawURL)
if got != tc.expected {
t.Errorf("KeyFromURL(%q) = %q, want %q", tc.rawURL, got, tc.expected)
}
})
}
}
func TestLocalStorage_DeleteKeys(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
store := NewLocalStorageFromEnv()
if store == nil {
t.Fatal("NewLocalStorageFromEnv returned nil")
}
ctx := context.Background()
data := []byte("hello world")
keys := []string{"file1.txt", "file2.txt", "file3.txt"}
for _, key := range keys {
_, err := store.Upload(ctx, key, data, "text/plain", key)
if err != nil {
t.Fatalf("Upload %s failed: %v", key, err)
}
}
store.DeleteKeys(ctx, keys)
for _, key := range keys {
filePath := filepath.Join(tmpDir, key)
if _, err := os.ReadFile(filePath); !os.IsNotExist(err) {
t.Errorf("file %s should be deleted", key)
}
}
}
func TestLocalStorage_KeyFromURL_Empty(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
store := NewLocalStorageFromEnv()
if store == nil {
t.Fatal("NewLocalStorageFromEnv returned nil")
}
if got := store.KeyFromURL(""); got != "" {
t.Errorf("KeyFromURL(\"\") = %q, want empty string", got)
}
}
// TestLocalStorage_ServeFile_ContentDispositionFromSidecar verifies the fix
// for issue #2442: downloads served from /uploads/* must carry the original
// uploaded filename in Content-Disposition, mirroring the S3 Upload path's
// existing ContentDisposition behavior. Without this, browsers fall back to
// the storage-key basename (UUID + ext) for the download filename.
func TestLocalStorage_ServeFile_ContentDispositionFromSidecar(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
store := NewLocalStorageFromEnv()
if store == nil {
t.Fatal("NewLocalStorageFromEnv returned nil")
}
cases := []struct {
name string
key string
contentType string
filename string
wantHeader string
}{
{
name: "attachment for non-inline type",
key: "workspaces/ws-1/abc.xlsx",
contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
filename: "Bwave JE_V1.xlsx",
wantHeader: `attachment; filename="Bwave JE_V1.xlsx"`,
},
{
name: "inline for image",
key: "workspaces/ws-1/def.png",
contentType: "image/png",
filename: "screenshot 2026-05-11.png",
wantHeader: `inline; filename="screenshot 2026-05-11.png"`,
},
{
name: "filename with header-injection characters is sanitized",
key: "workspaces/ws-1/ghi.txt",
contentType: "text/plain",
filename: "weird\";name.txt",
wantHeader: `attachment; filename="weird__name.txt"`,
},
{
// SVG can carry <script>/onload — never serve inline.
name: "attachment for svg (stored-XSS prevention)",
key: "workspaces/ws-1/jkl.svg",
contentType: "image/svg+xml",
filename: "logo.svg",
wantHeader: `attachment; filename="logo.svg"`,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
if _, err := store.Upload(ctx, tc.key, []byte("body"), tc.contentType, tc.filename); err != nil {
t.Fatalf("Upload failed: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/uploads/"+tc.key, nil)
rec := httptest.NewRecorder()
store.ServeFile(rec, req, tc.key)
got := rec.Header().Get("Content-Disposition")
if got != tc.wantHeader {
t.Errorf("Content-Disposition = %q, want %q", got, tc.wantHeader)
}
})
}
}
// TestLocalStorage_ServeFile_NoSidecarFallback documents the graceful
// degradation path: files uploaded before the sidecar landed (or written
// out-of-band) have no .meta.json on disk and ServeFile must not set
// Content-Disposition — leaving the existing pre-fix behavior intact.
func TestLocalStorage_ServeFile_NoSidecarFallback(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
store := NewLocalStorageFromEnv()
if store == nil {
t.Fatal("NewLocalStorageFromEnv returned nil")
}
key := "legacy-no-sidecar.txt"
if err := os.WriteFile(filepath.Join(tmpDir, key), []byte("body"), 0644); err != nil {
t.Fatalf("seed write failed: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/uploads/"+key, nil)
rec := httptest.NewRecorder()
store.ServeFile(rec, req, key)
if got := rec.Header().Get("Content-Disposition"); got != "" {
t.Errorf("Content-Disposition = %q, want empty (no sidecar fallback)", got)
}
}
// TestLocalStorage_ServeFile_RejectsSidecarSuffix verifies that the sidecar
// JSON itself is not addressable via /uploads/*. The sidecar is an
// implementation detail; exposing it would turn the filename + content-type
// pair into a stable read API and make any future ACL change leakier than
// the data file it sits next to.
func TestLocalStorage_ServeFile_RejectsSidecarSuffix(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
store := NewLocalStorageFromEnv()
if store == nil {
t.Fatal("NewLocalStorageFromEnv returned nil")
}
ctx := context.Background()
if _, err := store.Upload(ctx, "abc.xlsx", []byte("body"), "text/plain", "real.xlsx"); err != nil {
t.Fatalf("Upload failed: %v", err)
}
sidecarKey := "abc.xlsx" + metaSuffix
req := httptest.NewRequest(http.MethodGet, "/uploads/"+sidecarKey, nil)
rec := httptest.NewRecorder()
store.ServeFile(rec, req, sidecarKey)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", rec.Code)
}
if got := rec.Header().Get("Content-Disposition"); got != "" {
t.Errorf("Content-Disposition = %q, want empty", got)
}
}
// TestLocalStorage_ServeFile_RejectsPathTraversal documents that a key
// pointing outside uploadDir is rejected before any sidecar read. Without
// this guard, readLocalMeta would attempt a disk read at <some-path>.meta.json
// before http.ServeFile's own ".." check fires on r.URL.Path.
func TestLocalStorage_ServeFile_RejectsPathTraversal(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
store := NewLocalStorageFromEnv()
if store == nil {
t.Fatal("NewLocalStorageFromEnv returned nil")
}
// Seed a sidecar OUTSIDE uploadDir so we'd notice if it were read: the
// header would carry "leaked.xlsx". Locating the sibling inside the
// per-test TempDir keeps the test self-contained — no real /etc reads.
parentDir := filepath.Dir(tmpDir)
leakedBase := filepath.Join(parentDir, "leaked-target")
if err := os.WriteFile(leakedBase+metaSuffix, []byte(`{"filename":"leaked.xlsx","content_type":"text/plain"}`), 0644); err != nil {
t.Fatalf("seed leaked sidecar failed: %v", err)
}
t.Cleanup(func() {
os.Remove(leakedBase + metaSuffix)
})
traversal := "../" + filepath.Base(leakedBase)
req := httptest.NewRequest(http.MethodGet, "/uploads/"+traversal, nil)
rec := httptest.NewRecorder()
store.ServeFile(rec, req, traversal)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", rec.Code)
}
if got := rec.Header().Get("Content-Disposition"); got != "" {
t.Errorf("Content-Disposition = %q, want empty (sidecar must not leak)", got)
}
}
// TestLocalStorage_Upload_SkipsSidecarWhenFilenameEmpty verifies the tighter
// Upload gate: a write with no filename has nothing useful to preserve, so
// we shouldn't litter the upload directory with content-type-only sidecars
// that ServeFile would ignore anyway.
func TestLocalStorage_Upload_SkipsSidecarWhenFilenameEmpty(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
store := NewLocalStorageFromEnv()
if store == nil {
t.Fatal("NewLocalStorageFromEnv returned nil")
}
ctx := context.Background()
key := "no-filename.bin"
if _, err := store.Upload(ctx, key, []byte("body"), "application/octet-stream", ""); err != nil {
t.Fatalf("Upload failed: %v", err)
}
if _, err := os.Stat(filepath.Join(tmpDir, key+metaSuffix)); !os.IsNotExist(err) {
t.Errorf("sidecar should not exist when filename is empty, got err=%v", err)
}
}
// TestLocalStorage_Delete_RemovesSidecar verifies the cleanup half of the
// fix: when the upload is deleted, its sidecar metadata file disappears too.
// Otherwise the upload directory grows orphan .meta.json files forever.
func TestLocalStorage_Delete_RemovesSidecar(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
store := NewLocalStorageFromEnv()
if store == nil {
t.Fatal("NewLocalStorageFromEnv returned nil")
}
ctx := context.Background()
key := "deleteme.txt"
if _, err := store.Upload(ctx, key, []byte("body"), "text/plain", "original.txt"); err != nil {
t.Fatalf("Upload failed: %v", err)
}
sidecar := filepath.Join(tmpDir, key+metaSuffix)
if _, err := os.Stat(sidecar); err != nil {
t.Fatalf("sidecar should exist after Upload: %v", err)
}
store.Delete(ctx, key)
if _, err := os.Stat(sidecar); !os.IsNotExist(err) {
t.Errorf("sidecar should be removed after Delete, got err=%v", err)
}
}
// GetReader returns the uploaded bytes verbatim — used by the preview proxy.
func TestLocalStorage_GetReader_RoundTrip(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
store := NewLocalStorageFromEnv()
if store == nil {
t.Fatal("NewLocalStorageFromEnv returned nil")
}
ctx := context.Background()
key := "preview.md"
body := []byte("# hello\nworld\n")
if _, err := store.Upload(ctx, key, body, "text/markdown", "preview.md"); err != nil {
t.Fatalf("Upload failed: %v", err)
}
rc, err := store.GetReader(ctx, key)
if err != nil {
t.Fatalf("GetReader: %v", err)
}
defer rc.Close()
got, err := io.ReadAll(rc)
if err != nil {
t.Fatalf("io.ReadAll: %v", err)
}
if string(got) != string(body) {
t.Errorf("body = %q, want %q", got, body)
}
}
// Refuses path traversal at storage layer so callers don't need to defend it.
func TestLocalStorage_GetReader_RejectsTraversal(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
store := NewLocalStorageFromEnv()
if store == nil {
t.Fatal("NewLocalStorageFromEnv returned nil")
}
if rc, err := store.GetReader(context.Background(), "../../../etc/passwd"); err == nil {
rc.Close()
t.Fatal("GetReader should refuse traversal keys")
}
}
// The sidecar JSON is an internal detail. Allowing /content to read it via a
// crafted key would expose the original filename + content-type stored next
// to every upload.
func TestLocalStorage_GetReader_RejectsSidecarSuffix(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
store := NewLocalStorageFromEnv()
if store == nil {
t.Fatal("NewLocalStorageFromEnv returned nil")
}
if rc, err := store.GetReader(context.Background(), "some-key.txt"+metaSuffix); err == nil {
rc.Close()
t.Fatal("GetReader should refuse sidecar keys")
}
}
// Missing key surfaces as a plain error — the handler maps it to 404.
func TestLocalStorage_GetReader_MissingKey(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
store := NewLocalStorageFromEnv()
if store == nil {
t.Fatal("NewLocalStorageFromEnv returned nil")
}
if rc, err := store.GetReader(context.Background(), "nonexistent.txt"); err == nil {
rc.Close()
t.Fatal("GetReader should error on missing key")
}
}