Skip to content

fix: send a token on change-password and logout, and six other audit defects - #195

Merged
rghvgrv merged 7 commits into
mainfrom
fix/audit-2026-08-14
Aug 14, 2026
Merged

rghvgrv merged 7 commits into
mainfrom
fix/audit-2026-08-14

Conversation

@rghvgrv

@rghvgrv rghvgrv commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Resolves the audit tracked in #184. Nine of the ten slices are implemented; one was withdrawn after it turned out to be wrong.

Closes #185
Closes #186
Closes #187
Closes #188
Closes #190
Closes #191
Closes #192
Closes #193
Closes #194

The one that mattered

A user could never change their password from the app, and signing out did not end the session.

IAuthApi is registered without AuthDelegatingHandler — deliberately, since it carries /auth/refresh and chaining the handler there would let a 401 during refresh recurse into refresh. But change-password and logout also lived on it, and both are [Authorize]. Neither ever sent an Authorization header.

Change-password answered 401 every time. SettingsViewModel maps 401 to "Session expired, please log in again.", so the user re-authenticated, retried, and got the same message forever.

Logout was worse. It returns IApiResponse, which does not throw on a non-success status, so the 401 sailed past the catch and read exactly like a successful revoke. The client cleared its local tokens while the refresh token stayed live server-side for its full 30-day lifetime — a token lifted from a device backup outlived an explicit sign-out.

Fixed by splitting both calls onto a new IAccountApi registered with the handler attached. IAuthApi keeps the anonymous endpoints and stays handler-free.

The durable part is RefitClientCompositionTests. It builds the real container and sends a real request through each client into a capturing primary handler, asserting every client with [Authorize] endpoints carries a bearer token and that IAuthApi does not. I verified it fails when a handler registration is removed. Nothing asserted this before — the mobile tests substitute fakes and the API tests supply a token directly — which is exactly how two defects shipped together and sat there.

The rest

Issue Fix
#188 FX refresh accumulated rates and upserted only after the loop, so one bad currency threw past the upsert and discarded the whole pass. Now isolated per currency; a total outage still logs at Error so the signal is not downgraded.
#190 forgot-password returned after one SELECT for an unknown address vs. ~5 queries plus an insert for a known one. Code generation and hashing hoisted above the lookup. Residual gap documented in code — see below.
#191 An update carrying a stale version but no actual changes returned 200. EF marks nothing modified, issues no UPDATE, so the xmin predicate never ran. That is the shape the check exists for: a user who opened the edit screen before a mark-paid and changed nothing.
#192 Cache mirrored neither Version nor CatalogId. Latent today, but a save built from a cached row would 409 forever and silently strip the catalog link. Version is nullable so a pre-upgrade row is not read as an authoritative 0.
#193 The IsFreeTrial guard sat after the OneTime branch returned, so a free-trial one-time purchase counted in full.
#194 GET /users/me returned 200 with a null body for a vanished row; PUT has always returned 404.

Withdrawn: #189

I filed it, and it was wrong. MarkPaid_TwiceInARow_AdvancesTwoCycles already pins the behaviour I called a defect, with the rationale inline: "Someone clearing a backlog pays one period per tap." Advancing two cycles on two taps is deliberate — it is how a backlog is settled, and it matches the rule in CLAUDE.md that the date steps one cycle from the date just settled rather than skipping to today.

I implemented the "fix", hit that test, and reverted. MarkPaidAsync is byte-identical to main. I had audited the method without reading the test documenting its intent. Closed as invalid with the full reasoning on the issue.

Judgement call on #190

Full timing parity would mean performing throwaway DB writes for addresses with no account — real write load driven by anyone who can reach the endpoint. Chose partial parity instead: both branches now pay for the CSPRNG draw and the SHA-256, and the residual (a known address still costs extra round trips) is documented in the code with its reasoning, so a later refactor does not silently reverse it. The remaining signal is a few milliseconds of database time rather than the ~250ms BCrypt asymmetry the register and login paths exist to erase, on an endpoint already IP rate-limited to 10/min.

Verification

All four CI jobs reproduced locally against the final commit:

Suite Result
SubVora.Application.Tests 55 passed
SubVora.Infrastructure.Tests 109 passed
SubVora.Api.Tests 157 passed
SubVora.Mobile.Tests 330 passed
Android package (net10.0-android, Release, APK) built clean

Two fixes were additionally proven by disabling them and watching the new test go red:

No schema change, no migration, no new dependency. The only API contract change is additive: GET /users/me may now answer 404.

.secrets.baseline was re-staged twice for line-number drift only — inserting tests shifted existing is_secret: false entries. No new findings, and the pre-commit hook was not bypassed.

Both endpoints are [Authorize], but both lived on IAuthApi, which is
registered without AuthDelegatingHandler so it never attaches an
Authorization header.

