# QA: security and privacy hardening Everything below runs in `task test` (package `test`, `-tags nofrontend`) unless marked manual. ## Cross-origin protection and cookie flags 1. `test/cross_origin_protection_http_test.go`: wrap a counting handler in `middleware.CrossOriginProtection("https://cms.example.com")`. - Refused with 403 and `apperr.CrossOriginErrorMessage`, handler not reached: a cross-site POST, a same-site POST from `https://blog.example.com`, a cross-site DELETE, and a POST with only `Origin: https://evil.example`. - Allowed: same-origin POST, `Sec-Fetch-Site: none`, no headers, cross-site GET and OPTIONS, a cross-site POST with `Authorization: Bearer rcms_key`, and a POST whose `Origin` is the public URL while `Host` is `127.0.0.1:8080`. - With `PUBLIC_URL` `::not a url`, a cross-site POST is still 403. 2. `test/session_revocation_http_test.go` drives `api.InitRoutes`: the login cookie is HttpOnly, Secure and `SameSite=Lax`, and a cross-site `POST /v1/auth/logout` is 403 and revokes nothing. ## Session revocation 3. `test/authtoken_test.go` (`TestAuthTokenRevocation`, `TestAuthTokenDecodeRejections`): distinct `jti` per token; explicit `jti` kept; a revoked token gives `ErrRevoked` and is stored until its `exp`; other tokens stay valid; store read failure gives `ErrInvalid`; store write failure is returned by `Revoke`; no `jti` is a no-op; no `exp` refuses; tokens without `exp` or with a non-string `jti` are refused by `Decode`. 4. `TestSessionRevocationUseCase`: an already-expired session is not stored; an empty or 65-byte id is `session.ErrInvalidSessionID`; 64 bytes is accepted. 5. `TestRevokedSessionRepository` on a migrated SQLite file: revoking twice is harmless, `DeleteExpired(now)` removes rows whose `expires_at <= now` and reports the count. 6. `TestLogoutRevokesTheSession` through `api.InitRoutes`: two logins, logout with the first, `/me` with it is 401 and with the second is 200; logout without a cookie is 200 and stores nothing; a failing write gives 500 with the cookie cleared; a failing read makes `/me` 401. 7. `test/verified_account_required_http_test.go`: a verified user whose `token_version` moved on only sees online articles. ## Passwords 8. `test/password_length_limit_test.go`: 72 bytes accepted, 73 bytes and 37 × "é" refused by `user.ValidatePassword`; register, reset (valid code) and invite accept answer 400 with `apperr.PasswordTooLongErrorMessage` and change nothing; a wrong reset code still answers 403. 9. `test/hasher_test.go`: an unknown username compares against a decoy hash once per attempt and hashes the decoy once. ## Configuration and mail 10. `test/runtime_settings_from_env_test.go`: empty, short, 31-byte and both example `JWT_SECRET` values fail with `config.ErrWeakJWTSecret` and mention `openssl rand -hex 32`; 32 bytes passes. `SMTP_ALLOW_INSECURE` reads `""`/`false` as false, `true`/`TRUE` as true, and `maybe` fails naming the variable. 11. `test/smtp_mailer_test.go` against the fake server on `127.0.0.2`: no STARTTLS and no opt-out fails before `MAIL FROM` naming `SMTP_ALLOW_INSECURE`; with the opt-out the message is delivered in clear text; with the opt-out and credentials, nothing is sent. ## Upload metadata 12. `test/image_metadata_stripping_test.go` builds JPEG, PNG and WebP files carrying a GPS-like marker in EXIF, XMP, IPTC, comments, text chunks, MPF and trailing data. Assert the marker is gone, ICC is kept, pixels decode identically, the orientation-only EXIF appears only when the orientation is not 1 and sits in the documented place, WebP flags and RIFF size are correct, a plain WebP and an SVG come back unchanged, and each malformed container is `ErrImageUnreadable`. 13. `TestUploadStoresTheStrippedImage`: the stored bytes carry no marker and `Size`/`SHA256` match them. `TestUploadRefusesAnImageWhoseContainerIsBroken`: nothing is stored. ## Retention 14. `test/personal_data_retention_test.go`: the user and invite rules at their boundaries; one run over a seeded world removes exactly one expired revocation, one abandoned account and two invites; a second run removes nothing; a session store failure touches nothing else; a user or invite deletion failure returns the partial counts; the runner logs counts only when something was removed and logs failures. ## Fonts 15. `test/security_headers_test.go`: the CSP contains `font-src 'self';` and no Google host. 16. Manual: `task build:web`, open the back office with the network panel; no request leaves for `fonts.googleapis.com` or `fonts.gstatic.com`, and `task visual:check -- log-in` passes. ## Manual verification - Behind the real reverse proxy, log in, copy the `jwt` cookie, log out, then replay the cookie with `curl`: `/v1/auth/me` answers 401. - With an SMTP relay that lacks STARTTLS on a non-loopback host, sending fails until `SMTP_ALLOW_INSECURE=true` is set.