morphit/docs/audit/2026-04-28-followup-items-1-thru-6.md

14 KiB
Raw Permalink Blame History

Audit — follow-up items 1, 3, 4, 5, 6

Date: 2026-04-28 Scope: Items shipped in the post-Batch-I follow-up campaign. Item 2 (release-trust-anchor frontend wiring) was investigated but deferred to Batch J; not in this audit's surface. Item 7 IS this document.

Item Description Surfaces audited
1 ELI5 comments on useActiveKey / useOwnerKey / runWithActiveKey Comment-only; no behavior change
3 Operator-blocked-user notification ("honest-and-narrow") Chain op handler, DB schema, two API endpoints, ops-cli command, frontend banner, orderbook filter
4 Order expiry re-list flow relistOrder() helper, post-page prefill consumer expansion
5 Profile balance/MANA private div balanceMath helpers, MyBalanceCard component, RPC fetch path
6 P&L CSV export Categorizer, CSV builder (RFC 4180 + injection mitigation), 365-day history fetch

Methodology

Per the user's standing instruction ("think like an experienced black hat hacker"), each surface was reviewed for:

  • STRIDE per surface: spoofing, tampering, repudiation, information disclosure, denial of service, elevation of privilege.
  • Hostile input sweep on each handler / parser / consumer.
  • Chain-direct re-pass: assume the chain RPC and indexer DB rows are attacker-controlled; verify the frontend can't be coerced into unsafe behavior.
  • Cross-tab / multi-tab race conditions (continuation of Batch I's M6 line of inquiry).
  • Side-channel review for any surface that touches sensitive material (MANA / balance card displays public chain data only — no side channels relevant; reason/memo display reviewed for bidi-spoofing).

Severity codes:

  • HIGH — exploitable in a realistic attack scenario; user harm plausible.
  • MEDIUM — exploitable in a contrived scenario, or non-exploitable but enables further attack chaining.
  • LOW — defense-in-depth gap; no realistic exploit alone.
  • INFO — informational; not a vulnerability.
  • NOTED — reviewed and confirmed safe / acceptable.

Findings — applied this audit

#1 (LOW, item 5) — errorMsg rendered without length cap

Surface: MyBalanceCard.svelte's catch handler stores the RPC error message verbatim and renders it in the UI.

Issue: A malicious or buggy RPC node returning a multi-kilobyte error message could break the card's layout and the surrounding profile page. Not exploitable for code execution (Svelte's text interpolation is HTML-safe), but a denial-of-render concern.

Fix: Cap raw message at 200 chars before display, append when truncated. Applied to both refresh() (balance fetch) and exportPnl() (P&L export) catch blocks.

Severity post-fix: N/A — closed.


#7 (MEDIUM, item 6) — op.op destructure not type-checked

Surface: apps/web/src/lib/pnl/categorize.ts's categorizeOp() destructures op.op as [opName, body].

Issue: The HistoryOp type declares op as readonly [string, Record<string, unknown>] — but the data comes from a possibly-hostile RPC. A malformed op.op (null, non-array, length 0) would crash the destructure. Caller wraps in try/catch, so the user sees a generic error rather than a stack trace, but the export silently aborts on even one malformed entry — losing legitimate rows that came AFTER the bad one in the page.

Fix: Early guards at top of categorizeOp:

if (!Array.isArray(op.op) || op.op.length < 2) return null;
const [opName, body] = op.op;
if (typeof opName !== 'string') return null;
if (typeof body !== 'object' || body === null) return null;

Smoke: Added 3 scenarios to pnl-smoke.ts covering null op.op, empty-array op.op, and non-object body. All pass.

Severity post-fix: N/A — closed.


#8 (LOW, item 4) — terms length not capped before sessionStorage write

Surface: apps/web/src/routes/my/orders/+page.svelte's relistOrder() writes the order's terms field directly into sessionStorage.

Issue: A malicious indexer could return an oversized terms string (multi-megabyte). Stuffing into sessionStorage hits the origin's quota (510 MB browser-dependent), potentially breaking other session-state writes (draft restore, prefills, etc.). The post-page form already caps on submit, but the prefill path bypasses that.

Fix: Cap terms at 5000 chars when building the prefill payload ((o.terms ?? '').slice(0, 5000)). The post-page form already enforces this same cap on submit; no UX regression.

Severity post-fix: N/A — closed.


#10 (HIGH, item 3) — operator reason not validated for control / bidi codepoints

