Skip to content

Franz Franz

goFranz // personal journal all writing
Security · forseti

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_uri is matched exactly (trailing slash, subpath, query, fragment, subdomain, userinfo trick and http all rejected), S256 PKCE, state and nonce enforced, scopes checked at /authorize and again on every MCP call.
  • Tokens: audience is enforced, an ID token is refused as an access token, alg: none and 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_id has 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

  1. “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.
  2. 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/login per client IP.
  3. 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.
  4. 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 sid in ID tokens). Fixed in 0.2.22: foreign session IDs are refused.
  5. Unverified email accepted for OAuth login (Low). The deployed 0.2.20 issued email_verified: false instead of sending the user to verification. That was already fixed in 0.2.21.
  6. Topology leaks (Info). /kratos/admin/* redirected to http://kratos:4434, and the CSP form-action listed the internal Hydra address. The CSP entry is gone in 0.2.22; the proxy guide now answers /kratos/admin with a 404.

If you run Forseti

  • Upgrade to 0.2.22.
  • Throttle POST /kratos/self-service/login at your proxy; Forseti can’t do it for you.
  • Deny /kratos/admin/* at the proxy.
  • Only set proxy.trust_forwarded_for = true if the proxy appends X-Forwarded-For and 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.