145 lines
7.3 KiB
Gherkin
145 lines
7.3 KiB
Gherkin
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 the Cache-Control header
|
|
"public, no-cache", so a browser may store the file but must revalidate
|
|
it before reusing it; a stored filename is a UUID and its bytes never
|
|
change, but the file can be deleted from the media library, and a
|
|
long-lived cache would keep showing it
|
|
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, no-cache"
|
|
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, no-cache"
|
|
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, no-cache"
|
|
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
|