Skip to content

fix(history): require auth for transaction list and redact public single DTO#4166

Open
TaprootFreak wants to merge 3 commits into
developfrom
fix/secure-transaction-history-auth
Open

fix(history): require auth for transaction list and redact public single DTO#4166
TaprootFreak wants to merge 3 commits into
developfrom
fix/secure-transaction-history-auth

Conversation

@TaprootFreak

@TaprootFreak TaprootFreak commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Breaking: GET /transaction, CoinTracking/ChainReport export, and PUT /transaction/csv no longer accept wallet userAddress as the only credential. Callers must authenticate with Bearer JWT or DFX-ACCESS-KEY / SIGN / TIMESTAMP.
  • Optional userAddress is an ownership-scoped filter on the authenticated account (403 if the address is not owned).
  • GET /transaction/single stays reachable without login for status links, but returns a public DTO (no chargeback IBAN/target, fee breakdown, deposit address, external ids). Owners and staff (SUPPORT hierarchy / REALUNIT) still receive the full compact DTO.
  • New HistoryAccessService centralizes list auth; deprecated unauthenticated address loaders always fail closed.

Consumer sync

Repo Status
DFXswiss/packages ✅ Merged #189 + release #190; npm @dfx.swiss/react@1.5.1 omits userAddress for account sessions
DFXswiss/services ✅ Bump PR #1175 (@dfx.swiss/react@1.5.1); no app code change (list already uses /transaction/detail + JWT)
External CoinTracking tools Must use signed DFX-ACCESS-* headers (already supported on /history/:exportType) or JWT

Test plan

  • Unit: HistoryAccessService (JWT, API key, ownership, staff)
  • Unit: controller list/csv/single auth paths
  • Unit: toPublicDto redaction
  • Unit: isAccountSubject with real UserData instances (postal-address getter ≠ wallet string)
  • CI green
  • Manual: logged-in history/export still works
  • Manual: /tx/T… without login shows status without IBAN/fees
  • Manual: unauthenticated GET /transaction?userAddress=… → 401

Security notes

  • Fixes long-standing open history-by-address endpoint (present since history refactor 2024-01).
  • Account-subject detection must not treat UserData.address postal getter as a wallet string (regression covered by tests).

…gle DTO

List and export endpoints no longer accept wallet address as sole credential.
Callers must use JWT or CoinTracking API-key headers; userAddress is an optional
ownership-scoped filter. Unauthenticated single-tx lookups return a reduced DTO
without bank/chargeback targets, fee breakdowns, or external ids so status links
keep working without leaking private financial data.
@TaprootFreak

Copy link
Copy Markdown
Collaborator Author

Companion packages PR: DFXswiss/packages#189

@TaprootFreak

Copy link
Copy Markdown
Collaborator Author

Ready-for-review process

2 review passes until 0 open findings.

