think
16px
820px

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 ProvisionFromOIDCfinishOIDCLogin,
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/devices lists 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 in 11a7bd0) sitting where every other
    credential in the same block is a REPLACE_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 .gitignore is * — 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.json and app.config.js appear 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/android and mobile/ios are untracked prebuild output, so a fresh checkout
    will not contain the files two findings cite.