refac: GET /static/uploadedImages/{filename}
This commit is contained in:
1 parent
8d17a12e92
commit
61828783a1
5 files changed
+592
No files matched your search
@@ -0,0 +1,98 @@
|
|||||||
|
package staticimages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
const cacheControl = "public, max-age=31536000, immutable"
|
||||||
|
const svgContentSecurityPolicy = "default-src 'none'; style-src 'unsafe-inline'; sandbox"
|
||||||
|
const fallbackContentType = "application/octet-stream"
|
||||||
|
|
||||||
|
var contentTypeExtensions = map[string]string{
|
||||||
|
".png": "image/png",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
}
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
uploadDir fs.FS
|
||||||
|
modTime time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(uploadDir string) *Handler {
|
||||||
|
return &Handler{
|
||||||
|
uploadDir: os.DirFS(uploadDir),
|
||||||
|
modTime: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
|
||||||
|
cleaned := cleanPath(requestedPath(r))
|
||||||
|
file, stat, ok := h.openFile(cleaned)
|
||||||
|
if !ok {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
content, ok := file.(io.ReadSeeker)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "File not seekable", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType := contentTypeFor(cleaned)
|
||||||
|
w.Header().Set("Content-Type", contentType)
|
||||||
|
w.Header().Set("Cache-Control", cacheControl)
|
||||||
|
if contentType == "image/svg+xml" {
|
||||||
|
w.Header().Set("Content-Security-Policy", svgContentSecurityPolicy)
|
||||||
|
}
|
||||||
|
|
||||||
|
http.ServeContent(w, r, cleaned, stat.ModTime(), content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestedPath(r *http.Request) string {
|
||||||
|
if rctx := chi.RouteContext(r.Context()); rctx != nil && rctx.RoutePath != "" {
|
||||||
|
return rctx.RoutePath
|
||||||
|
}
|
||||||
|
return r.URL.Path
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanPath(p string) string {
|
||||||
|
return strings.TrimPrefix(path.Clean("/"+p), "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) openFile(p string) (fs.File, fs.FileInfo, bool) {
|
||||||
|
if p == "" || p == "." {
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
file, err := h.uploadDir.Open(p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
stat, err := file.Stat()
|
||||||
|
if err != nil || stat.IsDir() {
|
||||||
|
file.Close()
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
return file, stat, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func contentTypeFor(p string) string {
|
||||||
|
ext := path.Ext(p)
|
||||||
|
if contentType, ok := contentTypeExtensions[ext]; ok {
|
||||||
|
return contentType
|
||||||
|
}
|
||||||
|
return fallbackContentType
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"RenewCMS/api/handlers/auth"
|
"RenewCMS/api/handlers/auth"
|
||||||
"RenewCMS/api/handlers/frontend"
|
"RenewCMS/api/handlers/frontend"
|
||||||
"RenewCMS/api/handlers/image"
|
"RenewCMS/api/handlers/image"
|
||||||
|
"RenewCMS/api/handlers/staticimages"
|
||||||
"RenewCMS/api/middleware"
|
"RenewCMS/api/middleware"
|
||||||
"RenewCMS/internal/infrastructure/config"
|
"RenewCMS/internal/infrastructure/config"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -139,6 +140,7 @@ func InitRoutes(container *UseCases, settings config.Settings) *chi.Mux {
|
|||||||
apiRouter.Use(chimiddleware.GetHead)
|
apiRouter.Use(chimiddleware.GetHead)
|
||||||
apiRouter.Get("/healthz", GetHealth)
|
apiRouter.Get("/healthz", GetHealth)
|
||||||
apiRouter.Mount("/v1", backend)
|
apiRouter.Mount("/v1", backend)
|
||||||
|
apiRouter.Mount("/static/uploadedImages", staticimages.NewHandler(settings.UploadDir))
|
||||||
apiRouter.Mount("/", frontend)
|
apiRouter.Mount("/", frontend)
|
||||||
|
|
||||||
return apiRouter
|
return apiRouter
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
Feature: Serving uploaded images as static files
|
||||||
|
|
||||||
|
As the public site embedding article images
|
||||||
|
I want GET and HEAD /static/uploadedImages/{filename} to serve a file
|
||||||
|
straight out of the configured upload directory, without authentication
|
||||||
|
So that an uploaded image loads for anonymous visitors, a script hidden in
|
||||||
|
an uploaded SVG can't execute against the CMS origin, and a missing,
|
||||||
|
deleted, directory or path-traversal request answers 404 instead of the
|
||||||
|
SPA's index.html
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given this slice adds api/handlers/staticimages.Handler, constructed with
|
||||||
|
NewHandler(uploadDir string) over config.Settings.UploadDir, with no
|
||||||
|
use case in front of it, because serving an already-stored file by
|
||||||
|
name is plain filesystem access over a config value, not a domain rule
|
||||||
|
And this slice mounts apiRouter.Mount("/static/uploadedImages", handler)
|
||||||
|
in api/router.go strictly before apiRouter.Mount("/", frontend), and
|
||||||
|
outside any jwtauth.Verifier / jwtauth.Authenticator group
|
||||||
|
And the handler maps the file extension to a Content-Type using the
|
||||||
|
mirror of persistence's contentTypeExtensions table: ".png" to
|
||||||
|
"image/png", ".jpeg" to "image/jpeg", ".webp" to "image/webp", ".svg"
|
||||||
|
to "image/svg+xml", and any other extension to
|
||||||
|
"application/octet-stream"
|
||||||
|
And every response from this handler, success or 404, sets
|
||||||
|
"X-Content-Type-Options: nosniff"
|
||||||
|
And every successful response sets a long-lived, public Cache-Control
|
||||||
|
header, e.g. "public, max-age=31536000, immutable", because a stored
|
||||||
|
filename is a UUID and its bytes never change
|
||||||
|
And an "image/svg+xml" response additionally sets
|
||||||
|
"Content-Security-Policy: default-src 'none'; style-src
|
||||||
|
'unsafe-inline'; sandbox", so a script embedded in an uploaded SVG
|
||||||
|
cannot run with the CMS origin's privileges
|
||||||
|
And the handler resolves the requested filename against uploadDir using
|
||||||
|
a cleaned, non-traversing path, exactly as frontHandler.go already
|
||||||
|
does for the SPA's static assets, and answers 404 for anything that
|
||||||
|
isn't a regular file directly inside uploadDir
|
||||||
|
|
||||||
|
Scenario Outline: Requesting an existing raster image returns 200 with the matching Content-Type
|
||||||
|
Given the upload directory contains a file named <filename> with some
|
||||||
|
non-empty content
|
||||||
|
When the client sends GET /static/uploadedImages/<filename> with no jwt
|
||||||
|
cookie
|
||||||
|
Then the response status is 200
|
||||||
|
And the response Content-Type is <content_type>
|
||||||
|
And the response Cache-Control is "public, max-age=31536000, immutable"
|
||||||
|
And the response X-Content-Type-Options is "nosniff"
|
||||||
|
And the response has no Content-Security-Policy header
|
||||||
|
And the response body is that file's exact content
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| filename | content_type |
|
||||||
|
| 4b9e2f1a-1111-4a11-9a11-111111111111.png | image/png |
|
||||||
|
| 4b9e2f1a-2222-4a11-9a11-222222222222.jpeg | image/jpeg |
|
||||||
|
| 4b9e2f1a-3333-4a11-9a11-333333333333.webp | image/webp |
|
||||||
|
|
||||||
|
Scenario: Requesting an existing SVG file returns 200 with a restrictive CSP
|
||||||
|
Given the upload directory contains a file named
|
||||||
|
"4b9e2f1a-4444-4a11-9a11-444444444444.svg" whose content is
|
||||||
|
"<svg onload=\"alert(1)\"></svg>"
|
||||||
|
When the client sends GET /static/uploadedImages/4b9e2f1a-4444-4a11-9a11-444444444444.svg
|
||||||
|
with no jwt cookie
|
||||||
|
Then the response status is 200
|
||||||
|
And the response Content-Type is "image/svg+xml"
|
||||||
|
And the response Cache-Control is "public, max-age=31536000, immutable"
|
||||||
|
And the response X-Content-Type-Options is "nosniff"
|
||||||
|
And the response Content-Security-Policy is
|
||||||
|
"default-src 'none'; style-src 'unsafe-inline'; sandbox"
|
||||||
|
And the response body is the exact SVG content, unmodified
|
||||||
|
|
||||||
|
Scenario: Requesting a file with an unrecognized extension falls back to a generic Content-Type
|
||||||
|
Given the upload directory contains a file named "notes.txt" with some
|
||||||
|
non-empty content
|
||||||
|
When the client sends GET /static/uploadedImages/notes.txt with no jwt
|
||||||
|
cookie
|
||||||
|
Then the response status is 200
|
||||||
|
And the response Content-Type is "application/octet-stream"
|
||||||
|
And the response X-Content-Type-Options is "nosniff"
|
||||||
|
And the response has no Content-Security-Policy header
|
||||||
|
|
||||||
|
Scenario: Requesting a filename that was never uploaded answers 404, not the SPA
|
||||||
|
Given the upload directory contains no file named "missing.png"
|
||||||
|
And the frontend catch-all route would otherwise serve index.html for
|
||||||
|
any unmatched path
|
||||||
|
When the client sends GET /static/uploadedImages/missing.png with no jwt
|
||||||
|
cookie
|
||||||
|
Then the response status is 404
|
||||||
|
And the response body is not the SPA's index.html content
|
||||||
|
And the response X-Content-Type-Options is "nosniff"
|
||||||
|
|
||||||
|
Scenario: Requesting a file that was deleted after being stored answers 404
|
||||||
|
Given the upload directory contained a file named
|
||||||
|
"4b9e2f1a-5555-4a11-9a11-555555555555.png", which has since been
|
||||||
|
removed from disk
|
||||||
|
When the client sends GET /static/uploadedImages/4b9e2f1a-5555-4a11-9a11-555555555555.png
|
||||||
|
with no jwt cookie
|
||||||
|
Then the response status is 404
|
||||||
|
|
||||||
|
Scenario: Requesting a directory inside the upload directory answers 404
|
||||||
|
Given the upload directory contains a subdirectory named "nested"
|
||||||
|
holding a file named "4b9e2f1a-6666-4a11-9a11-666666666666.png"
|
||||||
|
When the client sends GET /static/uploadedImages/nested with no jwt
|
||||||
|
cookie
|
||||||
|
Then the response status is 404
|
||||||
|
And the response body is not a directory listing
|
||||||
|
|
||||||
|
Scenario Outline: A path-traversal attempt never escapes the upload directory
|
||||||
|
Given the upload directory contains a file named
|
||||||
|
"4b9e2f1a-7777-4a11-9a11-777777777777.png"
|
||||||
|
And a file named "settings.go" exists outside the upload directory, in
|
||||||
|
the repository root
|
||||||
|
When the client sends GET /static/uploadedImages/<traversal_path> with
|
||||||
|
no jwt cookie
|
||||||
|
Then the response status is 404
|
||||||
|
And no file content from outside the upload directory is ever returned
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| traversal_path |
|
||||||
|
| ../settings.go |
|
||||||
|
| ..%2fsettings.go |
|
||||||
|
| ..%2f..%2f..%2fsettings.go |
|
||||||
|
| %2e%2e%2fsettings.go |
|
||||||
|
|
||||||
|
Scenario: HEAD returns the same headers as GET with no body
|
||||||
|
Given the upload directory contains a file named
|
||||||
|
"4b9e2f1a-8888-4a11-9a11-888888888888.webp" with some non-empty
|
||||||
|
content
|
||||||
|
When the client sends HEAD /static/uploadedImages/4b9e2f1a-8888-4a11-9a11-888888888888.webp
|
||||||
|
with no jwt cookie
|
||||||
|
Then the response status is 200
|
||||||
|
And the response Content-Type is "image/webp"
|
||||||
|
And the response Cache-Control is "public, max-age=31536000, immutable"
|
||||||
|
And the response X-Content-Type-Options is "nosniff"
|
||||||
|
And the response body is empty
|
||||||
|
|
||||||
|
Scenario: No authentication is required to fetch an uploaded image
|
||||||
|
Given the upload directory contains a file named
|
||||||
|
"4b9e2f1a-9999-4a11-9a11-999999999999.png" with some non-empty
|
||||||
|
content
|
||||||
|
And no jwt cookie is sent
|
||||||
|
When the client sends GET /static/uploadedImages/4b9e2f1a-9999-4a11-9a11-999999999999.png
|
||||||
|
Then the response status is 200
|
||||||
|
And the response is not a redirect to a login route
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# QA procedure: serve_uploaded_images
|
||||||
|
|
||||||
|
Package: `test` (black-box, new file `test/serve_uploaded_images_test.go`).
|
||||||
|
|
||||||
|
## Setup shared by every case
|
||||||
|
1. Call `validator.InitValidator()` once (harmless even though this handler
|
||||||
|
does no validation).
|
||||||
|
2. Create a fresh `uploadDir := t.TempDir()` per test/subtest. This handler
|
||||||
|
has no domain repository to fake — it serves real files from a real
|
||||||
|
directory on disk, which is standard for filesystem-serving code and is
|
||||||
|
not the "real SQLite file" / "live server" / "real SMTP" the project
|
||||||
|
convention rules out.
|
||||||
|
3. Build `staticimages.NewHandler(uploadDir)` (`api/handlers/staticimages`).
|
||||||
|
4. Mount it on a fresh `chi.NewRouter()` mirroring `api/router.go`'s shape
|
||||||
|
after this slice:
|
||||||
|
- `r.Mount("/static/uploadedImages", staticHandler)` — no
|
||||||
|
`jwtauth.Verifier` / `jwtauth.Authenticator` wrapping.
|
||||||
|
- `r.Mount("/", stubFrontend)` where `stubFrontend` is a trivial
|
||||||
|
`http.HandlerFunc` that writes a recognizable marker string, e.g.
|
||||||
|
`"SPA-INDEX-MARKER"`, with status 200 — standing in for the real SPA
|
||||||
|
fallback, just to prove routing order without needing the embedded
|
||||||
|
`dist/` build.
|
||||||
|
5. Helper to seed a file: `os.WriteFile(filepath.Join(uploadDir, name),
|
||||||
|
content, 0o644)`, creating parent directories with `os.MkdirAll` when a
|
||||||
|
subdirectory case needs one.
|
||||||
|
|
||||||
|
## HTTP checks
|
||||||
|
6. Write `4b9e2f1a-1111-....png`, `.jpeg`, `.webp` files with distinct
|
||||||
|
non-empty byte content. For each, `httptest.NewRequest("GET",
|
||||||
|
"/static/uploadedImages/"+name, nil)`, no cookie. Assert status `200`;
|
||||||
|
`Content-Type` equals the expected value; `Cache-Control` equals
|
||||||
|
`"public, max-age=31536000, immutable"`; `X-Content-Type-Options` equals
|
||||||
|
`"nosniff"`; `Content-Security-Policy` header is absent; body bytes equal
|
||||||
|
what was written.
|
||||||
|
7. Write a `.svg` file whose content contains an inline `onload` handler
|
||||||
|
(content doesn't matter functionally, just needs to be non-empty and
|
||||||
|
distinguishable). GET it. Assert status `200`, `Content-Type` is
|
||||||
|
`"image/svg+xml"`, `Content-Security-Policy` equals `"default-src
|
||||||
|
'none'; style-src 'unsafe-inline'; sandbox"`, `X-Content-Type-Options` is
|
||||||
|
`"nosniff"`, body equals the exact SVG bytes (not sanitized/altered).
|
||||||
|
8. Write `notes.txt`. GET it. Assert status `200`, `Content-Type` is
|
||||||
|
`"application/octet-stream"`, no `Content-Security-Policy` header.
|
||||||
|
9. Don't create `missing.png`. GET `/static/uploadedImages/missing.png`.
|
||||||
|
Assert status `404`; assert the body does **not** contain
|
||||||
|
`"SPA-INDEX-MARKER"` (proves the static mount, not the frontend
|
||||||
|
fallback, produced the 404 — i.e. mount ordering in the test router
|
||||||
|
matches the required ordering in `api/router.go`).
|
||||||
|
10. Write a file, then `os.Remove` it before the request. GET its old name.
|
||||||
|
Assert status `404`.
|
||||||
|
11. `os.MkdirAll(filepath.Join(uploadDir, "nested"), 0o755)` and write a
|
||||||
|
file inside it. GET `/static/uploadedImages/nested`. Assert status
|
||||||
|
`404` (a directory is never listed or served).
|
||||||
|
12. Write one real file inside `uploadDir`, and separately write a marker
|
||||||
|
file (e.g. `"sentinel-outside"`) to a location outside `uploadDir` (a
|
||||||
|
second `t.TempDir()`, or a path one level above `uploadDir`) so a
|
||||||
|
successful escape would be detectable. For each of the traversal paths
|
||||||
|
`"../"+sentinelName`, `"..%2f"+sentinelName` (raw in the request path,
|
||||||
|
since `httptest.NewRequest` takes a literal target string and Go's
|
||||||
|
`net/http` mux/handler must not decode-then-escape), and a
|
||||||
|
triple-`..%2f` variant: send `GET /static/uploadedImages/<path>`.
|
||||||
|
Assert status `404` for every case, and assert the response body never
|
||||||
|
equals the sentinel file's content.
|
||||||
|
13. Write a `.webp` file. Send `httptest.NewRequest("HEAD", ...)`. Assert
|
||||||
|
status `200`, the same `Content-Type` / `Cache-Control` /
|
||||||
|
`X-Content-Type-Options` headers as the GET case, and an empty response
|
||||||
|
body (`rec.Body.Len() == 0`).
|
||||||
|
14. Repeat step 6's PNG case with explicitly no `Cookie` header set on the
|
||||||
|
request (already true in step 6, but assert this once more as its own
|
||||||
|
subtest/comment-free assertion block) and confirm status `200`, not a
|
||||||
|
3xx redirect and not `401`.
|
||||||
|
|
||||||
|
## Manual verification (not exercised by the Go test)
|
||||||
|
- Real end-to-end mount order: run `task dev` or a built `task build`
|
||||||
|
binary, confirm `GET /static/uploadedImages/<unknown>` returns 404 from
|
||||||
|
the real router (with the real embedded `dist/`), not `index.html`, since
|
||||||
|
the Go test above stands in a stub for the frontend rather than the
|
||||||
|
embedded SPA.
|
||||||
|
- Browser-level CSP enforcement: open an uploaded SVG containing a script
|
||||||
|
directly in a browser and confirm the CSP header actually blocks
|
||||||
|
execution (header presence is asserted by the test; browser enforcement
|
||||||
|
isn't).
|
||||||
|
- No SMTP involvement.
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
package test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/api/handlers/staticimages"
|
||||||
|
validator "RenewCMS/api/pkg/validator"
|
||||||
|
"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(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, max-age=31536000, immutable" {
|
||||||
|
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, max-age=31536000, immutable" {
|
||||||
|
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, max-age=31536000, immutable" {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in new issue
Block a user