Skip to content

[FEAT-93] LeanFin: Indexa Capital portfolio sync (+ stored XSS fix) - #116

Merged
jmunar merged 5 commits into
mainfrom
feat-93-indexa-capital
Aug 29, 2026
Merged

[FEAT-93] LeanFin: Indexa Capital portfolio sync (+ stored XSS fix)#116
jmunar merged 5 commits into
mainfrom
feat-93-indexa-capital

Conversation

@jmunar

@jmunar jmunar commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Summary

Two independent pieces of work. The XSS fix is first in the branch and stands
alone — it can be reviewed (or cherry-picked) without the feature.

491fae3 — HTML-escape user-controlled strings (stored XSS)

Pre-existing, not introduced by this feature. LeanFin interpolated account
names, label names and bank-supplied transaction text straight into format!
HTML templates. A name containing markup executed script on five surfaces:

Surface Context
/leanfin/accounts — manual account name element body
/leanfin/settingsapp_id value="" attribute
/leanfin — account/label <option> lists element body
/leanfin/balance-evolution — account <option> list element body
/leanfin/expenses — label pills element body

Two traps worth knowing about. The <option> cases look escaped in the served
HTML, but the browser re-parses the entity-decoded text and builds a live
<img>, firing its onerror. And the app_id case needs an attribute-shaped
payload (" autofocus onfocus="…) rather than a tag, so a tag-only probe reads
as clean.

Notes and FormInput already escaped; LeanFin was the only app that didn't.
FormInput's helper (which also escapes single quotes, making it safe in both
quoting styles) is promoted to myapps_core::components::html_escape as the
single shared implementation.

db3da8d — Indexa Capital portfolio sync

Adds Indexa as a third account provider. PSD2 covers payment accounts, not
securities/custody accounts, so Enable Banking cannot see an Indexa account
(depositary: Cecabank) — until now it could only be tracked manually.

  • Read-only personal API token, sent as X-AUTH-TOKEN, stored AES-256-GCM
    encrypted alongside the Enable Banking key. It doesn't expire, so linked
    accounts use the 9999-12-31 sentinel and never prompt for re-auth.
  • Linking backfills the full valuation history from performance.portfolios[],
    which reaches back to account opening. PSD2 gives 90 days at best.
  • The daily cron calls only /portfolio.

Privacy: /users/me and /accounts/{n} embed postal address, email and
national ID document numbers, despite Indexa's docs claiming the API excludes
personal data. Neither is payload-logged, the sync path never calls them, and
the response structs deserialize only what's needed.

Two bugs caught during development, both fixed here:

  • Indexa accounts would have charted wrong — get_balance_series sent every
    non-manual type down the bank path, which reconstructs balances by walking
    transactions backwards from a snapshot. Indexa accounts have snapshots and no
    transactions, so that yields nothing.
  • Multi-select would have 422'd in the browser. axum's Form uses
    serde_urlencoded, which can't deserialize repeated keys into a Vec, so
    account selection uses acct_{number} checkboxes into a HashMap.

Test plan

  • make check — green: fmt, clippy, 400 tests.
  • Response structs verified against live API payloads captured from a real
    account.
  • Browser-walked with /frontend-walkthrough: the section renders, the link
    route redirects to settings without a token, the token field never echoes a
    stored value, a bogus token produces the error card rather than a 5xx, and a
    traversal-shaped acct_../../etc/passwd POST persists nothing.
  • user_controlled_names_are_html_escaped asserts six routes render a stored
    payload escaped. Confirmed to fail when any single escape is reverted.

Not verified: the end-to-end link flow against Indexa with a valid token —
the walkthrough only exercised the bad-token path. Worth doing manually before
merge. Expect a flat 3-day line on the balance chart until trades execute and
history accumulates.

🤖 Generated with Claude Code

