# Forseti's First Pen Test

Source: https://gofranz.com/blog/forseti-first-pen-test/

[Forseti](/blog/forseti-the-web-ui-ory-doesnt-ship/) 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](https://github.com/franzos/forseti/releases/tag/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](https://github.com/franzos/forseti/blob/master/docs/operator-guide-proxy.md) 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](https://github.com/franzos/forseti/releases/tag/v0.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](https://github.com/franzos/forseti/blob/master/docs/operator-guide-proxy.md) has haproxy sketches for all of the proxy-side items.
