9.4 KiB
Audit — Batch L: canonical payment-methods registry + instance additions
Date: 2026-04-29 Scope: Code introduced for ADR-0021. Builds on Batch I/J/K audit methodology.
| Surface | Files |
|---|---|
| Canonical registry | apps/web/src/lib/payments/registry.ts |
| Search helper | apps/web/src/lib/payments/search.ts |
| Legacy resolver | apps/web/src/lib/payments/match.ts |
| Display helper | apps/web/src/lib/payments/display.ts |
| Picker component | apps/web/src/lib/components/PaymentMethodsPicker.svelte |
| Instance-additions store | apps/web/src/lib/stores/instanceAdditions.ts |
| Op id constants | apps/web/src/lib/net/config.ts (operatorPaymentMethod), apps/indexer/src/indexer/dispatcher.ts |
| Indexer handler | apps/indexer/src/indexer/handlers/operatorPaymentMethod.ts |
| Indexer API | apps/indexer/src/api/instancePaymentMethods.ts |
| Schema migration | apps/indexer/src/db/schema-v24.sql |
| ops-cli | apps/ops-cli/src/commands/paymentMethod.ts |
| Wired sites | post page, edit page, orderbook page, profile page, order-detail page |
Methodology
Same lens as prior audits — STRIDE per surface, hostile-input sweep on every parser/consumer, "experienced black hat hacker" mindset, federation-safety review.
Severity: HIGH / MEDIUM / LOW / INFO / NOTED.
Findings
L-1 (NOTED) — Operator-account spoofing gate
Handler enforces ctx.signer === ctx.config.officialAccountName.
Same trust gate as operator_block_v1. Confirmed.
L-2 (NOTED, CRITICAL) — Reserved-key shadowing
If an operator could broadcast key='paypal' and the indexer
accepted it, that operator's picker would silently shadow the
canonical entry — federation breaks because cross-instance
order filtering by paypal would now match different things.
Defense: RESERVED_CANONICAL_KEYS set in the handler; rejects
with key_reserved, no DB write. Smoke covers this with
explicit paypal, zelle, cash cases. Confirmed safe.
L-3 (NOTED) — Cross-spelling shadowing
Operator could add key='pay_pal' (underscore) which doesn't
collide with any canonical key. The picker renders BOTH the
canonical paypal (display name "PayPal") and the instance
@instance:pay_pal (operator-supplied display name). The
"(this instance only)" badge visually distinguishes them.
This is by design — operators have legitimate reasons to add region-specific methods that look similar (e.g. "PayPal Pakistan" might warrant a separate entry due to different account-verification rules). The badge protects users from confusion. Accepted.
L-4 (NOTED) — XSS via name/description rendering
The picker renders names and descriptions via Svelte's default
text interpolation ({entry.name}, {descFor(entry.key)}),
which HTML-escapes. Display chip uses {nameFor(key)}. Order-
detail and profile pages use displayNamesForMethods(...).join(', ')
which also goes through Svelte interpolation. Confirmed safe.
L-5 (NOTED) — Operator-supplied content is operator's responsibility
A malicious operator can write a 300-char description that's misinformation or social-engineering text. This is by design: the operator runs the instance, sets its policies, and is accountable to their users. Same as the operator-block reason field's posture. Confirmed acceptable.
L-6 (NOTED) — URL field is stored but not currently linked
entry.url is in the schema and the picker stores it for
future use, but no current code creates an <a href={url}>.
The indexer validates https:// prefix only, so a future code
change that DOES link the URL would still need to defend
against javascript: schemes (which the prefix check already
prevents). Confirmed safe; flagged for awareness if anyone
adds URL-as-link rendering later.
L-7 (NOTED) — Picker render race vs instance-additions fetch
The instance-additions store starts empty and resolves async. For a fast-clicking user, the picker might briefly show no instance options before the fetch resolves. Svelte re-renders on store update, so the eventual state is correct. Confirmed acceptable — instance additions are an extension, not a dependency.
L-8 (NOTED) — Legacy resolver doesn't shadow instance keys
resolveLegacy("@instance:paypal") → first checks
findPaymentMethod("@instance:paypal") (returns null, not in
canonical set), then folded-name lookup (also no match), then
returns original text unchanged. Display helper recognizes the
prefix and routes to instanceLookup, falling back to
stripping the prefix if the key isn't known on this instance.
No exploit path. Confirmed safe.
L-9 (NOTED) — User can't type arbitrary picker keys
The picker is multi-select over a fixed list (canonical + instance additions). No free-text input means no way to type keys that match nothing or that probe for collisions. Confirmed safe.
L-10 (NOTED) — Compromised-indexer trust posture
A compromised indexer could lie about additions. Mitigation: the frontend independently knows the canonical list (ships with the code); a malicious indexer can't shadow canonical entries because their keys never go through the instance- lookup branch. The visual badge distinguishes additions from canonical entries. Confirmed: same trust posture as the rest of the app, no regression.
L-11 (NOTED, CRITICAL) — Reserved-keys parity drift
If a future canonical entry is added to the frontend registry
but not the indexer's RESERVED_CANONICAL_KEYS, an operator
could accidentally or maliciously shadow it.
Defense: reserved-keys-parity-smoke reads both sources and
asserts they match exactly. Drift fails CI immediately.
Confirmed.
L-12 (INFO) — Sanitization is silent strip
U+202E (RIGHT-TO-LEFT OVERRIDE) and other dangerous
codepoints are stripped silently before storage. The ops-cli
surfaces the strip count to the operator; the indexer does not
echo the strip. Operators broadcasting via raw chain ops would
see the chain-stored value (sanitized) differ from what they
posted. Acceptable — same posture as operator-block reasons.
L-13 (NOTED) — Direct-broadcast bypass of CLI
An operator with their posting key can post the chain op directly. The indexer applies all the same validation (key shape, length, sanitization, reserved-keys, https URL) so the CLI is operator-friendliness, not a security boundary. Confirmed.
L-14 (NOTED) — DoS by infinite additions
No per-operator cap on additions. An attacker who has the operator's posting key has already broken the operator account; trashing the picker UI is the smallest concern at that point. Out of scope.
L-15 (NOTED) — Legacy-orders migration is transparent
Pre-Batch-L orders carry free-text strings like "PayPal". The
orderbook filter on the indexer side is case-insensitive substring
match, so a filter for paypal (canonical key) matches both new
orders posting the canonical key AND legacy orders posting the
display name. Confirmed transparent migration.
Cross-surface findings
CS-L-1 (NOTED) — Federation safety preserved
Plan A (canonical-only-with-instance-additions): the canonical list is global; instance additions are local with namespaced keys. A buyer on instance B viewing a seller's order from instance A sees:
- Canonical methods (PayPal, Wise) → match by display name
via
displayNamesForMethods(canonical lookup hits). - Instance-A additions (@instance:foo) → display falls back
to stripping the prefix (
foo) since instance B's lookup has no such key. Slightly degraded but informative.
This is the documented federation behavior. Confirmed.
CS-L-2 (NOTED) — Three-place duplication of canonical keys
Canonical keys are listed in:
apps/web/src/lib/payments/registry.ts(frontend, full entries with name/url/category).apps/indexer/src/indexer/handlers/operatorPaymentMethod.ts(indexer's RESERVED_CANONICAL_KEYS, just the keys).apps/ops-cli/src/commands/paymentMethod.ts(CLI's RESERVED_CANONICAL_KEYS, just the keys).
Drift between 1 and 2 is caught by reserved-keys-parity-smoke. Drift between 1 and 3 is NOT currently caught. The CLI's check is informational (the indexer rejects regardless), so drift would result in a worse-but-not-broken UX (CLI permits a broadcast that the indexer rejects).
Recommendation accepted as-is: not adding a third smoke because (a) the CLI's set is a courtesy check, (b) the indexer is the actual security boundary, and (c) anyone updating the frontend registry would naturally update the CLI as well — but if they didn't, the failure mode is "operator gets a clear error from the indexer," not security loss.
Smoke regression posture
- 1049 total scenarios passing (was 1020 pre-Batch-L; +1 reserved- keys-parity + 28 operator-payment-method-handler scenarios).
- Typecheck clean.
- i18n drift = 0 across 1861 keys × 10 locales.
Outstanding (not in this audit's scope)
- Batch I H2 — WebHID transport hardware probe (independent).
- External pre-launch security audit by a security firm.
- Phase G mobile PWA polish — gated on this campaign closing.
- Future: tx-by-id explorer fetch from non-tx-index nodes.
- Future: payment_methods endpoint observability (rate-limit tier, latency).
Sign-off
This audit reviewed 15 surface findings + 2 cross-surface findings. Zero open issues. The two CRITICAL items (L-2 reserved-key shadowing and L-11 reserved-keys parity drift) are both protected by smokes that fail CI on regression.
Batch L considered shippable.