Ctrl K
ComunidadGuías…

audits/2026-10-22-claude-opus-5-5-pentest.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Black-Box Pre-Release Test: claude-opus-5-5

| Field | Value |
|---|---|
| Date | 2026-10-22 |
| Auditor | claude-opus-5-5 (Claude Code), black-box testing of the compiled binary with 5 parallel agents plus manual re-verification of every finding on a fresh instance. Run by eugenioenko. |
| Commit audited | fa45835 |
| Scope | The production binary (`CGO_ENABLED=0 go build`, placeholder admin/account React bundles) driven over HTTP, SMTP, the CLI and the database file. 5 dimensions: (1) front channel: discovery, `/authorize`, login, SSO, consent, callbacks, RP-initiated logout; (2) back channel: all token grants, client authentication, introspection, revocation, userinfo, token integrity, registration, lifetimes; (3) lifecycle: signup, email verification, magic link, password reset, MFA, trusted devices, account API, passkeys (Playwright virtual authenticator), deletion; (4) admin API authorization, web layer (headers, cookies, CSRF, CORS, XSS, error leakage), rate limiting and proxy IPs, federation SSRF and state, onboarding; (5) reliability: races on every single-use grant, sustained load, SIGKILL/SIGTERM recovery, restart and key handling, cleanup, upgrades from schemas 14/15/16/18 with failure injection |

Purpose: a pre-v3-beta check that the fixes from the [2026-10-20 security review](2026-10-20-claude-opus-5-5-security-review.md) and the [2026-10-21 compliance review](2026-10-21-claude-opus-5-5-compliance-review.md) hold when attacked from outside, and a first look at reliability and upgrades. About 1,100 individual checks across roughly 60 throwaway scripts; no repository code was changed.

