271 lines
9.8 KiB
Go
271 lines
9.8 KiB
Go
package test
|
|
|
|
import (
|
|
"RenewCMS/api/handlers/staticimages"
|
|
validator "RenewCMS/api/pkg/validator"
|
|
"RenewCMS/internal/infrastructure/storage"
|
|
"RenewCMS/internal/infrastructure/useCases"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
|
)
|
|
|
|
const spaIndexMarker = "SPA-INDEX-MARKER"
|
|
|
|
func newServeUploadedImagesRouter(uploadDir string) *chi.Mux {
|
|
r := chi.NewRouter()
|
|
r.Use(chimiddleware.GetHead)
|
|
r.Mount("/static/uploadedImages", staticimages.NewHandler(useCases.NewOpenMediaUseCase(storage.NewLocalStorage(uploadDir)), newInertServeVariantUseCase(storage.NewLocalStorage(uploadDir))))
|
|
r.Mount("/", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(spaIndexMarker))
|
|
}))
|
|
return r
|
|
}
|
|
|
|
func writeUploadedImageFile(t *testing.T, dir, name string, content []byte) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o755); err != nil {
|
|
t.Fatalf("failed to create parent directories: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, name), content, 0o644); err != nil {
|
|
t.Fatalf("failed to write file: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestServeUploadedImagesHttp(t *testing.T) {
|
|
validator.InitValidator()
|
|
|
|
t.Run("Requesting an existing raster image returns 200 with the matching Content-Type", func(t *testing.T) {
|
|
examples := []struct {
|
|
filename string
|
|
contentType string
|
|
}{
|
|
{"4b9e2f1a-1111-4a11-9a11-111111111111.png", "image/png"},
|
|
{"4b9e2f1a-2222-4a11-9a11-222222222222.jpeg", "image/jpeg"},
|
|
{"4b9e2f1a-3333-4a11-9a11-333333333333.webp", "image/webp"},
|
|
}
|
|
for _, example := range examples {
|
|
uploadDir := t.TempDir()
|
|
content := []byte("content-of-" + example.filename)
|
|
writeUploadedImageFile(t, uploadDir, example.filename, content)
|
|
router := newServeUploadedImagesRouter(uploadDir)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/static/uploadedImages/"+example.filename, nil)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d", rec.Code)
|
|
}
|
|
if got := rec.Header().Get("Content-Type"); got != example.contentType {
|
|
t.Fatalf("expected Content-Type %q, got %q", example.contentType, got)
|
|
}
|
|
if got := rec.Header().Get("Cache-Control"); got != "public, no-cache" {
|
|
t.Fatalf("unexpected Cache-Control: %q", got)
|
|
}
|
|
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
|
t.Fatalf("unexpected X-Content-Type-Options: %q", got)
|
|
}
|
|
if got := rec.Header().Get("Content-Security-Policy"); got != "" {
|
|
t.Fatalf("expected no Content-Security-Policy header, got %q", got)
|
|
}
|
|
if rec.Body.String() != string(content) {
|
|
t.Fatalf("unexpected body: %q", rec.Body.String())
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("Requesting an existing SVG file returns 200 with a restrictive CSP", func(t *testing.T) {
|
|
uploadDir := t.TempDir()
|
|
filename := "4b9e2f1a-4444-4a11-9a11-444444444444.svg"
|
|
content := []byte(`<svg onload="alert(1)"></svg>`)
|
|
writeUploadedImageFile(t, uploadDir, filename, content)
|
|
router := newServeUploadedImagesRouter(uploadDir)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/static/uploadedImages/"+filename, nil)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d", rec.Code)
|
|
}
|
|
if got := rec.Header().Get("Content-Type"); got != "image/svg+xml" {
|
|
t.Fatalf("expected Content-Type image/svg+xml, got %q", got)
|
|
}
|
|
if got := rec.Header().Get("Cache-Control"); got != "public, no-cache" {
|
|
t.Fatalf("unexpected Cache-Control: %q", got)
|
|
}
|
|
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
|
t.Fatalf("unexpected X-Content-Type-Options: %q", got)
|
|
}
|
|
expectedCsp := "default-src 'none'; style-src 'unsafe-inline'; sandbox"
|
|
if got := rec.Header().Get("Content-Security-Policy"); got != expectedCsp {
|
|
t.Fatalf("expected Content-Security-Policy %q, got %q", expectedCsp, got)
|
|
}
|
|
if rec.Body.String() != string(content) {
|
|
t.Fatalf("unexpected body: %q", rec.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("Requesting a file with an unrecognized extension falls back to a generic Content-Type", func(t *testing.T) {
|
|
uploadDir := t.TempDir()
|
|
writeUploadedImageFile(t, uploadDir, "notes.txt", []byte("some notes"))
|
|
router := newServeUploadedImagesRouter(uploadDir)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/static/uploadedImages/notes.txt", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d", rec.Code)
|
|
}
|
|
if got := rec.Header().Get("Content-Type"); got != "application/octet-stream" {
|
|
t.Fatalf("expected Content-Type application/octet-stream, got %q", got)
|
|
}
|
|
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
|
t.Fatalf("unexpected X-Content-Type-Options: %q", got)
|
|
}
|
|
if got := rec.Header().Get("Content-Security-Policy"); got != "" {
|
|
t.Fatalf("expected no Content-Security-Policy header, got %q", got)
|
|
}
|
|
})
|
|
|
|
t.Run("Requesting a filename that was never uploaded answers 404, not the SPA", func(t *testing.T) {
|
|
uploadDir := t.TempDir()
|
|
router := newServeUploadedImagesRouter(uploadDir)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/static/uploadedImages/missing.png", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("expected status 404, got %d", rec.Code)
|
|
}
|
|
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
|
t.Fatalf("unexpected X-Content-Type-Options: %q", got)
|
|
}
|
|
if contains := rec.Body.String(); contains == spaIndexMarker {
|
|
t.Fatalf("expected body to not be the SPA index marker, got %q", contains)
|
|
}
|
|
})
|
|
|
|
t.Run("Requesting a file that was deleted after being stored answers 404", func(t *testing.T) {
|
|
uploadDir := t.TempDir()
|
|
filename := "4b9e2f1a-5555-4a11-9a11-555555555555.png"
|
|
writeUploadedImageFile(t, uploadDir, filename, []byte("temporary"))
|
|
if err := os.Remove(filepath.Join(uploadDir, filename)); err != nil {
|
|
t.Fatalf("failed to remove file: %v", err)
|
|
}
|
|
router := newServeUploadedImagesRouter(uploadDir)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/static/uploadedImages/"+filename, nil)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("expected status 404, got %d", rec.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("Requesting a directory inside the upload directory answers 404", func(t *testing.T) {
|
|
uploadDir := t.TempDir()
|
|
writeUploadedImageFile(t, uploadDir, "nested/4b9e2f1a-6666-4a11-9a11-666666666666.png", []byte("nested-content"))
|
|
router := newServeUploadedImagesRouter(uploadDir)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/static/uploadedImages/nested", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("expected status 404, got %d", rec.Code)
|
|
}
|
|
if body := rec.Body.String(); body != "404 page not found\n" {
|
|
t.Fatalf("expected no directory listing body, got %q", body)
|
|
}
|
|
})
|
|
|
|
t.Run("A path-traversal attempt never escapes the upload directory", func(t *testing.T) {
|
|
uploadDir := t.TempDir()
|
|
writeUploadedImageFile(t, uploadDir, "4b9e2f1a-7777-4a11-9a11-777777777777.png", []byte("inside-content"))
|
|
|
|
outsideDir := t.TempDir()
|
|
sentinelName := "settings.go"
|
|
sentinelContent := []byte("package outside\n")
|
|
if err := os.WriteFile(filepath.Join(outsideDir, sentinelName), sentinelContent, 0o644); err != nil {
|
|
t.Fatalf("failed to write sentinel file: %v", err)
|
|
}
|
|
|
|
router := newServeUploadedImagesRouter(uploadDir)
|
|
|
|
traversalPaths := []string{
|
|
"../" + sentinelName,
|
|
"..%2f" + sentinelName,
|
|
"..%2f..%2f..%2f" + sentinelName,
|
|
"%2e%2e%2f" + sentinelName,
|
|
}
|
|
for _, traversalPath := range traversalPaths {
|
|
req := httptest.NewRequest(http.MethodGet, "/static/uploadedImages/"+traversalPath, nil)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("traversal path %q: expected status 404, got %d", traversalPath, rec.Code)
|
|
}
|
|
if rec.Body.String() == string(sentinelContent) {
|
|
t.Fatalf("traversal path %q: sentinel content leaked", traversalPath)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("HEAD returns the same headers as GET with no body", func(t *testing.T) {
|
|
uploadDir := t.TempDir()
|
|
filename := "4b9e2f1a-8888-4a11-9a11-888888888888.webp"
|
|
writeUploadedImageFile(t, uploadDir, filename, []byte("webp-bytes"))
|
|
router := newServeUploadedImagesRouter(uploadDir)
|
|
|
|
req := httptest.NewRequest(http.MethodHead, "/static/uploadedImages/"+filename, nil)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d", rec.Code)
|
|
}
|
|
if got := rec.Header().Get("Content-Type"); got != "image/webp" {
|
|
t.Fatalf("expected Content-Type image/webp, got %q", got)
|
|
}
|
|
if got := rec.Header().Get("Cache-Control"); got != "public, no-cache" {
|
|
t.Fatalf("unexpected Cache-Control: %q", got)
|
|
}
|
|
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
|
t.Fatalf("unexpected X-Content-Type-Options: %q", got)
|
|
}
|
|
if rec.Body.Len() != 0 {
|
|
t.Fatalf("expected empty body, got %q", rec.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("No authentication is required to fetch an uploaded image", func(t *testing.T) {
|
|
uploadDir := t.TempDir()
|
|
filename := "4b9e2f1a-9999-4a11-9a11-999999999999.png"
|
|
writeUploadedImageFile(t, uploadDir, filename, []byte("png-bytes"))
|
|
router := newServeUploadedImagesRouter(uploadDir)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/static/uploadedImages/"+filename, nil)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d", rec.Code)
|
|
}
|
|
if rec.Code >= 300 && rec.Code < 400 {
|
|
t.Fatalf("expected no redirect, got status %d", rec.Code)
|
|
}
|
|
})
|
|
}
|