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 /v1/user/invite carrying the jwt cookie and the 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 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 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 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 image is uploaded Then the stored bytes no longer contain And they still contain 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= unverified_users= invites=" And a run that removes nothing logs nothing, and a failed run logs "... failed: " Scenario Outline: What is removed Given Then the retention job 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