[FEAT-93] LeanFin: Indexa Capital portfolio sync (+ stored XSS fix) - #116
Merged
Conversation
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>
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.
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:
/leanfin/accounts— manual account name/leanfin/settings—app_idvalue=""attribute/leanfin— account/label<option>lists/leanfin/balance-evolution— account<option>list/leanfin/expenses— label pillsTwo traps worth knowing about. The
<option>cases look escaped in the servedHTML, but the browser re-parses the entity-decoded text and builds a live
<img>, firing itsonerror. And theapp_idcase needs an attribute-shapedpayload (
" autofocus onfocus="…) rather than a tag, so a tag-only probe readsas 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_escapeas thesingle shared implementation.
db3da8d— Indexa Capital portfolio syncAdds 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.
X-AUTH-TOKEN, stored AES-256-GCMencrypted alongside the Enable Banking key. It doesn't expire, so linked
accounts use the
9999-12-31sentinel and never prompt for re-auth.performance.portfolios[],which reaches back to account opening. PSD2 gives 90 days at best.
/portfolio.Privacy:
/users/meand/accounts/{n}embed postal address, email andnational 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:
get_balance_seriessent everynon-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.
Formusesserde_urlencoded, which can't deserialize repeated keys into aVec, soaccount selection uses
acct_{number}checkboxes into aHashMap.Test plan
make check— green: fmt, clippy, 400 tests.account.
/frontend-walkthrough: the section renders, the linkroute 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/passwdPOST persists nothing.user_controlled_names_are_html_escapedasserts six routes render a storedpayload 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