Security

Adversarial dApp audit suite

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.

How to run

Three steps

# 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.

Coverage

Attack matrix

#VectorDefense expected
01Unconnected origin → read method4001 before any work (requireConnected)
02Unconnected origin → mutating method4001 before the approval popup
03Spoofed origin in page messageBackground uses Chrome-supplied sender.origin
04iframe postMessage with content targetBridge ignores it — ev.source !== window
05chrome.runtime from MAIN worldchrome undefined in page context
06Read sensitive state from window.chiaNo mnemonic / master_sk / private fields exposed
07Race: 5 parallel signMessageAll wait for the popup; none bypass (serialisation)
08Overwrite window.chia with a fakedefineProperty(...writable:false) blocks it
09Pretend to be the approval popupBackground only accepts decisions from extension contexts
10Replay a stale request idpending.get(id) returns undefined
1110 MB payload in signMessage4 MiB cap in bridge + SW (4029), no SW death
12Read chrome.storage.local keys from pagechrome.storage unavailable to MAIN world
13Lookalike subdomain (victim.example.evil.com)Treated as evil.com's subdomain — separate permission record
14chia_send with address only (no to)Approval popup shows the recipient verbatim
15Attacker tries to cancel victim's offerHandler filters offers by origin stamp
16walletWatchAsset with spoofed symbolPopup shows the assetId verbatim so the lie is visible
17signCoinSpends blind-sign (opaque bundle)Popup renders a decoded CoinSpendBreakdown, not "N coin spends"
Extending the suite

Adding a new attack

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",
    };
  },
}
Honesty

Hardening status

These were the documented weak spots. Here's what changed — and the residual risk that remains, stated plainly.

Blind signing depth ✓ hardened

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.

Per-method permission scopes ✓ added

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.

takeOffer royalty verification ✓ added

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".

Connection auto-expiry ✓ added

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.

Re-run the suite whenever you change background.ts, content.ts, inpage.ts, rpc-router.ts, permissions.ts, approval.ts, or any ApprovalSummary case.