Surface: Indexer handler for morphit_operator_block_v1 accepts any string ≤500 chars as the operator's reason. Stored verbatim. Banner displays verbatim (with HTML-escape via Svelte interpolation).

Issue: A malicious operator (or a compromised operator key) could embed:

  • Bidi override codepoints (U+202AU+202E, U+2066U+2069): flips display order. Operator writes "spam attack" but it renders as "kcatta maps" — could be used to make a benign-looking reason appear hostile, or vice versa. More importantly in the banner context, could escape the reason-block and visually re-order the surrounding "What this does NOT do" list, undermining the honest-and-narrow design intent.
  • Zero-width chars (U+200BU+200D, U+FEFF): invisible content used to fingerprint copies of a reason or to make two visually-identical reasons differ byte-wise (could be used to evade automated content moderation if/when we add it).
  • Null bytes (U+0000) and other C0/C1 control chars: most display as nothing, but some downstream tooling (CSV log streams, audit exports) might truncate or interpret unexpectedly.

Severity rationale: HIGH because the attacker is the operator themselves — exactly the role the user is told to trust in the banner. A trust-undermining attack on a trust-establishing UI is material, even if no funds move.

Fix (defense-in-depth):

  1. Indexer handler strips dangerous codepoints before storage. FORBIDDEN_REASON_CODEPOINTS set mirrors the existing display-name stripper (apps/web/src/lib/crypto/profile.ts). Strips: bidi (9 codepoints), zero-width (4), invisible math (5), C0 control chars except LF/TAB, all C1 control chars. Newline and tab preserved because legitimate multi-line reasons exist.
  2. Banner component mirrors the same set and re-strips on render. Belt-and-braces: catches data already in the DB from before this fix, AND a sibling-instance indexer that hasn't applied the fix yet.
  3. ops-cli command strips the reason BEFORE broadcast, surfacing a warning to the operator if anything was stripped (paste-from- malicious-doc accident detection).

Smokes:

  • "sanitizes bidi-override codepoints from reason" — U+202E in reason → 'spam\u202Eattack' becomes 'spamattack' in DB row.
  • "sanitizes zero-width-joiner / null bytes from reason" — U+200B and \x00\x01 stripped.
  • "preserves newline + tab in reason (legitimate multi-line)" — control chars whitelist verified.

Severity post-fix: N/A — closed at server side. The banner-side strip is layer 2 defense, also in place.


#13 (LOW, item 3) — /by-blocked query missing ORDER BY

Surface: GET /v1/operator-blocks/by-blocked/:account SQL with LIMIT 1 and no ORDER BY.

Issue: Schema-level uniqueness (PK + handler operator gate) makes multiple matching rows impossible in normal operation. A corrupted DB or operational mistake could result in multiple rows; the LIMIT 1 returns Postgres' arbitrary choice. Cosmetic / determinism issue; no exploit.

Fix: Added ORDER BY updated_at DESC so behavior is deterministic under any database state. Most-recent-action wins, matching the audit trail intent.

Severity post-fix: N/A — closed.


#15 (HIGH, item 3) — banner reason rendering — duplicated by #10

