Problem
Three facts combine into a stored cross-site scripting vulnerability on an origin that holds live
wallet connections.
1. The chain accepts arbitrary attacker-controlled strings.
referral_registry::register_referral(user, display_name, referrer) is gated only by
user.require_auth() -- any funded account may call it. The display_name it stores is written
straight to persistent storage with no length limit, no character restriction, and no
sanitisation of any kind:
env.storage()
.persistent()
.set(&DataKey::DisplayName(user.clone()), &display_name);
There is no validation anywhere in register_referral. (The PR that would have capped it at 64
characters was never merged -- and 64 characters is more than enough for a payload regardless.)
get_display_name(user) hands the string back to any caller.
2. The frontend's entire rendering pipeline is innerHTML with unescaped template literals.
Every list in the app is built by string concatenation and assigned to innerHTML:
frontend/pages.js:2 -- market cards, interpolating m.title, m.category, m.close
frontend/pages.js:3 -- leaderboard rows, interpolating p.name and p.address
frontend/script.js:126 -- market list, interpolating market.title, market.detail
frontend/script.js:186 -- position list, interpolating position.explorerUrl into an href
Nothing is escaped. There is no textContent on any of these paths, no sanitiser, no templating
library that would escape by default.
3. Two open issues are about to connect the two.
The leaderboard issue and the referral issue both call for replacing the hardcoded leaders array
with real on-chain identities, which is exactly get_display_name. The moment that lands, an
attacker-supplied string flows into innerHTML.
Impact
A single register_referral call with a display_name of
<img src=x onerror="...">
stores executable markup on chain. It then runs in the browser of every visitor to the
leaderboard, permanently, because it lives on chain rather than in a database anyone can edit.
The origin it executes on is not a blog. It is the origin that:
- holds the connected wallet session
- builds Soroban transactions
- calls the wallet's
signTransaction
so injected script can rewrite the operation before the user is asked to approve it, prompt for
signatures of its own, read every address the visitor has connected, or replace the market contract
address in memory. There is currently no Content-Security-Policy on the site to contain any of
it -- see the CDN issue for the confirmed missing headers.
The payload is also censorship-resistant in the worst way: it cannot be deleted, because it is in
contract storage. Removing it means a contract migration or a frontend blocklist.
What to do
Why this is critical
This is a wallet-draining vulnerability that any user can plant, that persists forever, and that
the two most-requested frontend features will activate. It should be fixed before the
leaderboard and referral work lands, not after.
Problem
Three facts combine into a stored cross-site scripting vulnerability on an origin that holds live
wallet connections.
1. The chain accepts arbitrary attacker-controlled strings.
referral_registry::register_referral(user, display_name, referrer)is gated only byuser.require_auth()-- any funded account may call it. Thedisplay_nameit stores is writtenstraight to persistent storage with no length limit, no character restriction, and no
sanitisation of any kind:
There is no validation anywhere in
register_referral. (The PR that would have capped it at 64characters was never merged -- and 64 characters is more than enough for a payload regardless.)
get_display_name(user)hands the string back to any caller.2. The frontend's entire rendering pipeline is
innerHTMLwith unescaped template literals.Every list in the app is built by string concatenation and assigned to
innerHTML:frontend/pages.js:2-- market cards, interpolatingm.title,m.category,m.closefrontend/pages.js:3-- leaderboard rows, interpolatingp.nameandp.addressfrontend/script.js:126-- market list, interpolatingmarket.title,market.detailfrontend/script.js:186-- position list, interpolatingposition.explorerUrlinto anhrefNothing is escaped. There is no
textContenton any of these paths, no sanitiser, no templatinglibrary that would escape by default.
3. Two open issues are about to connect the two.
The leaderboard issue and the referral issue both call for replacing the hardcoded
leadersarraywith real on-chain identities, which is exactly
get_display_name. The moment that lands, anattacker-supplied string flows into
innerHTML.Impact
A single
register_referralcall with adisplay_nameofstores executable markup on chain. It then runs in the browser of every visitor to the
leaderboard, permanently, because it lives on chain rather than in a database anyone can edit.
The origin it executes on is not a blog. It is the origin that:
signTransactionso injected script can rewrite the operation before the user is asked to approve it, prompt for
signatures of its own, read every address the visitor has connected, or replace the market contract
address in memory. There is currently no
Content-Security-Policyon the site to contain any ofit -- see the CDN issue for the confirmed missing headers.
The payload is also censorship-resistant in the worst way: it cannot be deleted, because it is in
contract storage. Removing it means a contract migration or a frontend blocklist.
What to do
innerHTMLfor anything carrying dynamic data. Build nodes and assigntextContent, or escape every interpolation at a single choke pointdisplay_name, marketquestion, and marketimage_urlimage_urlagainst an allowlist of schemes -- rejectjavascript:anddata:outright -- before it is ever placed in a
srcorhrefContent-Security-Policyheader, so a missed escape is contained rather than fataldisplay_nameinreferral_registryas defence indepth, so the chain stops storing payloads at all
Why this is critical
This is a wallet-draining vulnerability that any user can plant, that persists forever, and that
the two most-requested frontend features will activate. It should be fixed before the
leaderboard and referral work lands, not after.