Evidence rule: a finding counts only if it reproduces against the running binary. Source was read only to learn request shapes and, after reproduction, to point at the likely cause (the Location column). Each finding reproduced twice by the testing agent and once more independently on a fresh instance. Known open issues (#358, #346, #348, #101, #166, #447) and the earlier audits' false positives and deliberate deviations were not re-reported.

Harness limits: anti-timing delays were set to 0 and cookies were non-Secure over plain HTTP; rate limiting was off except in dedicated rate-limit instances. No Docker, so the OIDF conformance suite and an OWASP ZAP rescan were not run.

## Findings

| Severity | Finding | Location (likely cause) | Status |
|---|---|---|---|
| MEDIUM | `/oauth2/revoke` accepts an admin user's bearer token from **any** client. When an admin signs in to an ordinary RP, that RP gets an access token with `aud=[issuer, rp]` and `role=admin`. Posting it as `Authorization: Bearer` to `/oauth2/revoke` revokes any user's access and refresh tokens (200; victim userinfo then 401, refresh `invalid_grant`). The same token is refused by the admin API and introspection (403). This is the #474 audience gap that #503 closed for introspection only. | `pkg/revoke/handler.go:68` (role check, no admin-audience check) | Open ([#539](https://github.com/eugenioenko/autentico/issues/539)) |
| LOW | Pre-account takeover through an unverified signup. An attacker signs up (or only calls passkey `register/begin`) with the victim's email. The victim's own signup, reset and magic link are then refused for that email. If the victim clicks the verification email the attacker's signup triggered, they are logged into the attacker's account, which becomes verified, and the attacker can still sign in with their password. | `pkg/signup`, `pkg/passkey/handler.go` (register begin), `pkg/emailverification` | Open ([#540](https://github.com/eugenioenko/autentico/issues/540)) |
| LOW | `GET /account/api/trusted-devices` returns each device's raw cookie secret as its `id`. Setting that value as `autentico_trusted_device` in a fresh browser skips MFA. A short-lived account access token plus the password becomes a 30-day MFA bypass. | `pkg/account/trusted_devices.go:38` | Open ([#541](https://github.com/eugenioenko/autentico/issues/541)) |
| LOW | `require_email_verification` is not enforced on the password grant. An unverified user receives access, ID and refresh tokens (ID token `email_verified: false`), while browser, passkey and magic-link login are blocked. | `pkg/token/handler.go` (password grant) | Open ([#542](https://github.com/eugenioenko/autentico/issues/542)) |
| LOW | `POST /oauth2/signup` is not rate limited. With rate limiting on, one signup page created 30-40 accounts in about 3 s from one IP, while `/login` and `/forgot-password` returned 429 from the 10th request. Taken usernames get "Username may already be taken", so this is also an unthrottled username oracle. | `pkg/cli/start.go:178-179` (only `csrfProtected`) | Open ([#543](https://github.com/eugenioenko/autentico/issues/543)) |
| LOW | `http.Server` has no `ReadHeaderTimeout`, `ReadTimeout` or `IdleTimeout`. Partial-header connections stay open indefinitely (20/20 still open after 75 s; 200/200 after 200 s). About 1,100 such sockets against a 1,024 file-descriptor limit stopped `/healthz` with `accept4: too many open files`. A buffering reverse proxy mitigates this. | `pkg/cli/start.go:347` | Open ([#544](https://github.com/eugenioenko/autentico/issues/544)) |
| LOW | RP-initiated logout silently drops an invalid `id_token_hint` (signed with a foreign key, `alg=none`, HS256 with the public key, or garbage). With `client_id` and a registered `post_logout_redirect_uri` it still returns `302 …?state=S1` on GET and POST. RP-Initiated Logout §2-§3: the OP must not redirect when hint validation fails. The target is still a registered URI. | `pkg/session/logout.go` | Open ([#545](https://github.com/eugenioenko/autentico/issues/545)) |
| LOW | No PKCE downgrade protection. A confidential client's code issued without `code_challenge` is redeemed (200) even when the token request carries a `code_verifier`. RFC 9700 §4.8.2 requires rejecting such requests. | `pkg/token/authorization_code.go:48-53` | Open ([#546](https://github.com/eugenioenko/autentico/issues/546)) |
| LOW | The lockout response reveals that a username exists. After 5 failures an existing account gets "Account is temporarily locked…" (browser) or `403 account_locked` (password grant). An unknown name keeps getting "Invalid username or password" / `400 invalid_grant`. Attempts 1-5 and timing are indistinguishable. | `pkg/login/handler.go:130`, `pkg/token/handler.go:152` | Open ([#547](https://github.com/eugenioenko/autentico/issues/547)) |
| LOW | `GET /admin/api/settings/export` includes `smtp_password` in plaintext. `GET /admin/api/settings` strips it, and Swagger says export excludes it. | `pkg/appsettings/handler.go:212` | Open ([#548](https://github.com/eugenioenko/autentico/issues/548)) |
| LOW | An admin can lock every administrator out with no supported recovery. Deactivating or deleting the built-in `autentico-admin` client survives restart: deletion is a soft delete and seeding skips any existing row, so after restart `/authorize` for `autentico-admin` returns 400 and admin login fails. Settings accept invalid values: `access_token_expiration=-5m` makes every new token dead on arrival, and `account_lockout_max_attempts=-1` disables lockout. `autentico onboard` refuses once onboarded, so recovery needs a direct database edit. | `pkg/cli/start.go` (`seedAdminClient`), `pkg/client` update/delete, `pkg/appsettings` validation | Open ([#549](https://github.com/eugenioenko/autentico/issues/549)) |
| LOW | Group membership and custom-claim changes are not written to the audit log, although both end up in issued tokens. User, client, settings, federation, session and token actions are audited. | `pkg/group`, `pkg/userclaim` handlers | Open ([#550](https://github.com/eugenioenko/autentico/issues/550)) |
| LOW | A wrong `AUTENTICO_TOTP_ENCRYPTION_KEY` still changes the schema before startup refuses. On a schema-18 database with encrypted enrollments, `start` with another valid key applies routine migration 19 and then exits with "cannot decrypt TOTP secret". Interactive `migrate` applies 19 and exits 0. The schema-18 binary then refuses the database as too new, and routine migrations take no backup. Restoring the original key fixes it; no data is lost. | `pkg/cli/start.go:96-106` (routine migrations before `ValidateTotpEncryption`), `pkg/cli/migrate.go` | Open ([#551](https://github.com/eugenioenko/autentico/issues/551)) |
| LOW | Parallel approve and deny of the same device `user_code` can both return success. Over 40 rounds both succeeded in 14, and in 8 of those the device still received tokens after the user was told it was denied. Sequential decisions correctly give 409, and polling stays single-use. | `pkg/account/device.go:73,117` | Open ([#552](https://github.com/eugenioenko/autentico/issues/552)) |
| LOW | `POST /admin/api/users` silently ignores `role`: `{"role":"admin"}` returns 201 with `role: user`. The admin UI create form offers a Role field and Swagger documents it. This fails safe, but an admin believes they created an administrator. | `pkg/user/handler.go:48` | Open ([#553](https://github.com/eugenioenko/autentico/issues/553)) |

## What passed

Every fix from the 2026-10-20 and 2026-10-21 reviews that was in scope held on every entry point tested. Specifically:

- **Front channel (#511-#535):**
  - `prompt=none` returns `consent_required` / `login_required` on GET and POST, and `prompt=none` combined with `login` or `consent` is rejected.
  - `auth_time` stays at the original login across two refreshes, SSO and consent.
  - `token` response types are refused at registration, update, `/authorize` and SSO. Clients without `authorization_code` get `unauthorized_client` on SSO.
  - PKCE method handling is correct with `pkce_enforce_s256` on and off, and discovery follows the setting. `request_uri` is rejected with `request_uri_not_supported`, and non-query `response_mode` is rejected.
  - `state` round-trips byte-exact (`&`, `#`, `%`, unicode, spaces) on password, SSO, MFA, consent-deny and logout paths. Existing callback query strings are preserved.
  - About 30 wildcard redirect bypass variants (dot-segments, encoded traversal, extra segments, `?`, empty) were rejected.
  - Tampering with any signed login or consent field returned 400. Missing or foreign CSRF tokens were rejected, and a consent form from another browser was refused.
  - SSO honours `prompt=login`, `max_age`, the per-client idle timeout, `sso_enabled=false`, user deactivation and `require_mfa` evidence.
  - Logout kills SSO. `post_logout_redirect_uri` must match exactly, and a `client_id` that doesn't match the hint blocks the redirect.
  - The ID token carries correct `iss`, `aud`, `azp`, `sub`, `nonce`, `at_hash`, `sid`, `exp` and `iat`.
- **Back channel (#466, #467, #474 introspection, #488, #491, #494, #503, #514, #516, #522, #523, #528, #529, #531):**
  - Codes are bound to client and redirect. PKCE S256 and plain checks reject bad length and charset.
  - Code reuse returns `invalid_grant` and revokes the original and rotated tokens.
  - Refresh rotates, rejects old tokens, revokes the account on replay, accepts only subset scopes and binds to the client (public by id, confidential needs its secret).
  - The password grant is confidential-only, and the account-level TOTP lock holds. `client_credentials` is confidential-only, strips `openid` and issues no refresh token.
  - The device grant matches RFC 8628 end to end: confidential auth, `slow_down` interval increase, `expired_token`, `access_denied`, client binding.
  - Client authentication:
    - Basic secrets containing `a+b/c=%:x` are URL-decoded correctly.
    - Malformed Basic credentials never fall back to form credentials.
    - Two credential sets, repeated or query credentials are rejected.
    - Failures return 401 with a Basic challenge on `/token`, `/revoke`, `/introspect` and `/device_authorization`.
    - Unknown grants return `unsupported_grant_type`.
  - Introspection accepts only confidential clients or admin-audience bearers, returns `aud` as an array, the presented `jti`, and `exp` from the presented credential. Cross-client tokens report inactive.
  - Revocation returns 200 with no effect for foreign or unknown tokens and revokes the pair.
  - UserInfo accepts header xor body, returns `WWW-Authenticate` on 401, and rejects revoked, deactivated, deleted and post-logout tokens.
  - `alg=none`, HS256 signed with the JWKS key, `kid` tricks, tampered or expired claims, and tokens from another instance's key are rejected by userinfo, the account API, the admin API and introspection.
  - Registration infers `none` → public, rejects contradictory metadata, keeps the type immutable and accepts only `code`. Short access and refresh lifetimes are honoured.
- **Lifecycle (#463, #465, #468/#510, #469/#492, #470/#509, #472, #478/#496, #479, #480/#506, #481, #493):**
  - Signup:
    - All six combinations of global and per-client signup policy (including override true when global is false) govern the link, `prompt=create` and direct POST.
    - Tampering with signed signup fields returns 400 and injected `role` / `is_email_verified` are ignored. Duplicates (exact, upper-case, padded) are rejected, and 8 parallel signups produce one account.
  - Email verification and resend:
    - Without verification, signup, password, passkey and magic-link login issue no code.
    - Tampered verification links do nothing, and a genuine link only reaches the original redirect.
    - Resend responses are identical across account states.
  - Magic link:
    - There is no signing oracle and no enumeration. Links and codes are single-use (15 parallel redemptions give 1 success).
    - Five wrong codes exhaust both credentials. A 60 s per-account cooldown applies across clients, and a replacement link cannot extend the deadline. MFA and consent are still enforced.
  - Password reset:
    - There is no enumeration and the token is single-use (10 parallel redemptions give 1 success). Expiry is enforced even with a pre-loaded form.
    - Reset revokes RP tokens, account tokens and SSO. A crafted redirect causes no auto-login.
  - No reset, verification or magic-link tokens appear in `server.log`.
  - MFA:
    - MFA cannot be skipped by jumping straight to `/authorize`, `/consent` or `/mfa`.
    - TOTP step replay is rejected in the browser and the password grant. The account-level 5-attempt budget is shared across browser, password grant and removal, and expires after `account_lockout_duration`.
    - Email OTP has its resend cooldown and single-use codes (10 parallel give 1 code).
    - With `mfa_method=both`, TOTP is preferred and `switch_to` is safe.
  - Trusted devices: the cookie is HttpOnly and SameSite=Strict, works for the same user only, and follows client `trust_device_enabled` / expiration overrides.
  - Account API:
    - There is no IDOR on sessions, passkeys or trusted devices. Profile updates ignore `role` and `is_email_verified`.
    - The sixth wrong `current_password` on any sensitive endpoint returns 429 and locks login. Password change revokes other sessions and SSO.
    - Deletion review and immediate modes behave as documented, and the last admin cannot self-delete (409).
  - Passkeys (Playwright virtual authenticator): registration honours signup policy and `auth_mode` at begin and finish, and passkey login still enforces local TOTP and consent.
- **Admin and web layer (#464, #474 admin, #475/#508, #476/#497, #481/#504, #490, #536):**
  - All 55 admin routes (plus `/oauth2/register*`) reject the following, including after the admin is demoted, deactivated, revoked or logged out:
    - no token or a malformed one
    - non-admin tokens via `autentico-admin` (the #358 mitigation holds), `autentico-account` and third-party clients
    - algorithm-confusion tokens
  - 35 rounds of parallel mutual or self demotion never left zero admins (409 `last_active_admin`).
  - No stored or reflected XSS from client names, theme fields, footer links, usernames or federation names on any server-rendered page or email.
  - CSRF is enforced on every browser POST. CORS reflects only the exact listed origin and never combines credentials with `*`.
  - Spoofed `X-Forwarded-For`, `X-Real-IP` and `Forwarded` never bypass rate limits. The trusted-proxy right-to-left walk works, and invalid `AUTENTICO_TRUSTED_PROXIES` makes `start`, `migrate` and `onboard` fail clearly.
  - The federation SSRF guard blocked all private IPv4 ranges, `localhost`, mapped IPv4 and redirects to loopback; a loopback listener received zero requests. Federation state is browser-bound, single-use, survives restart and expires after 10 minutes. Federation begin rejects unregistered client, redirect and scope before contacting upstream.
  - Onboarding is locked after the first admin on the web and in the CLI.
- **Reliability:**
  - Upgrade gating:
    - Plain `start` refuses pending major migrations without changing the database hash. Partial or bogus `--approve-major` values apply nothing.
    - `migrate --approve-major=15,17` (or `=17` from schema 15/16) succeeds from schemas 14, 15 and 16, and routine 19 auto-applies from 18.
  - Data preservation across upgrades:
    - Accounts, groups, claims, client secrets and consents are preserved.
    - TOTP works with fresh codes after encryption.
    - Only the schema-14 upgrade discards tokens, as documented.
  - Failure injection on a 501 MB database (15 s migration):
    - SIGKILL at 8 points during the migration leaves either a consistent old database or a recovery record that `start` refuses and `migrate` restores and completes.
    - Disk full during backup and during transform, read-only tmpfs and a read-only directory all fail cleanly and recover on retry.
    - Concurrent `migrate`, and `start` during `migrate`, are refused with "database is in use".
  - Wrong, short, non-base64 or missing TOTP keys on a current database make `start` and `migrate` refuse with no change. `init` on an existing `.env` only adds a missing key.
  - Races (30-40 parallel requests, 8-10 rounds, two runs) produced exactly one success each round for:
    - code redemption and refresh rotation (no forked family)
    - device polling, password reset, magic link (URL and code)
    - TOTP and email-OTP challenges
    - duplicate signups
  - Failed logins were never undercounted under parallel requests.
  - Load and crash safety:
    - Under 50 workers for 3 and 9 minutes (30.9k requests): zero 5xx, no `database is locked` or `SQLITE_BUSY`, file descriptors back to baseline, `integrity_check` ok.
    - SIGKILL under write load: restarts cleanly with no half-issued grants (every consumed code has exactly one token row, every rotation revoked exactly one predecessor, every token has its session), and pre-existing tokens stay valid.
    - SIGTERM under load exits in about 0.1 s.
  - Tokens and TOTP survive restart. The #482/#498 cleanup fix holds: live refresh tokens work after cleanup purges expired rows, and replay is still detected.

## Observations (not filed)

Hardening ideas, SHOULD-level items and documentation mismatches. None violates a MUST or misstates advertised behaviour.

| Area | Observation |
|---|---|
| Consent | A used consent form can be re-posted in the same browser for its 10-minute life and mints another code, including `action=allow` after a deny. Consent POST after `max_age` lapses returns raw JSON 401 to the browser instead of returning to login. |
| Authorize | The `error` query parameter is rendered as text on the login and signup pages (escaped: content spoofing only). `id_token_hint` is ignored at `/authorize` (OIDC Core SHOULD). `code_challenge` format is not validated until the token endpoint. `prompt=consent` is honoured but not listed in `prompt_values_supported`. `token_endpoint_auth_methods_supported` omits `none`. |
| Sessions | Re-authenticating as another user with `prompt=login` replaces the cookie but does not deactivate the previous IdP session. The ID token lifetime ignores the per-client access-token override. `sid` differs per client within one SSO session (relevant if front/back-channel logout is added). |
| MFA | The email-OTP attempt budget is per challenge, not per account (TOTP is per account). MFA challenges are not bound to the browser. Trusted devices survive an admin password change. |
| Identity | Usernames are case-sensitive and not Unicode-normalized (case, Cyrillic, fullwidth and zero-width variants become separate accounts). Magic-link credentials can be redeemed under another client's validly signed request, with that client's own MFA and consent applied. Verification resend has no per-account cooldown. |
| Registration / admin input | Registration accepts relative and dot-segment redirect URIs (authorize then fails safely). `post_logout_redirect_uris` accepts `javascript:` / `data:` (302 Location, so no execution). Federation provider issuer/id accept odd values and plain HTTP. Admin can set `totp_verified=true` without a secret, which strands the user at MFA. Adding `autentico-admin` to any client's audiences makes that client's admin-user tokens valid on the admin API. |
| Onboarding | The fresh-database onboarding race is about one bcrypt wide: 6 of 6 parallel POSTs created admins (known, previously called "narrow"). |
| Logging | The CSRF-failure warning logs the full request URL with its query string, bypassing #490's redaction (real reset and magic-link forms post tokens in the body). The cleanup job logs only auth-code purges. |
| Web | No `Referrer-Policy` header (pages use a meta tag). CSP lacks `base-uri`. Swagger and `/api-docs` are public and served without security headers. Unknown `/admin/api/*` paths fall through to the SPA with 200 HTML. The server binds 0.0.0.0 regardless of `APP_URL`. The `_gorilla_csrf` cookie has no explicit `Path`. The rate-limit bucket is shared per IP across all limited routes. Federation SSRF guard does not block documentation/benchmark ranges (192.0.2.0/24, 198.18.0.0/15). |
| Token endpoint | The password grant accepts `username`/`password` in the URL query string. `GET /oauth2/token` gives 404 instead of 405. A public client's stray `client_secret` is ignored. `/device_authorization` returns 400 without a challenge where `/token` returns 401 for the same missing-secret case. UserInfo answers PUT and echoes a non-standard `scope` member. |
| Reliability | RSS grew from 45 MB to 142 MB peak (125 MB idle) over a 9-minute, 30.9k-request run while tables grew by ~30k rows; inconclusive without a longer soak. Every client-authenticated request runs bcrypt, so throughput is CPU-bound (password-grant p50 1.95 s on 4 shared cores under 50 workers). The database file is created 0644 (`.env` is 0600). `users.locked_until` is stored with Go's monotonic suffix. With `AUTENTICO_PRIVATE_KEY` removed, old refresh tokens still rotate although the warning says all tokens are invalidated. |
| Migrations | Backups from interrupted major migrations are never cleaned up (one full-size copy per attempt). A cancelled interactive `migrate` exits 0. `migrate --help` describes `--approve-major` as session invalidation, which is wrong for schema 17, and doesn't mention the comma form. A partial `--approve-major` falls back to the interactive prompt with only `failed to read input: EOF` when there is no TTY. |
| Docs | CLAUDE.md lists the health endpoint as `/health`; the route is `/healthz`. `autentico init --url http://…` writes Secure cookie flags, which break non-localhost plain-HTTP testing. |

## False positives

| Claim | Evidence against |
|---|---|
| Parallel code redemption revokes the winner's tokens | Expected: losing requests are code replays, and RFC 6749 §4.1.2 replay handling revokes tokens issued from that code. The single winner was otherwise correct in every round. |
| Fragment redirect URIs make SSO deliver codes to the fragment | Already listed as a minor item in the 2026-10-21 review; reproduced, not worse than described. |
| Logout CSRF without `id_token_hint` | Documented deliberate deviation (2026-10-21 review). |

## Not tested

- OIDF conformance suite and OWASP ZAP rescan (no Docker in the test environment). The last conformance run predates every spec fix since 2026-03-24, so a rerun of the Basic, Config and RP-Initiated Logout plans is still recommended before release.
- The real admin and account React UIs (placeholder bundles), so client-side rendering of stored payloads was not exercised.
- IPv6 SSRF targets (`::1`, `fc00::/7`, `fe80::/10`, NAT64), non-`localhost` DNS names that resolve to private addresses, and true DNS rebinding (no IPv6 or external wildcard DNS in the sandbox).
- End-to-end flows in Secure-cookie mode, SameSite behaviour in a real cross-site browser context, and HSTS (no TLS listener).
- Discoverable/conditional passkey login, account-API passkey add, federation completion with a real upstream provider, and the #346 email-change flow.
- Consent continuation expiry at 10 minutes and SSO absolute max age (too slow), legacy client rows stored with `token` response types.
- Statistical timing analysis with the default anti-timing delay, a long memory soak, Windows, and non-root ownership after a root-run migration.

## See also

- [2026-10-20 security review](2026-10-20-claude-opus-5-5-security-review.md), [2026-10-21 compliance review](2026-10-21-claude-opus-5-5-compliance-review.md)
- CVE-mapped regression tests: `tests/security/CVE_REFERENCE.md`
- Spec tables: [`rfc/rfc.md`](../rfc/rfc.md)