Repository navigation
Conversation
Sirajmx
left a comment
There was a problem hiding this comment.
The most serious of this batch (P0,
seller-928). Reproduced the defect live and independently —
not via the PR's own fixture, a hand-built order + change request through the real service layer
— and the pre-fix listing genuinely returns the fullrollback_snapshot, near-exact match to the
PR's own example: the victim'squote_id, campaign metadata, and audit-log actor/reason all
present, unauthenticated.Fix verified on a clean merge onto current
main(one trivial CHANGELOG conflict from #94
landing since I last checked, resolved by concatenation): new disclosure test file 16/16 passed,
existingtest_change_request.py+test_operator_auth.py103 passed (the scoping changes don't
break owner/operator behavior), full suite 1583 passed, ruff clean.Checked the design choices, not just read them: 404-not-403 for another actor's record is right
(a 403 would let status codes enumerate valid ids); scoping applied in Python rather than pushed
into the storage filter dict is the safe choice — confirmedStorageBackend.list_change_requests
silently ignores unknown filter keys, so the naive approach would fail open; the idempotency key
namespaced per actor closes a real replay hole (without it, one buyer replaying another's
(order_id, idempotency_key)pair would get handed the other buyer's record); and
actor_from_api_keycorrectly anchors to buyer identity rather thankey_id, since an ownership
key must survive key rotation while an audit identity should not.Cross-repo: zero impact, confirmed by grep — buyer-agent has no change-request client code at
all, no hits forchange-request,requested_by, orrollback_snapshotanywhere in itssrc/.
rollback_snapshotitself is still written to storage, deliberately left for a follow-up since
deleting it is a persisted-storage-shape change, not a wire-disclosure fix — confirmed it's
genuinely write-only (exactly two references repo-wide) so that follow-up is low-risk whenever it
lands. Approving.
…very order's audit trail (seller-928) GET /api/v1/change-requests took optional auth, required no filter, and declared no response_model. One anonymous request returned every change request for every order, and each record carries rollback_snapshot — a full copy of the order including its state-machine audit log, every transition's actor and reason, plus the quote id, deal id and metadata. Two gates close it. Create, list and read-by-id now take require_api_key_record (anonymous -> 401), matching the approvals router; review and apply keep their operator gate. Authentication alone is not enough, so each record is stamped at creation with an actor derived from the credential and buyer callers see only their own records — another actor's record reads 404, not 403, so the by-id route is not an existence oracle. Operator callers stay unscoped because they review the queue. All five handlers declare a response_model, so rollback_snapshot cannot reach the wire at all; it is still written server-side (deleting it is tracked separately). requested_by is dropped from the create body and stamped from the credential instead — it is the field a reviewer trusts when approving, so a requester must not be able to name itself. The change-request idempotency key is namespaced per actor as well as per order, closing the replay path that would otherwise hand one buyer another buyer's record straight past the scoping. The list scope is applied in the service in Python rather than pushed into the storage filters dict, because StorageBackend.list_change_requests understands only order_id and status and silently ignores any other key — a scope passed that way would fail open.
8100e8b to
f6bd39b
Compare
Information disclosure on a public reference implementation. This adds an auth requirement to three routes, scopes their results to the caller, changes the response shape of all five, and removes a field from a request body. Please review the wire change and the auth decisions deliberately rather than rubber-stamping.
Fixes the unauthenticated change-request disclosure (P0, tracked internally).
The defect
GET /api/v1/change-requeststook optional auth (_get_optional_api_key_record, which returnsNonefor anonymous callers), required no filter (order_idandstatusare bothOptional), and declared noresponse_model. With no query params the service built an empty filter and asked storage for everything.Each record carries
rollback_snapshot, written atorder_service.py:365asorder.copy()— the full order:OrderStateMachine.to_dict()(order_id,status, and theaudit_logwith every transition's actor and reason) plusdeal_id,quote_id,created_atandmetadata.So one anonymous unfiltered GET returned every tenant's order audit trail. Here is the actual body, from the negative control below:
POST /api/v1/change-requestswas anonymous for the same reason and tookrequested_bystraight off the wire (schemas.py:364,requested_by: str = "system"), writing it into the persisted record verbatim. Nothing downstream decides onrequested_by— approval routing is severity-only and review/apply are operator-gated — so this is not privilege escalation. It is worse in the place that matters:requested_byis exactly what a human reviewer looks at when deciding whether to approve, so the practical attack is social-engineering the approval ("requested_by": "human:cfo@publisher.example"), and the audit trail that would catch it was the thing being disclosed.Note the contrast on the same router:
reviewandapplyalready used_require_operator_api_key_record. Only create and the two reads were open.What changed
1. Authentication. Create, list and read-by-id now take
require_api_key_record(anonymous → 401) — the same dependency the approvals router uses. Review/apply keep their stricter operator gate. A buyer key is sufficient for the buyer-facing three; this is not a promotion to operator-only.2. Ownership scoping — because authentication alone is not enough. An authenticated buyer would still read every other buyer's trail. Each record is stamped at creation with an actor derived from the credential, and a buyer caller sees only records carrying its own actor. An operator caller is unscoped: reviewing the queue is the seller's job and needs all of it.
The actor comes from a new
auth.dependencies.actor_from_api_key. It deliberately differs from the existingprincipal_from_api_key: that one anchors to the immutablekey_idbecause an approval audit entry must name the exact credential that decided, whereas this is an ownership key and must survive key rotation, or a buyer would lose sight of records it filed under an earlier key. So it anchors to the strongest stable buyer identifier (agent:<advertiser|agency|seat>), falls back toagent:key:<key_id>only for a credential asserting no buyer identity at all, and gives operators their ownoperator:namespace so a buyer identifier can never collide with a seller-side actor.Another actor's record reads
404, not403. A distinguishable "exists but not yours" would turn the by-id route into an existence oracle over every tenant's change request ids.3. The wire shape is pinned. All five handlers declare a
response_model(ChangeRequestResponse,ChangeRequestListResponse,ChangeRequestApplyResponse).rollback_snapshotis absent from those models by construction, so it cannot reach the wire even if a future consumer serialises the record wholesale.4. The actor is stamped, not asserted.
requested_byis removed fromCreateChangeRequestModeland derived from the credential in the router. A body that still sends it is accepted and the value ignored (pydantic drops unknown fields), so this is not a hard break for existing clients — but the value they send no longer does anything.5. Idempotency keys are namespaced per actor. The key was
idempotency:change-request:{order_id}:{idempotency_key}. Without the actor in it, one buyer replaying another's(order_id, idempotency_key)pair would be handed back the other buyer's change request through the replay path, straight past the new scoping. Pinned by a test.6. The list scope is applied in the service, in Python — not in the storage filter dict.
StorageBackend.list_change_requestsunderstands onlyorder_idandstatusand silently ignores any other key (storage/base.py:260-274), so arequested_bypushed in that way would fail open and return every tenant's records. There is a test asserting the scope is not passed that way.Deliberately NOT done here
rollback_snapshotis still written. It is genuinely write-only — a repo-wide grep oversrc/finds exactly two references, the declaration and the one write, andapply_change_requestnever reads it. Deleting it is the right call and is the cheapest part of the fix, but it is a persisted-storage-shape change (existing records carry it; both storage backends should be confirmed to deserialize them) and that is beyond the disclosure. This PR closes the wire completely —rollback_snapshotappears in no response on this router — and the deletion is tracked internally as a follow-up.Tests
tests/unit/test_change_request_disclosure.py(16 tests), in four groups: the auth gate, the ownership scoping, the declared wire shape, and the server-stamped actor. Notable ones:401and the response text contains none ofrollback_snapshot, the order id, the secret quote id or the audit-log reason — asserted against raw text, so the values cannot reappear under another key404by id, while the owner still gets200and its own record (the gate is scoping, not a blanket denial)rollback_snapshot, the audit log, or the quote id — plus a check that the snapshot is still in storage, so the test pins the wire rather than the storage shapeNoneresponse_modelrequested_byofhuman:cfo@publisher.exampledoes not appear in the response or in the persisted recordNegative control: with
src/reverted toorigin/main(and a local shim standing in for the newactor_from_api_key, which is new code and cannot exist on the old tree), 11 of the 16 fail, reporting the actual leaked values shown above. The 5 that pass are the 3 pure helper unit tests plus "owner still sees its own records" and "operator sees the whole queue" — both of which correctly still pass, since the unguarded route answered everyone.src/was then restored and the reapplied diff verified byte-identical to the pre-revert diff (matching sha256).Full unit suite: 1537 passed.
ruff check src/andruff format --check src/ tests/both clean.The remaining unauthenticated routes
PR #95 listed the other unauthenticated routes and asked for a deliberate
public/private ruling. That ruling was made against one principle: a route is
legitimately public only if what it returns is identical for every caller and
is content the seller intends to publish; an id is not a credential; a state
change is not a read. The per-route classification and the follow-up hardening
work are tracked in the project's internal tracker and are in progress. None of
it blocks this PR, which is complete for the change-request surface on its own.