191 lines
9.9 KiB
Gherkin
191 lines
9.9 KiB
Gherkin
Feature: Sessions, uploads and stored personal data are hardened against forgery, replay and over-retention
|
|
|
|
As the operator of a RenewCMS instance
|
|
I want cross-site writes refused, logged-out sessions dead, weak secrets rejected, mail encrypted,
|
|
photo metadata removed and stale personal data deleted on a schedule
|
|
So that the instance meets the security-of-processing and storage-limitation duties of the GDPR
|
|
(art. 5(1)(c), 5(1)(e), 25 and 32) without the operator having to configure anything
|
|
|
|
Background:
|
|
Given api.InitRoutes wraps every route in middleware.CrossOriginProtection(settings.PublicURL),
|
|
built on the standard library's http.CrossOriginProtection, after CORS and before any router
|
|
And the jwt cookie is set and cleared with HttpOnly, SameSite=Lax, Path "/" and, in
|
|
production, Secure
|
|
|
|
Rule: Cross-site writes are refused
|
|
|
|
Scenario Outline: An unsafe cross-origin browser request is refused before any handler runs
|
|
Given a request <method> /v1/user/invite carrying the jwt cookie and the headers <headers>
|
|
Then the response is 403 with the body "Cross-origin requests are not allowed."
|
|
And the handler never runs
|
|
|
|
Examples:
|
|
| method | headers |
|
|
| POST | Sec-Fetch-Site: cross-site, Origin: https://evil.example |
|
|
| POST | Sec-Fetch-Site: same-site, Origin: https://blog.example.com |
|
|
| DELETE | Sec-Fetch-Site: cross-site, Origin: https://evil.example |
|
|
| POST | Origin: https://evil.example (an older browser, no Sec-Fetch-Site) |
|
|
|
|
Scenario Outline: Requests that cannot be forged are let through
|
|
Given a request <request>
|
|
Then the handler runs
|
|
|
|
Examples:
|
|
| request |
|
|
| POST with Sec-Fetch-Site: same-origin |
|
|
| POST with Sec-Fetch-Site: none |
|
|
| POST with neither Sec-Fetch-Site nor Origin (a non-browser client) |
|
|
| GET, HEAD or OPTIONS from any origin |
|
|
| POST from another origin carrying an Authorization header (an API key or bearer jwt) |
|
|
| POST whose Origin is the origin of PUBLIC_URL while a proxy rewrote Host |
|
|
|
|
Scenario: A PUBLIC_URL that cannot be parsed still leaves the protection on
|
|
Given PUBLIC_URL is not a URL
|
|
When a cross-site POST arrives
|
|
Then it is refused with 403
|
|
|
|
Rule: Logging out kills the session server-side
|
|
|
|
Scenario: Every session token carries a unique id and an expiry
|
|
When authtoken.Encode issues a token without "jti" or "exp"
|
|
Then it adds a random "jti" (crypto/rand.Text) and "exp" 24 hours after "iat"
|
|
And authtoken.Decode refuses a token without "exp" and a token whose "jti" is not a string
|
|
with ErrInvalid
|
|
|
|
Scenario: The token presented at logout is refused afterwards, other sessions are not
|
|
Given alice logged in twice, getting tokens A and B
|
|
When she sends POST /v1/auth/logout with token A
|
|
Then the response is 200 and clears the cookie
|
|
And a session.Revocation for A's jti, expiring at A's exp, is stored in revoked_sessions
|
|
And GET /v1/auth/me with token A answers 401 and with token B answers 200
|
|
|
|
Scenario: Logout without a valid session stores nothing
|
|
When POST /v1/auth/logout arrives without a cookie, or with an expired or revoked one
|
|
Then the response is 200, the cookie is cleared and nothing is stored
|
|
|
|
Scenario: A revocation that cannot be stored is reported
|
|
Given the revoked_sessions store refuses writes
|
|
When alice logs out
|
|
Then the response is 500 and the cookie is still cleared
|
|
|
|
Scenario: A token whose revocation cannot be checked is refused
|
|
Given the revoked_sessions store cannot be read
|
|
Then any request authenticated by a jwt is treated as unauthenticated
|
|
|
|
Rule: Only a current session sees drafts
|
|
|
|
Scenario: A verified user's stale token reads like an anonymous visitor
|
|
Given alice is verified and her token_version is 1
|
|
When GET /v1/article/ arrives with a token carrying token_version 0
|
|
Then only online articles are listed
|
|
|
|
Rule: Passwords fit bcrypt
|
|
|
|
Scenario Outline: A password over 72 bytes is refused with 400
|
|
When <request> carries a password of 73 bytes, or of 37 two-byte characters
|
|
Then the response is 400 with the body "The password must be 72 bytes or fewer."
|
|
And nothing is created or changed
|
|
|
|
Examples:
|
|
| request |
|
|
| POST /v1/auth/register |
|
|
| POST /v1/auth/password-reset/confirm (valid code) |
|
|
| POST /v1/auth/invite/accept (valid code) |
|
|
|
|
Scenario: A wrong reset code still wins over a long password
|
|
When POST /v1/auth/password-reset/confirm carries a wrong code and a 73-byte password
|
|
Then the response is 403 as for any wrong code
|
|
|
|
Rule: The server refuses to start with a guessable signing secret
|
|
|
|
Scenario Outline: A weak JWT_SECRET stops startup
|
|
Given JWT_SECRET is <value>
|
|
Then config.NewSettingsFromEnv fails with config.ErrWeakJWTSecret
|
|
And the message says to generate one with `openssl rand -hex 32`
|
|
|
|
Examples:
|
|
| value |
|
|
| empty |
|
|
| shorter than 32 bytes |
|
|
| "change-me-to-a-long-random-string" |
|
|
| "replace-with-output-of-openssl-rand-hex-32" |
|
|
|
|
Rule: Mail is never sent in clear text over the network by default
|
|
|
|
Scenario: A non-loopback SMTP server without STARTTLS is refused
|
|
Given SMTP_PORT is not 465, SMTP_HOST is not localhost, 127.0.0.1 or ::1, and the server does
|
|
not offer STARTTLS
|
|
When a mail is sent
|
|
Then delivery fails before MAIL FROM with an error naming SMTP_ALLOW_INSECURE
|
|
|
|
Scenario: The operator can opt out for a trusted relay
|
|
Given SMTP_ALLOW_INSECURE is "true"
|
|
When a mail is sent to such a server without credentials
|
|
Then it is delivered in clear text
|
|
And credentials are still never sent in clear text
|
|
|
|
Rule: Uploaded photos lose their metadata
|
|
|
|
Scenario Outline: Metadata is removed before the bytes are stored, hashed or measured for size
|
|
When a <format> image is uploaded
|
|
Then the stored bytes no longer contain <removed>
|
|
And they still contain <kept>
|
|
And the image's size and sha256 describe the stored bytes, and its pixels are unchanged
|
|
|
|
Examples:
|
|
| format | removed | kept |
|
|
| JPEG | EXIF, XMP, IPTC (APP13), comments, MPF and other APPn segments, trailing images | JFIF, ICC profile, Adobe |
|
|
| PNG | eXIf, tEXt, zTXt, iTXt, tIME and data after IEND | every other chunk |
|
|
| WebP | EXIF and XMP chunks (their VP8X flags cleared) and data after the RIFF size | every other chunk |
|
|
|
|
Scenario: A rotated photo keeps only its orientation
|
|
Given an uploaded JPEG, PNG or WebP whose EXIF orientation is 2 to 8
|
|
Then the stored file carries a minimal EXIF block holding only that orientation (after SOI or
|
|
JFIF for JPEG, after IHDR for PNG, at the end with the EXIF flag set for WebP)
|
|
|
|
Scenario: An image whose container cannot be walked is refused
|
|
When a JPEG, PNG or WebP with a truncated header, a chunk longer than the file or no IEND
|
|
(PNG) is uploaded
|
|
Then the upload fails with image.ErrImageUnreadable and nothing is stored
|
|
And a JPEG cut off inside its scan data is kept as it is
|
|
|
|
Scenario: SVG is stored as uploaded
|
|
When an SVG is uploaded
|
|
Then its bytes are stored unchanged
|
|
|
|
Rule: Stale personal data is deleted on a schedule
|
|
|
|
Scenario: The retention job runs hourly and logs only when it removed something
|
|
Given scheduler.NewSystemRetentionRunner runs useCases.EnforceRetentionUseCase a minute after
|
|
startup and then every hour
|
|
When a run removes something
|
|
Then the log reads "scheduler: personal data retention completed: revoked_sessions=<n>
|
|
unverified_users=<n> invites=<n>"
|
|
And a run that removes nothing logs nothing, and a failed run logs "... failed: <error>"
|
|
|
|
Scenario Outline: What is removed
|
|
Given <record>
|
|
Then the retention job <outcome>
|
|
|
|
Examples:
|
|
| record | outcome |
|
|
| a revoked session whose exp has passed | deletes it |
|
|
| an unverified account whose verification code expired more than 7 days ago | deletes it |
|
|
| an unverified account without a code expiry created more than 7 days ago | deletes it |
|
|
| an invite that expired more than 30 days ago | deletes it |
|
|
| an invite accepted more than 30 days ago | deletes it |
|
|
| a verified account, a pending invite or an account still inside its 7 days | keeps it |
|
|
|
|
Scenario: A failure stops the run and keeps the counts of what was done
|
|
Given deleting a user fails
|
|
Then the run returns the error with the sessions already purged counted and touches no invite
|
|
|
|
Rule: The back office loads nothing from third parties
|
|
|
|
Scenario: Fonts are bundled
|
|
Given the SPA's fonts (IBM Plex Sans, IBM Plex Mono, Source Serif 4) live in
|
|
web/src/assets/fonts with their OFL licences
|
|
Then the Content-Security-Policy allows "font-src 'self'" and "style-src 'self'
|
|
'unsafe-inline'" with no Google Fonts host
|
|
And loading the back office sends no request to fonts.googleapis.com or fonts.gstatic.com
|