Security
This page helps you report a security issue in Goiabada, and shows what it does to keep sign-ins, tokens and sessions safe.
Report a vulnerability
Section titled “Report a vulnerability”-
Email [email protected] with what you found: what an attacker can do, how to reproduce it, and the Goiabada version you tried it on.
-
Keep it private, out of GitHub issues and discussions, until a fix is released, so people running Goiabada can upgrade before the details are public.
Sign-in
Section titled “Sign-in”- Passwords are hashed with bcrypt, and never stored or logged as typed. See passwords.
- Two-factor authentication uses one-time codes from an authenticator app, optional or required per client. See ACR and AMR.
- A one-time code works once. Once a code has been accepted, it’s refused everywhere, at sign-in, while setting up an authenticator and through the account API, with the same error a wrong code gets. Two submissions of the same code at the same moment get at most one success. A refused replay leaves an
otp_code_replay_detectedentry in the audit log. Removing an authenticator clears that history. - PKCE is required for every public client, and by default for every other one.
- Guessing is rate limited. Wrong passwords for one account, at the sign-in form and through the password grant together, wrong one-time codes, email verification, credential changes on the Account pages, and password-reset and registration mails to one address each get a fixed budget that always applies.
GOIABADA_AUTHSERVER_RATELIMITER_ENABLED, off by default, adds the limits counted by an IP address, among them dynamic client registration. See rate limits. - Anyone who knows an email can block its password sign-in for up to an hour, with 100 wrong passwords, since the limit on wrong passwords for one account holds whatever address they come from. Anyone already signed in stays signed in. If it happens, block the attacker at your proxy or CDN. See rate limits.
- No error is redirected before the user signs in. A request that fails while nobody is signed in sends the user to sign in first, and the error reaches the client afterwards, so one crafted link can’t send a visitor straight to any address a client has registered, as RFC 9700 section 4.11.2 requires. A silent request, which must never show a page, is answered at once. See When an error reaches your app.
- Every authorization response names the auth server. A code, the implicit flow’s tokens and every error carry
iss, so an app that signs in with more than one auth server can tell which one answered, and a response from one can’t pass for another’s (RFC 9700 section 4.4). See The response. - A redirect after a sign-in form is a
303 See Other. Every redirect after the password, one-time code or consent form is a 303, never a 302 or a 307, so the browser follows it with a GET and the password or code in the form goes no further, as RFC 9700 section 4.12 recommends. - A self-registered client is never sent an error. Anyone can register a client and point it anywhere, so instead of redirecting, the auth server shows the user a page naming the address. See self-registered clients.
Tokens
Section titled “Tokens”- Access tokens are short-lived: 5 minutes by default.
- Tokens are signed with RS256, by keys you can rotate in the admin console. See Discovery and JWKS.
- A refresh token works once. Each refresh replaces it, and two refreshes with the same token at the same moment get at most one new token. See rotation.
- A replayed refresh token revokes its family. Presenting one that a refresh already replaced revokes every other token in its line of refreshes, and leaves a
refresh_token_replay_detectedentry in the audit log. See replay. - Ending a session revokes what it authorized: its refresh tokens, offline ones included, and its unredeemed authorization codes. See what ending a session revokes.
Sessions and cookies
Section titled “Sessions and cookies”- The cookie holds an identifier, nothing else. The session’s contents stay on the server.
- The cookie is
HttpOnly, so no script can read it,SameSite=Lax, so the browser leaves it off another site’s form posts, andSecurewhen the server’s base URL ishttps, so it never travels over plain HTTP. Onhttpsits name carries the__Host-prefix, so a sibling subdomain can’t set one. - A second cookie names the browser, not a session. It holds a random identifier, with the same attributes, for 400 days, and is used only to end the user’s own earlier sessions from that browser when they sign in again. A session stores only its SHA-256. See Sessions.
- Sessions expire: after 2 hours idle or 24 hours in all, by default. See Sessions.
- The admin console’s cookie has no expiry, so the browser drops it when it closes. A browser that restores its last session can bring it back.
Cross-site request forgery
Section titled “Cross-site request forgery”Every form the auth server and the admin console serve, the sign-in, two-factor and consent screens and every admin console page, accepts a POST only from its own origin. A request the browser says came from another site, a sibling subdomain included, is refused with 403 Forbidden.
The browser’s Sec-Fetch-Site header decides: same-origin and none, which a browser sends only for an address the user typed or a bookmark they picked, are accepted, and same-site and cross-site are refused. A browser that sends no Sec-Fetch-Site has its Origin header’s host compared with the host it called. There’s no CSRF token and no CSRF cookie, so there’s nothing to expire in a page left open, and no setting that trusts another origin.
The protocol endpoints are called from other sites by design, so the auth server leaves them out of the check. Each is protected by something else:
| Path | Why another site may POST to it |
|---|---|
/auth/authorize |
OpenID Connect requires it to take a POST as well as a GET, and a POST only starts a sign-in, as a link would. Every step after it is checked. |
/auth/token |
A confidential client presents its secret. A public client presents a one-time code, bound to its redirect URI and to a PKCE verifier only it holds. |
/userinfo |
It takes an access token, never a cookie. |
/connect/register |
It creates a client and acts on no signed-in user. It’s off by default. |
/auth/logout |
Only a POST carrying an id_token_hint, which RP-initiated logout requires. A POST without one is checked. |
/api/ and everything under it |
Every API route takes an access token, never a cookie. |
/static/ and everything under it |
Images, styles and scripts, read with a GET, which the check never applies to. |
The admin console leaves out only /auth/callback, its sign-in callback, which the OAuth state parameter protects, and /static/.
Response headers
Section titled “Response headers”Both servers add these headers to every answer:
| Header | Value | What it does |
|---|---|---|
X-Frame-Options |
DENY |
No other site can show a page in a frame, so it can’t trick a user into clicking. |
Content-Security-Policy |
frame-ancestors 'none' |
The same, for browsers that read the policy. It sets nothing else. |
X-Content-Type-Options |
nosniff |
The browser takes each answer, an uploaded image included, as the type it’s labelled with. |
Referrer-Policy |
same-origin |
A URL carrying a code or a state never reaches another site in the Referer header. |
Strict-Transport-Security |
max-age=31536000; includeSubDomains |
The browser uses only https for a year, subdomains included. Only when the server’s base URL is https. |
Stored data
Section titled “Stored data”- Secrets are encrypted in the database with AES-256-GCM, under
GOIABADA_AES_ENCRYPTION_KEY: client secrets, the SMTP password, authenticator seeds, emailed codes and the keys that sign tokens. See Secrets. - Authorization codes are stored as a digest. The database holds each code’s SHA-256, so a copy of it holds no code anyone could redeem. The identifier in a session cookie is stored the same way.
- The audit log leaves out what users type. An email address typed into the sign-in, registration or forgot-password form is recorded only as a digest, unless it becomes an account’s address, as when registering creates the account:
created_usernames it. See the audit log’s details.
The network
Section titled “The network”- Forwarded headers are ignored until you turn on
GOIABADA_AUTHSERVER_TRUST_PROXY_HEADERS, so a client can’t pick the IP address the rate limiter counts and the audit log records. See client IP addresses. - The admin console’s calls to the auth server are plain HTTP with the generated setups, carrying its client secret and administrators’ tokens, which is sound only on a network you trust. See the hop from the admin console to the auth server.
- The auth server’s connection to the database checks the database’s certificate only when
GOIABADA_DB_TLS_MODEisverify-caorverify-full. It checks it against the authorities inGOIABADA_DB_TLS_CA_FILE, or the system’s when that’s empty. Left unset, it’sprefer, which checks nothing and warns at every start. It encrypts when the server offers TLS, though on SQL Server only the login unless the server forces encryption. See Where the database sits. - Browser calls across origins to
/auth/token,/auth/logoutand/userinfoare allowed only from the web origins registered on clients. Discovery and the JWKS answer any origin, since they’re public, and nothing else answers a cross-origin call.
Request records redact every query value but a short list of safe ones, and verbose API logging redacts credentials. See What a request record leaves out and Verbose API logging.
Thanks
Section titled “Thanks”Adrean Boyadzhiev of Lambda Bit assessed Goiabada’s security and advised on it. Thank you, Adrean.