Go-side security items from the 2026-08-08 mobile scan
For: Main. From: Mobile. Written 2026-08-08 because two MCP relay attempts expired
waiting for operator approval — the content is here so it survives.
A Claude Security scan ran over the mobile module plus the Go surface built for it
(revision 9c009de; scope was device pairing/login, the well-known handler, and the push
senders). 23 findings. The 8 client-side ones are fixed and pushed as 2d2cc59
(mobile-only, no deploy needed). Six are server-side and belong to Main.
These six were not verified by me — they are the review panel's verdicts, deduped.
Treat them as leads, not confirmed defects. Everything in "Confirmed by me" below I did
check against the code.
1. AdminSetPassword doesn't revoke device refresh tokens
go/internal/auth/app/service.go:575
Deletes from sessions only. A stolen refresh token therefore keeps minting sessions at
the unauthenticated /auth/refresh after an admin "recovers" the account, and each
refresh rotates the chain for another RefreshTTL (720h default) — so the foothold is
indefinite. UpdatePassword and LogoutAll both call revokeNonWebCredentials; this
path does not. With must_change_password=false (accepted by the endpoint, and used by
cmd/obscura-server/jobs.go:1047) the recovered session is unrestricted immediately.
The function's doc comment claims the reset "always revokes existing sessions" — it does
not.
Rated highest of the six: it defeats the primary incident-response control.
Suggested: call s.revokeNonWebCredentials(ctx, userID) before DeleteSessionsForUser,
and correct the comment.
2. OIDC bootstrap-admin matches an UNVERIFIED email claim
go/internal/httpapi/handlers_auth.go:345, maybeBootstrapAdmin
Any IdP account able to set that address becomes wildcard admin. The account-linking path
already requires a trust signal; this one does not.
Suggested: plumb claims.EmailVerified through ProvisionFromOIDC → finishOIDCLogin,
and make the grant strictly one-shot — skip it once any account holds the admin role, or
key it on the OIDC subject rather than the email claim.
3. Unbounded device name/platform, and the list is read wholesale
go/internal/auth/domain/device.go:71 and go/internal/auth/adapters/devices_pg.go:53
(two findings, one root cause)
Client-supplied name/platform are persisted per login with no cap, the login/refresh
bodies are not wrapped in http.MaxBytesReader (the rest of the API does — see
handlers_signup.go:57), and ListDevicesForUser then materializes every row with no
LIMIT. RegisterDevicePush only checks that pushToken is non-empty.
Suggested: cap name/platform at the trust boundary, add a per-user device ceiling,
LIMIT both ListDevicesForUser and PushTargetsForUser, and add column constraints in
a follow-up migration.
4. RevokeDevice is three non-transactional statements
go/internal/auth/app/devices.go:201
A concurrent /auth/refresh can mint a session that outlives the revoke.
Suggested: wrap the sequence in s.db.Do(...), and have Authenticate reject sessions
whose device_id points at a revoked device — that closes the window regardless of
statement ordering.
5. No per-account throttle on the second factor
go/internal/httpapi/handlers_auth.go:121, TOTPVerify
A per-IP limiter only, while the password step has real per-account lockout. Guessing is
bounded by the source IP rather than by the account.
Suggested: call RecordLoginFailure/IsLocked around VerifyTOTP in
CompleteTOTPLogin, and invalidate the pending token after N wrong codes.
6. Push keeps delivering to disabled accounts
go/internal/notify/adapters/push.go:77
PushTargetsForUser carries no account-state predicate, and SetUserDisabled does not
run the revokeNonWebCredentials sweep (service.go:905) that LogoutAll and
UpdatePassword do. An offboarded person's phone keeps receiving notification content.
Confirmed by me
/me/deviceslists revoked devices. This is fine, and the mobile client already
labels them "· revoked" and withholds the Revoke button — noted only because the device
count does not drop on logout. My probe first asserted deletion and was wrong.- The scan's one HIGH is
docs/DEPLOYMENT.md:99— a live 40-hex Casdoor client secret
(client_id 8532f70c5d4f7e45ef83, committed in11a7bd0) sitting where every other
credential in the same block is aREPLACE_ME. Still present; I read the file. Rotating
it touches production SSO, so I left it entirely alone — the user has been told and it
is their call.
Caveats worth carrying
- Scan artifacts live in
CLAUDE-SECURITY-20260808-150508/(RESULTS.md+
RESULTS.jsonl). That directory's.gitignoreis*— they vanish on a clean
checkout, which is why this file exists. - Coverage gap the report itself flags:
mobile/e2e,mobile/plugins,mobile/scripts,
package.json,eas.jsonandapp.config.jsappear in neither the component list
nor the skip list. Because the run was scoped, the whole-tree accounting check never
ran. Treat those as thinly covered, not cleared. mobile/androidandmobile/iosare untracked prebuild output, so a fresh checkout
will not contain the files two findings cite.