bank: check a caller-supplied owner against the session principal - #477
Merged
Conversation
`bank::resolveOwner()` returned `action.owner` whenever it was non-empty and
consulted the session principal only when it was not. Nothing compared the two,
so ten actions across eight models -- `ListAccounts`, `ListCards`, `ListPayees`,
`ListPayments`, `ListLoans`, `ListBudgets`, `ListNotifications`,
`GenerateStatement`, `OpenAccount` and `MarkAllRead` -- served a signed-in
customer another customer's data for the price of typing their username. Two of
them write: `MarkAllRead` marked another owner's notifications read, and
`OpenAccount` created an `accounts` row *owned by* the named customer, because
there the resolved owner is not a filter but the new row's `user_id`.
It is now compared, and a mismatch is refused:
owner does not match the session principal
The field is verified rather than ignored. Ignoring `action.owner` was the other
option on the table and is rejected here because the field is load-bearing on
the wire: it is `CustomerModel`'s bridge routing key (`BRIDGE_MODEL_KEY`), so a
request naming another customer would be routed to that customer's model
instance and then quietly handed the caller's own rows -- a wrong answer where a
refusal belongs. Refusing also matches `db::loadOwned`, which is how the
id-addressed half of these same models has always enforced ownership, and it
keeps every legitimate caller working: naming yourself still resolves, and both
bank's Qt GUI and its CLI leave `owner` empty at every call site.
Two decisions the issue asked for explicitly:
* **An empty session refuses**, as it already did. `resolveOwner` returns the
empty principal and each call site's existing `owner.empty()` guard throws
with a message naming what it was about to do. What changes is the
combination that used to slip through: an anonymous caller *naming* a real
customer is a mismatch, and is refused rather than served.
* **`SpendingByKind` is scoped too.** It carries no `owner` field, so
`resolveOwner` never saw it; it consulted no owner at all and reported on
any account in the database, including for a caller with no session. It now
goes through `db::loadOwned<AccountRecord>`, the same guard every other
id-addressed action uses. Both scenarios that pinned it named morph#471 as
the reason, so leaving it would have left those files citing an issue that
is closed.
A side effect worth naming: the comparison happens before the `users` lookup, so
an unregistered owner name and a registered one are now answered identically.
The old ordering resolved the name first and reported `unknown user: <name>`,
which told an unauthorized caller whether a username existed.
The regression test already existed. PR #470's corpus pinned the permissive
behaviour as `expect ok` on purpose, so this commit flips those assertions
rather than writing new ones, and rewrites the header comments that explained
why a passing assertion recorded a defect. For the two writes an error return is
not the assertion: `MarkAllRead` is followed by a read from the victim's own
session showing the notification still unread, and `OpenAccount` opens with a
marker `overdraftMinor` no other scenario uses and then searches both customers'
account lists for it. `an-owner-named-outright-is-not-checked-against-the-session`
is renamed to `...-is-checked-against-the-session` and grows the two actions its
inventory was missing, `ListCards` and `OpenAccount`.
`a-statement-covers-every-account-an-owner-has` read a third party's empty
statement to pin the empty-statement shape; it now reads it from that owner's
own session.
Control: with this commit's production change reverted and the flipped scenarios
in place, `--rung bank` fails three files (exit 1). With it applied, the corpus
passes twice against the same database, and all six rungs pass.
Fixes #471
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6uB9zxdSG8qp3VNAAyFvF
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
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.
Fixes #471.
bank::resolveOwner()returnedaction.ownerwhenever it was non-empty and consulted the session principal only when it was not. Nothing compared the two, so ten actions across eight models served a signed-in customer another customer's data for the price of typing their username — and two of them wrote to it.The resolution I chose, and why
The issue offered two, and asked for one to be picked outright.
Option 2 — keep the field, verify it.
resolveOwnernow returns the session principal always, and throwsUnauthorized{"owner does not match the session principal"}when a non-emptyaction.ownernames anybody else.Three reasons for verifying rather than ignoring:
owneris load-bearing on the wire. It isCustomerModel's bridge routing key —BRIDGE_MODEL_KEY(CustomerModel, ListAccounts, &ListAccounts::owner)andBRIDGE_KEY_FROM(OpenAccount, &OpenAccount::owner). Under option 1 a request naming another customer would still be routed to that customer's model instance and then quietly handed the caller's own rows: a wrong answer where a refusal belongs. The issue warned specifically against touching this surface without checking it.OpenAccountis a create. Silently redirecting the ownership of a newly created row is worse than refusing it.db::loadOwnedthrowsUnauthorizedon a mismatch rather than substituting; the ten owner-named actions now behave the same way as the id-addressed half of the very same models.I checked the callers the issue told me to check before assuming option 1 was safe: bank's Qt GUI (
gui/controllers/*.cpp) constructsListAccounts{}andOpenAccount{.kind = …}withownerleft empty at every call site, andsrc/cli/main.cppnever mentionsownerat all. So both options were behaviourally safe for the shipped clients — it is the wire surface and the create that decide it.One central change fixes both trees:
principal.hppis shared between the native models and the WASM shadow models undergui_wasm/(which resolvebank/core/*to the same headers), so the sixresolveOwnercall sites in the WASM build are covered too.The two decisions the issue asked to be recorded
Empty session → refuse. It already did, and that is preserved exactly:
resolveOwnerreturns the empty principal and each call site's existingowner.empty()guard throws with a message naming what it was about to do ("no session principal to own the account","no session principal to list accounts for","no session principal"). Keeping the refusal at the call sites rather than centralising it preserves those distinct messages, which the corpus pins. What changes is the combination that used to slip through: an anonymous caller naming a real customer is now a mismatch, and is refused rather than served. That case has new coverage inbanking-without-a-session-is-refused.scenario.SpendingByKind→ scoped. Verified: it carried noownerfield at all, soresolveOwnernever saw it, and it reported on any account in the database including for a caller with no session. It now goes throughdb::loadOwned<db::AccountRecord>(…, sessionPrincipal(), "account")— the same guard every other id-addressed action uses. I treated this as in scope rather than as a note: both scenarios pinned it citing morph#471 as the reason, so leaving it would have left two files pointing at a closed issue as though it were open.Also verified: id-addressed actions were indeed unaffected, and
db::loadOwnedis the working in-tree model this fix copies rather than inventing a shape.A side effect worth naming: the comparison happens before the
userslookup, so an unregistered owner name and a registered one are now answered identically. The old ordering resolved the name first and reportedunknown user: <name>, which told an unauthorized caller whether a username existed.The tests were already there — they are flipped, not written
PR #470's corpus pinned the permissive behaviour as
expect okdeliberately. This flips those assertions and rewrites the header comments that explained why a passing assertion recorded a defect; no comment is left pointing at #471 as though it were still open.an-owner-named-outright-is-not-checked-against-the-session.scenario→ renamed...-is-checked-against-the-session.scenario, and grown by the two actions its inventory was missing:ListCardsandOpenAccount. All ten owner-resolving actions are now exercised.MarkAllReadis followed by a read from the quarry's own session: still exactly one unread notification, still the same message.OpenAccountis attempted withoverdraftMinor=987654, a marker no other scenario in the corpus uses, and both customers' account lists are then searched for it — absent from the quarry's, and the snooper's list is empty outright.banking-without-a-session-is-refused.scenariogains the anonymous-caller-names-an-owner case across six actions, and asserts no account was created for the named member (marker overdraft876543).a-statement-covers-every-account-an-owner-has.scenarioread a third party's empty statement to pin the empty-statement shape; it now reads it from that owner's own session.The control: red without the fix, green with it
With the production change reverted (
git checkout 45d7d4a6 -- principal.hpp budget_model.cpp),ladder_bank_serverrebuilt, and the final flipped scenarios in place, the corpus goes red — three files, exit 1:The decisive one, in full — the snooper's session served the quarry's account, balance and all:
Restore the fix, rebuild, and it is green — twice, against the same database:
Everything else that was run
every scenario passed in: pastebin, bookmarks, polls, kanban, bank, ledger(exit 0)bank_testsAll tests passed (145 assertions in 21 test cases)ctest -L ladder -LE stress100% tests passed out of 1026ctest -LE stress(full)scenario_coverage.pybank actions 41/41 dispatched (0 exempt), workflows 22/22test_morph_scenario.pycheck_spec_citations.shProse lint OK— 746 references, every cited path resolvescheck_rung_filters.shAll 39 rung-filter checks passedcheck_catch_test_names.sh,check_test_type_names.sh,check_deprecated_markers.sh,check_journal_stamps.sh,check_ci_clang_pin.sh,check_install_export.sh,check_automoc_includes.shclang-format --dry-run --Werror(clang 22.1.8)clang-tidy -p build/gcc-debug(clang 22.1.8)budget_model.cppandprincipal.hppThe one failure, and why it is not this branch's. The full
ctest -LE stressrun reported one SEGFAULT: "App's fetch timer really does fire under a pumping loop" (ladder-bookmarks). It is a pre-existing flake, not a regression:-L ladder -LE stressrun (219/1026), and passed 3/3 on re-run afterwards.ladder_bookmarks_testswas never relinked by this change. The incremental build after the fix did 16 steps, all of them bank (bank_lib,bank_cli,ladder_bank_server,bank_tests); the bookmarks binary's mtime predates the edit.Explicitly out of scope
Bank's server trusting the client's asserted principal at all — its
AuthModelmints no token — is documented inexamples/bank/README.mdand untouched here. This change is narrower: even taking the asserted principal at face value, these ten actions ignored it. Thevalidate()-shadowing andclosingBalanceMinorfindings in bank's README are likewise separate and left alone;examples/bank/README.md's "defects the corpus pins on purpose" section drops from three entries to those two.Not verified
The
gui_wasmbuild was not compiled — no Emscripten toolchain here. The change it inherits is the sharedresolveOwnerplus an#include "bank/core/errors.hpp"that the WASM model translation units already include directly, and<stdexcept>is allerrors.hpppulls in, so the risk is a compile-order one rather than a behavioural one.wasm-demo.ymlwill cover it. The coverage gates (check_coverage_*.sh) no-op without aclang-coveragebuild tree and were not run against one.🤖 Generated with Claude Code