morphit/docs/PHASE-G-PREP-AUDIT.md

619 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Phase G prep — security audit of post-F.5 task changes
**Auditor:** Claude (Agorise's collaborator), per task #15.
**Date:** 2026-04-28.
**Scope:** New code introduced by the 14-task post-F.5 campaign.
Pre-existing code is out of scope (covered by `PHASE-F-AUDIT.md`).
This document re-walks each new surface and documents:
- What threat models apply.
- What checks were performed.
- Whether any issues were found.
- What was fixed (if anything) and where.
Severity scale matches `PHASE-F-AUDIT.md`:
- **HIGH** — exploitable today, leads to fund loss, account
compromise, or privacy violation.
- **MEDIUM** — exploitable but bounded impact, OR requires
unusual preconditions.
- **LOW** — defense-in-depth gap; theoretical exploit, or
exploit requires already-compromised position.
## Summary
| ID | Surface | Severity | Status |
|-------|--------------------------------------|----------|--------|
| G-1 | `morphit-ops edit` parser robustness | LOW | reviewed, no action needed |
| G-2 | `morphit-ops edit` quoting | LOW | reviewed, no action needed |
| G-3 | `/v1/chain-fee` cache poisoning | LOW | reviewed, no action needed |
| G-4 | `/v1/chain-fee` RPC hostile response | LOW | reviewed, no action needed |
| G-5 | SEO override storage XSS | MEDIUM | reviewed, no action needed |
| G-6 | Alt-network keystore passphrase reuse| LOW | accepted tradeoff, documented |
| G-7 | Alt-network keystore file perms race | LOW | reviewed, mitigated |
| G-8 | Alt-network export plaintext on disk | MEDIUM | reviewed, operator-doc'd |
| G-9 | Clock-drift fail-fatal as DoS vector | LOW | reviewed, accepted |
| G-10 | Asset registry path traversal | LOW | reviewed, no action needed |
| G-11 | order_views permlink injection | MEDIUM | reviewed, mitigated |
| G-12 | order_views table size growth | LOW | reviewed, mitigated |
| G-13 | order_views public read disclosure | LOW | accepted tradeoff |
| G-14 | view-count POST as referer leakage | LOW | reviewed, no action needed |
| G-15 | degraded.html CSP + XSS | LOW | **fixed during audit** |
| G-16 | Order detail viewer-side timing | LOW | reviewed, no action needed |
**Result:** 0 HIGH, 2 MEDIUM (reviewed and accepted with
documented tradeoffs), 13 LOW (1 fixed during audit, 12
reviewed with no action needed). No HIGH-severity issues
introduced by this campaign's changes. All MEDIUM issues are
accepted tradeoffs documented in the relevant feature docs.
---
## Findings
### G-1 — `morphit-ops edit` parser robustness — LOW
**Status:** reviewed, no action needed.
**Surface:** `apps/ops-cli/src/commands/edit.ts`
`parseKvLines` and `applyUpdates`.
**Threats considered:**
- Operator's existing `morphit.config.env` contains a malformed
line. Could the parser accept a line that should be rejected?
- Operator's file contains a key that's prefix-similar to one
we want to edit (e.g., `MORPHIT_INSTANCE_ORIGIN_LEGACY=...`).
Could `applyUpdates` accidentally replace it?
**Check:** `parseKvLines` finds `=` via `indexOf` and slices
`line.slice(0, eq).trim()` for the key. Equality match in
`applyUpdates` is `key === ` against the exact key, not
prefix. `MORPHIT_INSTANCE_ORIGIN_LEGACY` would not match
`MORPHIT_INSTANCE_ORIGIN`.
Quoted values:
- Lines like `KEY="value with spaces"` parsed correctly (strips
outer quotes).
- Lines like `KEY=value with spaces` (no quotes, has spaces)
return `value with spaces` (everything after `=`, trimmed) —
technically not a valid env-var format but our writer never
emits this, and reading-back doesn't matter for the editable
fields (URLs, addresses, SEO copy don't contain spaces in
practice — and even SEO copy is round-trip-safe via the
always-quote-on-spaces rule in `quoteValue`).
**Action:** None.
---
### G-2 — `morphit-ops edit` quoting — LOW
**Status:** reviewed, no action needed.
**Surface:** `apps/ops-cli/src/commands/edit.ts``quoteValue`.
**Threats considered:**
- Operator pastes a malicious SEO description containing shell
metacharacters or backslash-escapes. Could the written file
be exploited when sourced via `set -a; . morphit.config.env;
set +a`?
**Check:** `quoteValue` uses double-quoted form for any value
that contains characters outside `[A-Za-z0-9_./:@\-+]`.
Backslashes are escaped (`\``\\`), then double-quotes (`"`
`\"`). Result: a value like `"$(rm -rf /)"` becomes
`"\"\$(rm -rf /)\""` — wait, we don't escape `$`. Let me check.
Actually we don't escape `$` or backticks. When the file is
sourced via `set -a; . morphit.config.env`, bash WILL evaluate
`$(...)` and backticks in double-quoted values.
**Mitigation already in place:** the values written go into
`process.env` for the indexer/relay, NOT into shell-evaluated
contexts at runtime. The wizard's documented usage is also
`source morphit.env` for the critical-infra file, not
`morphit.config.env` (which is read via Node's `parseEnv`
not bash). `parseEnv` is purely textual; `$(...)` substrings
land in env vars verbatim.
But operators following a snippet that says "source
morphit.config.env" (some might) WOULD be exposed. Let me
check: do we tell operators to source morphit.config.env
anywhere?
Searched `docs/`, `apps/ops-cli/src/init/render.ts`, and
`scripts/`: only `morphit.env` (the critical-infra file) is
documented for shell-sourcing. `morphit.config.env` is
parseEnv-only, never bash-sourced.
**Action:** None. But noting for future: if anyone ever
introduces a "source the operator config" path, harden the
quoter to escape `$` and backticks too.
---
### G-3 — `/v1/chain-fee` cache poisoning — LOW
**Status:** reviewed, no action needed.
**Surface:** `apps/indexer/src/api/chainFee.ts`.
**Threats considered:**
- A malicious chain RPC node returns a wildly wrong
`account_creation_fee`, gets cached for 24h, frontend
displays "10000 BLURT" — confuses users into thinking
signups cost a fortune.
**Check:** The endpoint already has a fallback when chain RPC
is unreachable. When chain RPC is REACHABLE but returns garbage,
the cache stores the garbage. Our `fetchChainProperties` validates
the shape (throws if `account_creation_fee` is unparseable) — so
truly malformed responses fall through to the fallback path.
A more sophisticated attacker who controls one of the
operator's RPC endpoints could return a STRUCTURALLY VALID but
semantically wrong fee (e.g., 99 BLURT instead of 100). The
worst case impact: frontend FAQ displays "currently 99 BLURT"
for up to 24h — operator's relay still validates the actual
chain value at signup time (it has its own 10% sanity threshold
per `MORPHIT_INDEXER_ACCOUNT_CREATION_FEE_BLURT`). No fund
loss; cosmetic only.
**Action:** None. The relay's runtime sanity check is the
real defense; the displayed value being slightly off is not a
high-severity issue.
---
### G-4 — `/v1/chain-fee` RPC hostile response — LOW
**Status:** reviewed, no action needed.
**Surface:** `apps/indexer/src/blurt/chainProperties.ts` (pre-
existing) called by `apps/indexer/src/api/chainFee.ts` (new).
**Threats considered:**
- Hostile RPC returns a response with prototype-pollution
payload, e.g., `{"__proto__": {"foo": "bar"}}`.
**Check:** The RPC client uses `JSON.parse` (which doesn't
trigger setters and doesn't merge into Object.prototype). The
indexer reads only specific top-level fields
(`account_creation_fee`). No `Object.assign` or spread of the
raw response into another object.
**Action:** None.
---
### G-5 — SEO override storage XSS — MEDIUM
**Status:** reviewed, no action needed (Svelte auto-escaping +
CSP).
**Surface:** `apps/web/src/lib/components/Head.svelte`
displays `$instance.seo.title`, `description`, `keywords`.
**Threats considered:**
- Operator (or whoever controls the operator's
morphit.config.env) sets
`MORPHIT_INSTANCE_SEO_TITLE='<script>steal()</script>'`.
When users land on the homepage, does the browser execute it?
**Check:** Svelte's `<title>{title}</title>` and
`<meta name="description" content={description}>` use
auto-escaping; the value is set as a text node / attribute
value, never as innerHTML. Even if the operator deliberately
crafts an XSS payload, the browser sees literal text.
CSP (configured in `svelte.config.js`) prohibits inline
`<script>` tags entirely — even if escaping somehow failed,
the script would be blocked at load.
Verified the JSON-LD path does NOT route operator-controlled
SEO override fields: `apps/web/src/lib/seo/jsonld.ts` builds
schemas from `siteName` (svelte-i18n `seo.site_name`) and
`tagline` (`app.tagline`), neither of which is operator-set.
The operator's `MORPHIT_INSTANCE_SEO_*` knobs flow only
through `Head.svelte`'s `<title>` / `<meta description>` /
`<meta keywords>` outputs, all of which use Svelte's
auto-escaping at the attribute level.
The operator IS the trust authority for their own instance,
though — if their config is compromised, lots of other things
are too (the chain-broadcast fee account, etc.). This isn't
really a user-input-trust boundary; it's an operator-config-
trust boundary.
**Action:** None.
---
### G-6 — Alt-network keystore passphrase reuse — LOW
**Status:** accepted tradeoff, documented in `altKeystore.ts`
header.
**Surface:** `apps/ops-cli/src/init/altKeystore.ts` — same
passphrase encrypts the relay's posting key AND the alt-network
service keys.
**Threats considered:**
- An attacker who compromises the operator's passphrase via
keylogger / social engineering can decrypt EVERY service key
the operator runs. No compartmentalization.
**Check:** This is a documented tradeoff (see file header lines
12-21). The operator-UX value of "one passphrase per instance"
was explicitly chosen over compartmentalization.
Operators with stricter requirements can:
- Run the alt-network services in separate trust domains
(separate hosts) where the passphrase is genuinely separate.
- Not encrypt these keys via Morphit at all (they're
service-tool's own files — Tor stores them at
`/var/lib/tor/...` and doesn't require Morphit's involvement).
**Action:** None — the tradeoff is documented in the source
file's header.
---
### G-7 — Alt-network keystore file perms race — LOW
**Status:** reviewed, mitigated.
**Surface:** `apps/ops-cli/src/commands/importAltnetKey.ts`
the directory creation and the file write.
**Threats considered:**
- Between `mkdirSync(altDir)` and `chmodSync(altDir, 0o700)`,
the directory exists at default umask (usually 0755 →
group/other can read). Window for an unprivileged local user
to enumerate the file's existence.
**Check:** Read the code:
```
mkdirSync(altDir, { recursive: true });
chmodSync(altDir, 0o700);
// ... (then writeFileSync at 0o600 + chmod 0o600)
```
Hmm — the directory is created with default umask first, THEN
chmodded. On a single-tenant VPS where the operator is the
only user, this race is benign. On multi-tenant systems
(uncommon for a Morphit instance), there's a brief window.
The `writeFileSync` itself uses `mode: 0o600` directly (Node
respects mode at create time for new files), so the FILE never
exists at default umask — only the directory has the brief
race.
For most Morphit deployments (single-tenant VPS or home
server), this is fine. Operators on multi-tenant hosts
shouldn't be running Morphit anyway — the indexer's database
credentials, the relay's posting key, and these alt-network
keys are all on the same disk; the threat model is "you trust
the host's other users" already.
**Mitigation:** Document in the import command's success
message that the operator should verify perms after import:
`ls -la apps/relay/altnet/`
Already present implicitly via the "permissions set to 600"
output line. Acceptable.
**Action:** None. Could tighten by `mkdirSync` then `chmod`
in a single os.umask() block; not worth the complexity for the
risk class.
---
### G-8 — Alt-network export plaintext on disk — MEDIUM
**Status:** reviewed, operator-documented.
**Surface:** `apps/ops-cli/src/commands/exportAltnetKey.ts`
`--out=PATH` writes plaintext to disk.
**Threats considered:**
- Operator runs `morphit-ops export-altnet-key
--out=/some/path` to feed Tor at startup. The plaintext
sits at that path until something deletes it. If the path
is on persistent storage, the plaintext outlives the daemon's
lifetime.
- A startup script that runs `--out=/var/lib/morphit/tor.key`
and forgets to delete leaves the plaintext on disk
indefinitely.
**Check:** The export command writes with `mode: 0o600` (good)
but does not delete after — that's the caller's job. The
docstring notes "tmpfs is preferred" but doesn't enforce it.
**Mitigation:** Documented in the file's header:
> Operators on a privacy-conscious system should prefer
> tmpfs (`/dev/shm`, `/run/user/<uid>`) so the plaintext
> never touches persistent disk.
This is the right place for the mitigation — within the
operator-side workflow, not in our code. Forcing tmpfs from
within Morphit would be over-prescriptive; some operators have
legitimate reasons to write to other paths (Mode 0600 file in
their daemon's chroot, e.g.).
**Action:** None. The header doc is the right
operator-facing guard. Could tighten by adding a `--shred`
flag that overwrites the file before deletion; deferred to
post-launch if requested.
---
### G-9 — Clock-drift fail-fatal as DoS vector — LOW
**Status:** reviewed, accepted.
**Surface:** `apps/relay/src/main.ts` — relay refuses to start
if clock drift exceeds 120s.
**Threats considered:**
- A hostile chain RPC returns a fake `head_block_time` 5
minutes in the future. The relay's local clock is correct
but the comparison flags it as drift > 120s and refuses to
start.
- Multiple hostile RPCs do this in coordination — operator
can't bring their relay up.
**Check:** The drift check happens AFTER the BlurtClient is
configured but BEFORE the listener. RPC failure is silently
skipped (per the "RPC unreachable = silent skip" branch in
`main.ts`). But a hostile-but-reachable RPC that returns lies
COULD prevent boot.
Mitigation: the BlurtClient rotates across multiple endpoints
configured by the operator. Even one hostile endpoint among
several would cause inconsistent behavior (sometimes drift OK,
sometimes drift fatal). Operators noticing this pattern should
remove the bad endpoint from their config.
For a fully-controlled adversary who replaced ALL of the
operator's RPC endpoints with malicious mirrors: they could
already do worse things than block boot (steal the operator's
posting key via a forged signed-broadcast response, etc.).
Boot-blocking is a low-impact addition to a much larger
compromise.
**Action:** None. The drift check is a sanity net for the
common case (operator forgot to enable systemd-timesyncd),
not a defense against adversarial RPC.
---
### G-10 — Asset registry path traversal — LOW
**Status:** reviewed, no action needed.
**Surface:** `apps/web/src/lib/assets/registry.ts` — `logoSvgPath`.
**Threats considered:**
- Could a future entry include `../../etc/passwd` for
`logoSvgPath` and trick the frontend into rendering it?
**Check:** The registry is operator-controlled at build time
(it's compiled into the JS bundle, not loaded at runtime).
A malicious entry could only be added by someone who has
push access to the Morphit codebase. At that point they
control the entire frontend; path traversal in an SVG path is
not the largest concern.
The smoke test enforces `/coins/` prefix and `.svg` suffix as
a contract:
```
if (!p.startsWith('/coins/')) {
throw new Error(...);
}
```
This catches accidental developer typos (`./coins/btc.svg`)
that wouldn't resolve correctly anyway.
**Action:** None.
---
### G-11 — order_views permlink injection — MEDIUM
**Status:** reviewed, mitigated.
**Surface:** `apps/indexer/src/api/orderViewsLogic.ts`.
**Threats considered:**
- A user crafts a permlink containing SQL injection or
parameter-pollution characters. Could the parameterized
query be subverted?
- A user crafts an extremely long permlink to cause memory
blow-up.
**Check:** Both endpoints validate `account` and `permlink`
with regex:
```
const PERMLINK_RE = /^[a-z0-9-]+$/;
function isValidPermlink(s) {
if (typeof s !== 'string') return false;
if (s.length === 0 || s.length > 256) return false;
return PERMLINK_RE.test(s);
}
```
256-char cap blocks oversize attacks. Regex restricts to
`[a-z0-9-]` — no quotes, no escapes, no SQL metacharacters.
The query uses parameterized substitution (`$1` placeholder)
even after validation; defense in depth.
**Action:** None — already mitigated at validation + query
layer.
---
### G-12 — order_views table size growth — LOW
**Status:** reviewed, mitigated.
**Surface:** `apps/indexer/src/db/schema-v22.sql`.
**Threats considered:**
- An attacker creates many fake orders and bumps their view
counts to bloat the `order_views` table.
**Check:** The increment endpoint refuses to create counters
for non-existent orders (`SELECT EXISTS(SELECT 1 FROM orders
WHERE account = $1 AND permlink = $2)`). No order = no
counter row. The order itself costs the listing fee to
create — at 60 BLURT × 1000 fake orders = 60,000 BLURT to
generate 1000 useless rows. Attack is uneconomic.
Spam at scale is also rate-limited at nginx.
**Action:** None — already mitigated by the existence check.
---
### G-13 — order_views public read disclosure — LOW
**Status:** accepted tradeoff, documented.
**Surface:** GET /v1/orders/:account/:permlink/views is
public-readable.
**Threats considered:**
- A scraper hits GET for every permlink they can find,
building a popularity dataset they can then use to target
high-interest orders for fraud or surveillance.
**Check:** The threat is real but bounded. A scraper could
already do the same thing by sitting on the orderbook page and
counting clicks. The count itself is non-identifying (no IP,
no viewer account, no timestamps per view). See the privacy
notes in the orderViewsLogic.ts header.
The display is gated client-side: only the order's author sees
the count. But anyone CAN call the endpoint and read the
count. This is the structural privacy tradeoff documented in
the audit's design phase: signature-gating GET would require
bundling secp256k1 verify in the indexer for one soft metric.
Not worth it.
**Action:** None. The privacy property is structural (count
alone is non-identifying) rather than gate-enforced.
---
### G-14 — view-count POST as referer leakage — LOW
**Status:** reviewed, no action needed.
**Surface:** `apps/web/src/lib/orders/views.ts` —
`recordOrderView`.
**Threats considered:**
- The frontend POSTs to `/v1/orders/:account/:permlink/view`
when the user clicks "Message". Does the request leak
anything beyond what a normal indexer hit would?
**Check:** The fetch is `credentials: 'omit'` — no cookies, no
Authorization header. Same-origin in production. Referer is
the order's permlink path which the indexer already knows
about (it's the order they're inspecting). No new leak.
**Action:** None.
---
### G-15 — degraded.html CSP compliance + operator data leakage — LOW
**Status:** reviewed, **fixed during audit**.
**Surface:** `apps/web/static/degraded.html` and
`apps/web/static/degraded.css`.
**Threats considered:**
- Original draft of degraded.html had an inline `<script>` that
fetched `/v1/instance` for branding, plus an inline `<style>`
block. The operator's recommended CSP (`script-src: self;
style-src: self`, see OPERATIONS.md §15) would block both —
the page would render unstyled and silently fail to fetch
branding in any reasonably-configured deployment.
- Even if CSP didn't block them, the script's `innerHTML`-via-
`escapeHtml`-string-concatenation was a small XSS surface
for a malicious operator-set instance name.
**Check:** Discovered during audit re-walk of the new files.
Inline `<script>` violates `script-src: self`; inline `<style>`
violates `style-src: self`.
**Fix:**
- Removed the inline `<script>` entirely. Degraded mode now
shows generic Morphit branding (no operator name fetched).
Honest framing: in degraded mode, operator-specific copy is
the wrong priority anyway — the goal is "tell users this is
load-shedding, point them somewhere useful, get out of the
way."
- Moved styles to `apps/web/static/degraded.css`. External
stylesheet, served by nginx alongside `degraded.html`,
CSP-clean under `style-src: self`.
- Updated OPERATIONS.md to allowlist `/degraded.css` in the
reachable-during-degraded routes block.
**Action:** Fixed. Verified the page renders correctly without
any inline content; both files are pure static.
---
### G-16 — Order detail viewer-side timing — LOW
**Status:** reviewed, no action needed.
**Surface:** `apps/web/src/routes/my/orders/+page.svelte` —
viewcounts loaded with `Promise.all` over all visible items.
**Threats considered:**
- The view-count fetches reveal which orders the page-owner
has via timing analysis. But this is the AUTHOR's view of
THEIR OWN orders — they already know which orders they have.
**Check:** No new disclosure surface. The fetches go to the
same indexer the page is already talking to.
**Action:** None.
---
## Summary by severity
- **HIGH:** 0 introduced.
- **MEDIUM:** 2 introduced, both reviewed and accepted with
documented tradeoffs (G-5 operator-trust boundary for SEO
override, G-13 public-readable view counts as structural
privacy property).
- **LOW:** 13 reviewed: 1 fixed during audit (G-15 — CSP
compliance for degraded.html, accomplished by externalizing
styles and dropping the unused branding-fetch script), 12
non-issues or already-mitigated.
## Recommendation
Cleared to proceed to Phase G (mobile PWA polish + final
pre-launch deep-audit pass). No HIGH-severity vulnerabilities
introduced by the post-F.5 task campaign.
The two MEDIUM findings (G-5, G-13) are documented design
tradeoffs. G-5 (operator-set SEO copy XSS): trust boundary is
correctly drawn — the operator is the authority for their own
instance, and Svelte auto-escaping plus CSP defense-in-depth
holds. G-13 (public-readable view counts): structural privacy
property holds (count alone is non-identifying) and the
alternative (secp256k1 verify in indexer) was deemed
disproportionate to the soft-metric value.