The banner-side mitigation discussed above. Marked closed by the indexer-side fix (#10) plus the banner-side belt-and-braces strip. No separate fix needed.


Findings — accepted as-is (not patched)

#2 (INFO, item 5) — parseAssetAmount no value-bound

A malicious RPC returning "5000000000000 BLURT" would display 5 trillion BLURT. Visually misleading, not exploitable. The chain itself enforces sane balance limits at consensus. Real-world mitigation is the user noticing "that's way too much, the chain doesn't have that much BLURT in circulation." Accepted; no fix applied.

#3 (NOTED, item 5) — onDestroy + in-flight refresh

Reviewed. Svelte 5 runes-based components: $state writes after destruction are no-ops; the setInterval is cleared in onDestroy. No leak, no race that affects security. Confirmed safe.

#4 (MEDIUM, item 5) — top-up sessionStorage prefill writable by any same-origin script

A successful XSS could write to morphit.post.prefill and force the user toward composing an order with attacker-chosen amounts. The user still has to confirm the order, sign it (password / YubiKey prompt), and pay the listing fee — all of which surface the trade details for review. The prefill consumer validates types defensively (rejects non-string fields, clamps expiresDays).

The "real" fix would be HMAC-signing the prefill payload with a session key. Cost-benefit: an attacker with XSS already has substantial leverage; this surface adds marginal additional risk. Accepted as defense-in-depth gap; not patched. Documented in the component for future work if XSS-amplification ever becomes a concern.

#5 (NOTED, item 6) — bogus op.timestamp and pagination termination

Reviewed. The 5-page (50_000 ops) cap bounds worst-case behavior even when timestamp parsing always returns NaN. The catch in exportPnl surfaces any thrown error as the generic "couldn't build report" message. Confirmed safe.

Categorization issue. A user who sends a tip to @morphit labeled featured-bid: foo would have it categorized as "Featured bid (paid)" in their P&L. This is a self-mislabel by the user, not an external attack vector. Operators of a less-strict instance might also have their non-bid memos catch on this prefix; documented behavior. Accepted as-is.

#11 (NOTED, item 3) — reason length in UTF-16 code units

Reviewed. The 500-char cap is in string.length units (UTF-16 code units), so 500 ASCII fit, ~250 emoji fit, fewer surrogate pairs fit. The smoke explicitly tests this boundary. Consistent with the project's other char-count caps. Accepted as the design's intent.

#12 (NOTED, item 3) — no handler-level rate limit on operator-block ops

Chain-level RC (resource credits) rate-limits the operator's posting key globally. If the key were compromised, the attacker spamming block ops would burn through RC and stop. An indexer-level rate limit would be redundant. Confirmed safe.

#14 (NOTED, item 3) — /by-operator 5MB worst-case body

10_000 rows × 500-char reason ≈ 5 MB. Acceptable response size for an admin-style endpoint. Morphit explicitly does not use Cloudflare, so no CDN-cache-poisoning concern. Confirmed safe.

UX issue, not security. If the operator hasn't published a chat identity, the link goes to a chat page that shows an error. Future enhancement: probe chat-identity availability and fall back to a "contact operator at " affordance from the operator-register record if chat isn't available. Filed in REVISIT-LIST.md §F.27 for Phase G+.


Cross-surface findings (not in initial pass)

CS-1 (NOTED) — operator-block + user-block + hidden-accounts overlap

Three independent sets filter the orderbook view:

  • hiddenAccounts (per-user, per-browser, localStorage) — "I don't want to see X here."
  • blockedAccounts (per-user, chain-broadcast) — "X has blocked me from messaging them; symmetrically I hide them too."
  • operatorBlockedAccounts (per-instance, chain-broadcast by operator) — "this instance hides X."

Reviewed for interaction:

  • All three are unioned in visibleItems derived value. ✓
  • The transparency toggle (showHiddenTemporarily) reveals all three uniformly. ✓
  • Per-row rendering doesn't expose which set caused the hide (good — protects the operator from "you're being filtered by the operator specifically" inference attacks based on which badge appears). ✓

Confirmed safe.

CS-2 (NOTED) — sessionStorage prefill key shared between item 5 (top-up) and item 4 (re-list)

Both items write to morphit.post.prefill. Last-writer-wins. Both clear after read on the post page. If a user clicks "Top up BLURT" on their profile, then immediately navigates to /my/orders and clicks "Re-list" without going through the post page, the re-list overwrites the top-up prefill — the user sees their re-listed order's fields, not BLURT-buy. Acceptable behavior — the user's most- recent intent wins.

If the user clicks both in opposite order, same result. The "stale prefill from previous tab" case is handled by the one-shot read-and-clear. Confirmed safe.


Smoke regression posture

  • 860 total scenarios passing (was 854 pre-audit + 6 new for #7 and #10).
  • Typecheck clean, no new errors beyond the pre-existing baseline.
  • i18n drift = 0 across 1689 keys × 10 locales.

Outstanding (not in this audit's scope)

  • Batch I H2 — WebHID transport unverified against real hardware. Probe page is built and waiting for the user to run it with a YubiKey. Independent of this follow-up batch.
  • Item 2 — release-trust-anchor frontend wiring deferred to Batch J. Schema and pinned pubkey exist; signature-verify code does not.
  • External pre-launch audit by a security firm experienced in browser-based crypto. Recommended before production launch.
  • Phase G mobile PWA polish — gated on this campaign closing. Ready to start.

Sign-off

This audit closes 5 findings (1 LOW, 1 MEDIUM, 2 HIGH, 1 LOW). 11 findings reviewed and accepted as-is (4 NOTED-safe, 4 INFO, 1 MEDIUM-defense-in-depth-doc, 2 NOTED). No findings remain open on items 1, 3, 4, 5, 6 surfaces.

Items 1, 3, 4, 5, 6 considered shippable.