Pass 1 fixed: unused UserService injection in HistoryService after unauthenticated address loaders were fail-closed; dead private getHistoryData removed.
Pass 2: conformity + logic reviews clean (auth fail-closed, account vs wallet subject discrimination, public single DTO redaction, companion packages PR #189).

CI was already green on the first push; re-running after the follow-up commit.

@TaprootFreak TaprootFreak marked this pull request as ready for review July 11, 2026 13:03
@TaprootFreak

Copy link
Copy Markdown
Collaborator Author

Status update (ready, not merged)

  • CI: all green on latest commits (4d697e7, 9c5af9b)
  • Review process: 2 passes, 0 open findings
  • Companion packages: merged (CoinTracking API #189) and released via CoinTracking API #190@dfx.swiss/react@1.5.1 on npm
  • services does not call unauthenticated list history (getDetailTransactions + JWT); no services PR required for this change
  • Remaining: human review / approval and merge (out of scope for automation)

Requested reviewers: CODEOWNERS.

@TaprootFreak

Copy link
Copy Markdown
Collaborator Author

Frontend dependency follow-up

Services consumer bump is open and ready: DFXswiss/services#1175 (@dfx.swiss/react@1.5.1 + lockfile).

Together with packages#189/#190 this completes the in-repo frontend dependency chain for the history-auth change (still pending API merge for the server-side gate).

Gate the input and output tx identifiers on their payment method: a fiat leg
reuses the same DTO fields for a private bank reference (outputTxId carries
bankTx.remittanceInfo on a sell), so an unauthenticated status link handed out
the remittance reference of the payout.

Strip the chargeback identifiers unconditionally. The hash resolves to the
chargeback target on a block explorer, and a chargeback may go to an address
other than the input sender, so it can disclose an address no other public
field does.

Add PublicTransactionReasonMap, an exhaustively typed fail-closed allow list,
and only disclose an AML reason it classifies as operational. Hiding the reason
alone is not enough: KYC_REQUIRED is produced by nothing but private reasons,
so the state discloses the reason on its own - mask it to the neutral sibling
the same AML branch returns for any other pending check.

Restore instanceof UserData in isAccountSubject. Reading the address getter
dereferences the organization relation and throws for organization accounts
that do not have it loaded.

Drop the silent `?? []` fallback so a missing users relation fails loud instead
of quietly dropping the staking history from an export.
@TaprootFreak

Copy link
Copy Markdown
Collaborator Author

Follow-up commit ed1e3c5: a review of this PR surfaced residual leaks in the very DTO it introduces. It took 5 review passes (conformity + logic, in parallel) to reach zero findings.

What was still leaking

1. outputTxId handed out the bank remittance of a sell payout. For BuyFiat, that field is not an on-chain hash but bankTx.remittanceInfo — the reference DFX writes on the outgoing transfer, visible on the recipient's bank statement. toPublicDto passed it straight through, so anyone holding the status link got it. Input and output tx identifiers are now gated on the payment method of their leg: an on-chain hash is public, a fiat leg's identifier is a bank reference and is not.

2. Hiding the AML reason was ineffective, because the state gave it away. KYC_REQUIRED is produced by nothing but KycRequiredReason = {InstantPayment, SanctionSuspicion, FraudSuspicion}. Redacting reason while keeping state meant that "KycRequired + no reason" was a reliable tell for a suspicion. The public DTO now masks that state to CheckPending — the neutral sibling the same AML branch returns for any other pending check. It is truthful (the check really is pending) and makes a suspicion indistinguishable from an ordinary manual check. Owners and staff keep the true state on the full DTO.

3. Chargeback identifiers are stripped unconditionally. Exposing them was tempting (for sells the hash is on-chain), but it would re-expose the chargebackTarget we deliberately strip: the hash resolves to the chargeback destination on an explorer, and a chargeback may go to an address other than the input sender. chargebackAmount/-Asset/-Date remain for the status UI.

Also fixed

  • isAccountSubject is back to instanceof UserData. The typeof subject.address check evaluated the UserData.address getter, which dereferences the organization relation — a 500 for organization accounts whose relation is not loaded. Covered by a regression test.
  • Dropped the silent ?? [] fallback on user.users, so a missing relation fails loud instead of quietly dropping staking entries from an export.
  • PublicTransactionReasonMap is exhaustively typed over TransactionReason: a new reason that is not classified is a compile error (fail-closed).
  • Response params typed, imports sorted.

Notes for reviewers

  • Two classification calls worth a second opinion: AccountHolderMismatch and PaymentAccountNotAllowed are kept public. Both are user-actionable and the reason mapper already sanitises the underlying AML reason, but they are the closest neighbours of the private ones.
  • Deliberate UX trade-off: an unauthenticated viewer of a KYC-blocked transaction now sees CheckPending instead of KycRequired. The routine "you need KYC" paths do not use that state (limits map to LimitExceeded, KYC steps to CheckPending/Failed), and KYC requires a login anyway.
  • No consumer change required: all affected fields are already optional in the client types and truthiness-gated in the UI; reason is null for every successful transaction today, so an absent reason is nothing new for the frontend.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant