Forseti's First Pen Test
What a grey-box pen test of Forseti found, what held, and what changed in 0.2.22.
Forseti has been running my own logins at accounts.gofranz.com for a while. Before more people sign in there, I wanted someone to try to break it. So it got a grey-box pen test: public source review, unauthenticated recon, then two rounds of authenticated testing with throwaway accounts. To exercise the OAuth and MCP flows end to end, the test ran against a companion app that signs in through Forseti.
The short version: no Critical or High findings, two Mediums in Forseti, a handful of Lows. The fixes are in v0.2.22.
What held
- OAuth:
redirect_uriis matched exactly (trailing slash, subpath, query, fragment, subdomain, userinfo trick andhttpall rejected), S256 PKCE,stateand nonce enforced, scopes checked at/authorizeand again on every MCP call. - Tokens: audience is enforced, an ID token is refused as an access token,
alg: noneand HS256-signed-with-the-public-key forgeries are refused. Refresh-token revocation is immediate. - Cookies and CSRF: every session cookie is
__Host-,Secure,HttpOnly,SameSite=Lax; every form and every JSON write needs a CSRF token, and token-less POSTs get a 403. - CIMD fetching: HTTPS only, no redirects, size and time caps, private IPs refused, and a connect-time check against DNS rebinding.
client_idhas to match the fetched document byte for byte. - Account flows: login failures look the same for existing and unknown addresses, recovery is neutral, codes are single-use with a 15-minute expiry and 5 attempts per flow.
- Sessions: logging out of Forseti ends the session in the companion app too.
What it found
- “Revoke access” returned 404 for every CIMD client (Medium). CIMD clients use a URL as their
client_id, and the template dropped that URL into a single path segment. The slashes broke the route, so the button did nothing. That’s every third-party MCP client, and the admin client pages had the same problem. Fixed in 0.2.22: the ID is percent-encoded in the path. - No throttling on password logins (Medium). 37 wrong passwords in a row, no 429, no lockout, and the correct one still worked. The login form posts straight to Kratos, so none of Forseti’s limiters see it, and Kratos has no brute-force protection of its own. This one lives at the proxy; the proxy guide now caps
POST /kratos/self-service/loginper client IP. - The CIMD rate limit could be bypassed with
X-Forwarded-For(Medium/Low). A new header value per request meant a fresh per-IP bucket; only the global limit still applied. Forseti reads the chain from the right, so this comes down to the proxy appending the header and the listener only being reachable through the proxy. The guide spells that out now. - Any user could revoke another user’s session by ID (Low). Session IDs are 128-bit, but they aren’t secret (they show up as
sidin ID tokens). Fixed in 0.2.22: foreign session IDs are refused. - Unverified email accepted for OAuth login (Low). The deployed 0.2.20 issued
email_verified: falseinstead of sending the user to verification. That was already fixed in 0.2.21. - Topology leaks (Info).
/kratos/admin/*redirected tohttp://kratos:4434, and the CSPform-actionlisted the internal Hydra address. The CSP entry is gone in 0.2.22; the proxy guide now answers/kratos/adminwith a 404.
If you run Forseti
- Upgrade to 0.2.22.
- Throttle
POST /kratos/self-service/loginat your proxy; Forseti can’t do it for you. - Deny
/kratos/admin/*at the proxy. - Only set
proxy.trust_forwarded_for = trueif the proxy appendsX-Forwarded-Forand Forseti’s listener can’t be reached directly. Otherwise leave the default. - If your apps match or provision users by email, check
email_verified. - If five minutes of access after a revocation is too long for you, shorten Hydra’s
ttl.access_token.
The proxy guide has haproxy sketches for all of the proxy-side items.