package main import ( "net/http" "net/http/httptest" "os" "path/filepath" "testing" "github.com/gin-gonic/gin" ) // newUploadsRouter builds a router with the production upload routes over a // temp storage root that mirrors the real layout: the SQLite database file // lives in the root itself, uploads live in subdirectories. func newUploadsRouter(t *testing.T, storageDir string) (*gin.Engine, string) { t.Helper() gin.SetMode(gin.TestMode) root := t.TempDir() if err := os.WriteFile(filepath.Join(root, "blog.db"), []byte("fake sqlite"), 0644); err != nil { t.Fatalf("seed blog.db: %v", err) } r := gin.New() registerUploadRoutes(r.Group("/uploads"), root, storageDir) return r, root } func doGet(t *testing.T, r *gin.Engine, path string) *httptest.ResponseRecorder { t.Helper() w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) return w } func seedUploadFile(t *testing.T, root, sub, name string) { t.Helper() dir := filepath.Join(root, sub) if err := os.MkdirAll(dir, 0755); err != nil { t.Fatalf("mkdir %s: %v", dir, err) } if err := os.WriteFile(filepath.Join(dir, name), []byte("content"), 0644); err != nil { t.Fatalf("seed %s: %v", name, err) } } func TestUploadsWhitelistHidesStorageRoot(t *testing.T) { r, root := newUploadsRouter(t, "") for _, sub := range []string{"attachments", "avatars", "logos"} { seedUploadFile(t, root, sub, "file.txt") } // The database file in the storage root must not be downloadable. if w := doGet(t, r, "/uploads/blog.db"); w.Code != http.StatusNotFound { t.Fatalf("GET /uploads/blog.db = %d, want 404 (database leak)", w.Code) } // No directory listing anywhere. for _, p := range []string{ "/uploads", "/uploads/", "/uploads/attachments/", "/uploads/avatars/", "/uploads/logos/", } { if w := doGet(t, r, p); w.Code != http.StatusNotFound { t.Fatalf("GET %s = %d, want 404 (no directory listing)", p, w.Code) } } // Traversal attempts must not escape the subdirectory. for _, p := range []string{ "/uploads/attachments/../blog.db", "/uploads/attachments/..%2f..%2fblog.db", "/uploads/attachments/%2e%2e/blog.db", } { if w := doGet(t, r, p); w.Code == http.StatusOK { t.Fatalf("GET %s = %d, want non-200 (traversal)", p, w.Code) } } // Files in the whitelisted subdirectories are still served. for _, p := range []string{ "/uploads/attachments/file.txt", "/uploads/avatars/file.txt", "/uploads/logos/file.txt", } { if w := doGet(t, r, p); w.Code != http.StatusOK { t.Fatalf("GET %s = %d, want 200", p, w.Code) } } } func TestUploadsWhitelistCustomStorageDir(t *testing.T) { r, root := newUploadsRouter(t, "files") seedUploadFile(t, root, "files", "a.bin") if w := doGet(t, r, "/uploads/files/a.bin"); w.Code != http.StatusOK { t.Fatalf("GET /uploads/files/a.bin = %d, want 200", w.Code) } // The default dir stays mounted for backward compatibility. seedUploadFile(t, root, "attachments", "old.txt") if w := doGet(t, r, "/uploads/attachments/old.txt"); w.Code != http.StatusOK { t.Fatalf("GET /uploads/attachments/old.txt = %d, want 200", w.Code) } if w := doGet(t, r, "/uploads/blog.db"); w.Code != http.StatusNotFound { t.Fatalf("GET /uploads/blog.db = %d, want 404", w.Code) } } func TestUploadsWhitelistUnsafeStorageDirFallsBack(t *testing.T) { for _, dir := range []string{"../evil", "/etc", "..", "a/../../b", "."} { r, root := newUploadsRouter(t, dir) seedUploadFile(t, root, "attachments", "file.txt") if w := doGet(t, r, "/uploads/attachments/file.txt"); w.Code != http.StatusOK { t.Fatalf("storage dir %q: fallback mount broken: %d", dir, w.Code) } if w := doGet(t, r, "/uploads/blog.db"); w.Code != http.StatusNotFound { t.Fatalf("storage dir %q: /uploads/blog.db = %d, want 404", dir, w.Code) } } } func TestUploadsWhitelistStorageDirDedup(t *testing.T) { // A storage dir equal to a known dir must not panic on duplicate routes. r, root := newUploadsRouter(t, "avatars") seedUploadFile(t, root, "avatars", "me.jpg") if w := doGet(t, r, "/uploads/avatars/me.jpg"); w.Code != http.StatusOK { t.Fatalf("GET /uploads/avatars/me.jpg = %d, want 200", w.Code) } }