A Playwright-driven suite loads the Loroco extension and runs a hostile dApp through
a matrix of attack vectors from a fake origin. Each case verifies a concrete defense in the
page → content → background pipeline. The script exits 0 only if every attack was blocked.
# 1. Build the extension (Rust changes also need pnpm wasm:build)
pnpm --filter @ozone/extension build
# 2. Start fresh — isolated from the regular test profile
rm -rf /tmp/Loroco-PW-Security
# 3. Run
node scripts/security/pw-security-audit.mjs
The suite imports a wallet with the fixed test mnemonic, then probes the attacker dApp from
https://attacker.example/ (served via Playwright route()). Output is a pass/fail line per attack:
[sec] === ATTACK 01: unconnected read ===
[sec] ✓ DEFENSE HELD — got code=4001 "https://attacker.example is not connected"
[sec] === ATTACK 09: spoof approval message ===
[sec] ✗ DEFENSE FAILED — approval was accepted from page context
A ✓ DEFENSE HELD means the attack was blocked. A ✗ DEFENSE FAILED is a real
finding — the script exits 1 and prints what got through.
| # | Vector | Defense expected |
|---|---|---|
| 01 | Unconnected origin → read method | 4001 before any work (requireConnected) |
| 02 | Unconnected origin → mutating method | 4001 before the approval popup |
| 03 | Spoofed origin in page message | Background uses Chrome-supplied sender.origin |
| 04 | iframe postMessage with content target | Bridge ignores it — ev.source !== window |
| 05 | chrome.runtime from MAIN world | chrome undefined in page context |
| 06 | Read sensitive state from window.chia | No mnemonic / master_sk / private fields exposed |
| 07 | Race: 5 parallel signMessage | All wait for the popup; none bypass (serialisation) |
| 08 | Overwrite window.chia with a fake | defineProperty(...writable:false) blocks it |
| 09 | Pretend to be the approval popup | Background only accepts decisions from extension contexts |
| 10 | Replay a stale request id | pending.get(id) returns undefined |
| 11 | 10 MB payload in signMessage | 4 MiB cap in bridge + SW (4029), no SW death |
| 12 | Read chrome.storage.local keys from page | chrome.storage unavailable to MAIN world |
| 13 | Lookalike subdomain (victim.example.evil.com) | Treated as evil.com's subdomain — separate permission record |
| 14 | chia_send with address only (no to) | Approval popup shows the recipient verbatim |
| 15 | Attacker tries to cancel victim's offer | Handler filters offers by origin stamp |
| 16 | walletWatchAsset with spoofed symbol | Popup shows the assetId verbatim so the lie is visible |
| 17 | signCoinSpends blind-sign (opaque bundle) | Popup renders a decoded CoinSpendBreakdown, not "N coin spends" |
Each attack is a function registered in the ATTACKS array. Cases are independent — the suite
runs them sequentially against a single persistent context, but none may rely on state from another.
{
id: "13",
name: "describe the attack",
run: async ({ attacker, popup, ctx, sw, victim }) => {
// Drive the page / iframes / SW probe.
const out = await attacker.evaluate(...);
return {
held: /* true = defense held, false = attack succeeded */,
detail: "human description for the log line",
};
},
}
These were the documented weak spots. Here's what changed — and the residual risk that remains, stated plainly.
signCoinSpends / sendTransaction run the actual CLVM and surface recipients, amounts, fees, value leaving via unrecognised puzzles, and any replayable AGG_SIG_UNSAFE. A bundle Loroco can't fully decode now forces an explicit acknowledgement before Approve unlocks. Residual: a bespoke CLVM puzzle can still encode effects we don't classify — which is exactly why the acknowledgement + raw-params view exist. Trust the engine's decode, not the dApp's framing.
Connect now grants read-only or full access. A read-only connection is rejected outright (4001) on any signing/mutating method before any popup. dApps follow least privilege: they request a ceiling via connect({ scope }) and the user can only lower it — a read-only request is locked and can't be upgraded.
The popup now shows the concrete royalty amount and destination puzzle hash, re-derived by the engine from the offered NFT's on-chain royalty puzzle — independent of what the site claims, labelled "verified on-chain".
Grants now carry a 7-day sliding window: active use keeps a connection alive, inactivity auto-disconnects it. Expiry is enforced on access and swept periodically; the countdown shows in Settings → Connected sites.
background.ts, content.ts, inpage.ts,
rpc-router.ts, permissions.ts, approval.ts, or any ApprovalSummary case.