jmunar and others added 5 commits August 29, 2026 19:58
LeanFin interpolated account names, label names and bank-supplied
transaction text straight into `format!` HTML templates. A name
containing markup executed script on five surfaces:

  - /leanfin/accounts        manual account name    (element body)
  - /leanfin/settings        app_id                 (value="" attribute)
  - /leanfin                 account/label <option> (element body)
  - /leanfin/balance-evolution  account <option>    (element body)
  - /leanfin/expenses        label pills            (element body)

The <option> cases are easy to miss: the served HTML looks escaped, but
the browser re-parses the entity-decoded text and builds a live <img>,
firing its onerror. The app_id case needs an attribute-shaped payload
(`" autofocus onfocus="…`) rather than a tag, so a tag-only probe reads
as clean.

Notes and FormInput already escaped; LeanFin was the only app that did
not. FormInput's helper (which also escapes single quotes, making it
safe in both quoting styles) is promoted to myapps_core::components so
there is one shared implementation. accounts.rs had a private copy used
on only two error paths — removed in favour of the core one.

Escaping now covers accounts, settings, labels, dashboard, expenses,
balance-evolution and transactions, including bank-supplied description
and counterparty, which a hostile payee name could otherwise weaponise.

Found by /frontend-walkthrough. The regression test asserts six routes
render a stored payload escaped; it was confirmed to fail when any one
escape is reverted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds Indexa Capital as a third account provider alongside Enable Banking
and manual accounts, so investment accounts held at Indexa (depositary:
Cecabank) appear in net worth and the balance chart without manual entry.

PSD2 covers payment accounts, not securities/custody accounts, so Enable
Banking can never see these — until now the only option was a manual
account with hand-typed values.

Auth is a long-lived personal token (Ajustes -> Aplicaciones), sent as
X-AUTH-TOKEN, stored AES-256-GCM encrypted in leanfin_user_settings via
the same path as the Enable Banking key. It does not expire, so linked
accounts use the 9999-12-31 sentinel and never need re-authorization.

Linking pulls the full valuation history from performance.portfolios[],
which reaches back to account opening — PSD2 gives 90 days at best. The
daily cron then calls only /portfolio.

Privacy: /users/me and /accounts/{n} embed postal address, email and
national ID document numbers, despite Indexa's docs claiming the API
excludes personal data. Neither is payload-logged, the sync path never
calls them, and the response structs deserialize only what is needed.
/portfolio and /performance carry no personal data and are logged as
usual under provider='indexa'.

Also:
  - Non-bank accounts now use the snapshot-based balance series. Indexa
    accounts have snapshots but no transactions, so the bank path (which
    walks transactions backwards from a snapshot) yielded nothing.
  - Account selection uses acct_{number} checkboxes rather than a
    repeated field: axum's Form uses serde_urlencoded, which cannot
    deserialize repeated keys into a Vec, so multi-select would have 422'd.
  - Account numbers are validated before URL interpolation, and the link
    POST only accepts numbers the token actually owns.

Response structs verified against live API payloads. The browser flow
was walked with /frontend-walkthrough, including the bad-token error
path and adversarial account numbers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
architecture.md: Indexa Capital integration section (token auth, endpoints
used, personal-data handling, link and sync flows, balance-series routing),
new routes, tech-stack row.

requirements.md: investment account integration requirements, three-section
accounts page, token storage and output-escaping under Security.

CLAUDE.md: escaping convention, including the <option> re-parsing trap that
makes an escaped-looking payload execute.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Regenerate screenshots so leanfin-accounts.png shows the new Indexa
Capital section. The screenshot script needed no changes — it already
captures /leanfin/accounts, and the section is additive.

Also correct the accounts page subtitle, which still described only bank
and manual accounts, and mention Indexa in the README app table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jmunar
jmunar merged commit 7399fec into main Aug 29, 2026
2 checks passed
@jmunar
jmunar deleted the feat-93-indexa-capital branch August 29, 2026 18:28
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