refac: GET /static/uploadedImages/{filename}

This commit is contained in:
fsylvain committed 2026-09-25 15:40:21 +02:00
1 parent 8d17a12e92
commit 61828783a1
5 files changed
+592

No files matched your search

+142
View File
@@ -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
+82
View File
@@ -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.