fix(security): scope credentials to the instance origin, wipe on all session teardowns#37
Merged
Conversation
Origin-blind credentials. AuthInterceptor attached the bearer token and the Cloudflare Access secrets to every request regardless of host, and the Coil image client is cloned from the API client, so it inherited that interceptor. Image URLs can point anywhere: a member avatar or an image embedded in a bio can be hosted on an external server, and that server received the user's live session. The trusted-device cookie was host-blind too (it bound to the request host and gated only on the /v1/auth/ path). Fix: credentials are attached only to the instance's own origins, the API base URL and the configured file CDN, via a shared origin check. The cookie is scoped to the API origin. External image hosts now get the X-Sheaf-Client header and nothing else. The image client keeps the debug SSL-trust config (it is still cloned from the API client) but no longer re-adds the user-agent interceptor it already inherits. ran only on the standard login and logout paths. Three teardowns bypassed it: a registration that skips email verification (committed a session directly without finishAuth), a server-URL change while signed in (cleared nothing, so the old instance's tokens/cache/queue survived and its bearer would ride to the new host), and a forced logout on refresh-token expiry (cleared tokens only). the session; a change to the same origin does not. Tests: origin matching (trusted/untrusted/lookalike/scheme/port/path-prefix), the interceptor's actual header output for API vs CDN vs external hosts, and the cookie's host+path scoping.
This was referenced Jul 15, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Combined security PR from the latest review. Two credential-exposure problems, both confirmed against the code.
1. Credentials were origin-blind
AuthInterceptorattachedAuthorization: Bearer <token>and the Cloudflare Access secrets to everyrequest regardless of host. The Coil image client is built with
okHttpClient.newBuilder().build(), so itinherits that interceptor. And image URLs can resolve to three different places:
file_cdn_basehostSo an external image host received the user
s live session token and CF-Access secrets. The trusted-device cookie had the same shape of bug:loadForRequestbound it tourl.hostfrom the request and gated only on the/v1/auth/` path, so it was host-blind.Fix. Credentials are attached only to the instance
s own origins - the API base URL origin and the configured file CDN origin - via a sharedisTrustedCredentialOrigincheck (scheme + host + port; path ignored). The trusted-device cookie is scoped to the API origin on both save and load. An external image host now receives only the non-secretX-Sheaf-Client` header.The image client is still cloned from the API client (it needs the debug SSL-trust config for local dev
servers), but the origin gate means the inherited auth interceptor is a no-op for external hosts. Also dropped
the redundant second user-agent interceptor it was adding on top of the one it already inherits.
CDN note: I scoped credentials to API origin + configured CDN origin (not API-only), so instances that
serve avatars from their own CDN with auth keep working. External hosts get nothing either way.
2. Session teardown was incomplete
The wiper that clears the local cache and offline queue (from the earlier isolation PR) only ran on the normal
login and logout paths. Three teardowns bypassed it, all verified:
prefs.saveTokensdirectly instead offinishAuth(), so a prior account`s cache and queue survived into the new account.and with the origin fix aside, the old bearer would otherwise ride to the new host.
clearTokens()only, leaving cache and queue behind.All three now clear tokens and wipe. A server change to a different origin ends the session (a change to the
same origin is treated as a cosmetic edit and does nothing).
Tests
CredentialOriginTest: trusted API/CDN, external + lookalike hosts, scheme/port mismatch, path-prefixbases, case-insensitivity, scheme-less CDN, and the server-change (
sameConfiguredOrigin) detection.AuthInterceptorTest: the interceptors actual header output - bearer+CF on API and CDN, none on an external host,X-Sheaf-Client` always present, and the pending-token path.TrustedDeviceCookieJarTest: extended for host scoping (not attached to another hosts/v1/auth/`, notstored from another host).
Verification
:app:assemblePlayRelease :wear:assembleRelease :app:testPlayReleaseUnitTestgreen; Hilt validated the newLazy<AccountDataWiper>inTokenAuthenticator(no cycle). 162 phone tests pass.if you can watch traffic, carries no auth); change the server in Settings and confirm you are signed out;
register on an instance with verification off and confirm no prior data lingers.
Follow-ups (tracked, not here)
Reliability cluster next: wear queue-drain mutex is per-instance (should be process-wide like the refresh
mutex), and
SyncWorkerusesExistingWorkPolicy.REPLACEwhich can cancel a drain mid-createFront. GraphO(n^2)-on-main (#172), editor drafts (#173), and home/journal out-of-order writes are separately tracked.