85 lines
4.9 KiB
Markdown
85 lines
4.9 KiB
Markdown
# 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.
|