Files
RenewCMS/features/security_privacy_hardening.feature
T

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