Change-password therefore answered 401 every time. SettingsViewModel maps
401 to "Session expired, please log in again.", so the user re-logged in,
retried, and got the same message forever - the feature has never worked
from the app.

Logout was worse: it returns IApiResponse, which does not throw on a
non-success status, so the 401 sailed past the catch and read exactly like
a successful revoke. The client cleared its local tokens while the refresh
token stayed live server-side for its full 30-day lifetime.

Splits the two authenticated calls onto a new IAccountApi registered with
the handler attached. IAuthApi keeps the anonymous endpoints and stays
handler-free, preserving the property that a 401 during refresh cannot
recurse into refresh.

Adds RefitClientCompositionTests, which builds the real container and
sends a real request through each client into a capturing primary handler.
It asserts every client with [Authorize] endpoints carries a bearer token
and that IAuthApi does not. Verified to fail when a handler registration
is removed - this is the check whose absence let both defects ship, since
the mobile tests substitute fakes and the API tests supply a token
directly.

Closes #185
Closes #186
Closes #187
Rates accumulated into one list and were upserted only after the loop, so
a single unsupported pair or one transient 5xx threw straight past the
upsert and discarded every rate already fetched in that pass. One user
tracking an exotic currency aged everybody else's totals by a day.

Each base currency now fails on its own and the pass continues. A total
outage still logs at Error rather than a handful of warnings, so isolating
per-currency failures does not quietly downgrade the signal for the case
that used to throw.

Closes #188
UpdateAsync substitutes the client's version into the tracked entity's
xmin original value, so the generated UPDATE asserts nothing has moved.
That only works if an UPDATE is generated at all: submit values identical
to what is stored and EF marks nothing modified, issues no statement, and
SaveChangesAsync cannot raise a concurrency exception. The call returned
200 against a row that had moved on, while the controller documents the
409 unconditionally.

This is the shape the check exists for rather than a corner case - a user
who opened the edit screen before a mark-paid, changed nothing and pressed
Save is exactly the one whose write would silently roll the billing date
back.

Forces the row to be written when a version was supplied. Omitting the
version still applies unconditionally, so older clients are unaffected.

Closes #191
An unknown address returned after a single SELECT; a known one generated a
code, hashed it, queried outstanding codes, updated each, inserted and
saved. Response time alone therefore said whether an address had an
account - on the one endpoint whose entire contract is that it reveals
nothing, and in a file that goes to real lengths elsewhere to erase
exactly this signal (RegisterAsync hashes unconditionally, LoginAsync
verifies against a dummy hash).

Moves the code generation and SHA-256 above the user lookup so both
branches pay for them. An unknown address still writes nothing, which the
new test pins.

Residual gap accepted and documented in the code: a real account still
costs extra round trips. Closing that fully would mean issuing throwaway
writes for addresses with no account - real write load driven by anyone
who can reach the endpoint - to hide a few milliseconds of database time
on an endpoint already IP rate-limited to 10/min.

The baseline is re-staged for line-number drift only: inserting a test
shifted five already-audited is_secret:false entries in
PasswordResetControllerTests.cs. No new finding.

Closes #190
CachedSubscription mirrored neither field. Not reachable today - the
detail screen always fetches over the network and writes are gated when
offline - but the moment any screen edits from cache, a Version of 0
matches no xmin and every save 409s forever, and a null CatalogId silently
strips the record's catalog link.

Version is stored nullable rather than uint: sqlite-net adds a new column
to an existing table but leaves pre-upgrade rows at the default, and a
cached 0 read as authoritative is worse than an absent one.

Adds a reflection-driven round-trip test over every settable property of
SubscriptionDto, so a field added later fails the build instead of being
quietly dropped by the mirror.

Closes #192
The IsFreeTrial guard sat after the OneTime branch returned, so a one-time
purchase marked as a trial was never tested against it and counted in full
toward OneTimeThisYear despite nothing being charged.

Moves the guard above the cadence branch - whether a trial counts is not a
question about its cadence, so no cadence gets to bypass the check. Left
after rate resolution so unresolved-rate reporting is unchanged.

Closes #193
GET returned Ok(profile) unconditionally, so a vanished row produced a 200
with a null body - indistinguishable from a successful read - while PUT on
the same resource has always answered 404.

Baseline re-staged for line-number drift only: the new test shifted one
already-audited is_secret:false entry in UsersControllerTests.cs by three
lines. No new finding.

Closes #194
@rghvgrv rghvgrv changed the title Fix eight defects from the 2026-08-14 code audit fix: send a token on change-password and logout, and six other audit defects Aug 14, 2026
@rghvgrv rghvgrv self-assigned this Aug 14, 2026
@rghvgrv
rghvgrv merged commit 95824bd into main Aug 14, 2026
4 checks passed
@rghvgrv
rghvgrv deleted the fix/audit-2026-08-14 branch August 14, 2026 19:45
@rghvgrv rghvgrv mentioned this pull request Sep 9, 2026
3 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment