morphit/docs/adr/0006-security-posture-phase3a.md

397 lines
15 KiB
Markdown

# ADR-0006: Security posture for Phase 3a
**Status:** Accepted
**Date:** 2026-04-18
**Deciders:** project maintainer
**Supersedes:** none (complements ADR-0002 live-keys policy and
`docs/SECURITY.md`)
## Context
At the Phase-3a midpoint the maintainer raised specific concerns
from a security advisor — HTTP Parameter Pollution, SSRF, CSRF,
OAuth/nOAuth vulnerabilities, RCE, GuzzleHttp CVEs, xmlrpc.php
exploits, DDoS, maxlength enforcement, and related classes of
attack. Morphit is entering a phase where it begins accepting
untrusted HTTP input (the posting relay) and will soon host
large public-facing endpoints (indexer, orderbook). Formally
documenting which attack classes apply, which do not, and why
removes ambiguity for contributors, auditors, and future
phases.
This ADR classifies each attack vector into one of four verdicts:
- **Covered**: already mitigated in code or infrastructure.
- **Not-applicable**: structurally prevented by a design decision
elsewhere (e.g. no PHP means no PHP-specific CVEs).
- **Deferred**: tracked for a later phase with a specific trigger
for revisiting.
- **Out-of-scope**: cannot be solved in application code alone
and belongs to the operator's deployment posture.
## Decision
The verdicts below are binding for Phase 3a. Any future change to
a verdict requires either a subsequent ADR or a note in the review
document of the phase where it is revisited.
### Architectural defense
Before listing specific attack classes, the following design
decisions eliminate large swathes of common vulnerability. These
are the load-bearing non-code defenses:
- **Non-custodial.** Morphit never holds user funds. There is no
hot wallet to drain, no withdrawal mechanism to exploit, no
custody contract to misconfigure. The worst outcome of a full
relay compromise is that the operator loses the BLURT balance
the relay itself holds for account-creation fees (tens to low
hundreds of BLURT in practice).
- **No authentication layer.** There are no accounts on Morphit,
no sessions, no passwords, no cookies, no JWTs, no OAuth.
Every request is stateless. Entire vulnerability classes
(session hijacking, credential stuffing, OAuth misconfiguration,
nOAuth, CSRF-as-commonly-understood) do not exist here.
- **No user database.** Morphit does not collect KYC and does not
log IP addresses. There is no user database to breach, no
personal information to leak, and no data that could be
compelled by subpoena.
- **No server-side dynamic code.** The relay has no `eval`, no
`new Function`, no dynamic `require`, no template engines
evaluating user input, and no server-side rendering of user
input. Command-injection, template-injection, deserialization,
and RCE via string-to-code paths are structurally impossible.
- **Chain-native orderbook.** Orders live on Blurt itself. The
Morphit frontend is a reader; an indexer is a reader-with-cache.
Takedown of any specific frontend does not delete orders —
another frontend, self-hosted or community-run, can index the
same chain data.
### Attack-class verdicts
#### HTTP Parameter Pollution
**Verdict: Covered.**
Every relay endpoint uses `zod` schemas with `.strict()` (rejecting
unknown keys) and explicit `.max()` bounds on every field. Arrays
have explicit length bounds. We do not read query parameters for
business logic; all input comes through parsed JSON bodies. Hono
gives exact header values, not arrays, so duplicate-header injection
produces a deterministic first-value-wins behavior we explicitly
control.
#### SSRF (Server-Side Request Forgery)
**Verdict: Covered.**
The relay only makes outbound HTTP calls to a pre-configured
allowlist of Blurt RPC endpoints parsed from environment variables
at boot. No handler accepts a URL from a request body. No
redirect-following is enabled. No arbitrary-fetch feature exists
anywhere in the codebase.
**Phase 3b update (Audit 2026-05 Finding 5-5):** the indexer
introduced the `morphit_operator_register_v1` op and federation
probe, which accepts an operator-supplied `origin` URL and fires
GETs against `${origin}/v1/health` to verify reachability. This
extended the SSRF surface beyond what was true in Phase 3a. The
defenses applied:
- The `operatorRegister` handler rejects loopback / RFC1918 /
link-local / metadata-service / `*.local` / `*.internal`
origins at registration.
- The `federationProbe.fetchJson` re-validates hostname at
request time (defense in depth: a row that slipped past
registration via direct DB write or a future regex-bypass
is still refused).
- `redirect: 'manual'` on the fetch so a hostile server can't
redirect the probe to localhost or a public IMDS.
- Response body is capped at 256KB (Audit 2026-05 NEW-9-11)
with both Content-Length pre-check and streaming-with-abort.
The verdict remains **Covered**, but the structural argument
("no URL ever comes from a request body") no longer holds —
SSRF is now defended by explicit hostname allowlisting plus the
response-handling caps above.
#### CSRF (Cross-Site Request Forgery)
**Verdict: Not-applicable.**
CSRF is a vulnerability against cookie-based sessions: an attacker's
page causes the victim's browser to make an authenticated request
because the browser attaches cookies automatically. Morphit has
zero cookies, zero sessions, and zero server-side authentication
state. There is nothing the browser can "attach" that would
authenticate a third-party request.
Additionally, state-changing endpoints require inputs an attacker
cannot forge: the account-create endpoint needs the user's four
new public keys, which the user generates locally and which are not
available to any other origin.
An exact-match CORS allowlist provides defense-in-depth — the
browser blocks `fetch()` from any origin not on the list — but the
fundamental reason CSRF does not apply is structural.
#### Authentication vulnerabilities (AUTH-VULN-n, nOAuth)
**Verdict: Not-applicable.**
Morphit has no authentication. No login flow, no OAuth provider,
no password reset, no magic links, no SAML, no JWT, no API keys.
Classes of vulnerability that target these mechanisms cannot
apply to a system that does not implement them.
User identity on Blurt is cryptographic: transactions are signed
by the user's own keys on their own device. That authentication
belongs to Blurt; if it fails, the entire chain fails, not
Morphit specifically.
#### Remote Code Execution (RCE)
**Verdict: Covered, with caveats.**
- No dynamic code evaluation paths exist in relay or frontend source.
- No deserialization of untrusted binary formats. JSON is parsed
through zod, which cannot produce prototypes or functions.
- Supply-chain CVEs remain a concern. `npm audit` must be run at
deploy time and on every dependency bump (added to relay README).
Lockfile (`package-lock.json`) pins exact versions.
- Node and nginx CVEs on the VPS are the operator's patching
responsibility. Document this in the deployment guide.
#### GuzzleHttp CVEs
**Verdict: Not-applicable.**
Guzzle is a PHP HTTP client. Morphit has no PHP anywhere in the
stack. The relay is Node.js; the frontend is SvelteKit (also
JavaScript); the indexer (Phase 3b) will be Node.js; nginx
proxies without any PHP handler enabled. Guzzle CVEs cannot
apply.
#### xmlrpc.php exploits
**Verdict: Not-applicable.**
`xmlrpc.php` is a WordPress-specific endpoint. Morphit runs no
WordPress. No `xmlrpc.php` exists anywhere. nginx configuration
denies any request path outside `/v1/` on the relay, returning
404 without hitting the relay process.
#### Request-size / `maxlength`
**Verdict: Covered.**
- Relay-level: `maxRequestBodyBytes: 65536` (64 KiB hard cap).
Oversized `Content-Length` is rejected before any body is read.
- Schema-level: every `z.string()` in every request schema has a
specific `.max()` bound tuned to Blurt's on-chain constraints
(name ≤ 16, json_metadata ≤ 1024, etc.).
- nginx-level: `client_max_body_size 64k` matches the relay.
- Frontend-level: HTML `maxlength` attributes on form inputs are a
UX nicety, not a security boundary.
#### Volumetric / network-level DDoS
**Verdict: Out-of-scope.**
A single application cannot defend against a flood of 10 Gbps of
packets aimed at its network interface. This must be addressed at
the infrastructure layer: upstream DDoS scrubbing by the VPS
provider, a CDN (Cloudflare is the common choice), or a dedicated
mitigation service. Morphit's codebase explicitly does not try to
solve this.
Operators are encouraged to document their DDoS response plan
separately. Morphit mirrors that run behind Tor / I2P / Lokinet
naturally benefit from those networks' own DDoS properties.
#### Application-layer abuse / volumetric DDoS at L7
**Verdict: Covered.**
- Per-IP rate limits: 60 availability checks/min, 5 account
creates/hour, enforced as sliding-window buckets in relay memory.
- nginx-level timeouts: 10s client-body-timeout, 15s send-timeout,
30s keepalive-timeout.
- Resource ceilings in systemd: `MemoryMax=512M`, `TasksMax=256`.
- Relay is stateless beyond the in-memory rate limiters and a
one-minute dedupe cache. A restart clears everything; no file
or database grows unbounded.
- fail2ban integration is documented in the README: systemd
journal entries of rate-limit rejections can be consumed by
fail2ban to install temporary iptables bans without the relay
itself logging or persisting IPs.
#### XSS (Cross-Site Scripting)
**Verdict: Covered at the frontend.**
- Strict Content Security Policy with `script-src 'self'` and
hash-based inline-script allowlisting (configured in
`svelte.config.js`).
- Svelte auto-escapes all dynamic content; `{@html}` is not used
anywhere in the project (grep-verified in Phase 2 review).
- FAQ content is loaded from typed locale files, not user input.
- Relay never returns HTML — only JSON with `Content-Type:
application/json; charset=utf-8` and `X-Content-Type-Options:
nosniff`, so even if it somehow returned an XSS payload, no
browser would render it.
#### SQL Injection
**Verdict: Not-applicable in Phase 3a; covered in 3b plan.**
The relay has no database. The Phase-3b indexer will use Postgres
with `sqlc`-generated parameterized queries only; no string
concatenation into SQL. Documented in `docs/PHASE-3a-DESIGN.md`
ahead of 3b.
#### Prototype pollution
**Verdict: Covered.**
Zod's `.strict()` rejects unknown keys including `__proto__`,
`constructor`, and `prototype`. We never use dynamic property
access (`obj[userKey]`) on user-supplied keys. No `Object.assign`
or spread-merge of user input into trusted objects.
#### Log injection
**Verdict: Covered, documented in SECURITY.md.**
The relay's `log()` function interpolates config values (relay
account name, endpoint URLs) which are schema-validated at boot
with no newline characters. No user-supplied string is ever
interpolated into a log line. Switching to structured logging
(e.g. Pino) is a Phase-4 improvement but not required for safety.
#### Timing attacks
**Verdict: Documented as minor, not fixed in 3a.**
The dedupe-check in the create endpoint uses linear scan and
string equality. The compared fingerprints are SHA-256 of
public data (the user's own new pubkeys, which are going on
chain), so any timing leak reveals only information the attacker
already has. The scan is bounded to the one-minute dedupe window
(at most ~5 entries given the 5/hr create limit). Documented in
SECURITY.md; not a fix candidate for 3a.
#### TOCTOU on availability check
**Verdict: Covered.**
Between the relay's `blurt.getAccount(name)` check and its
`broadcastAccountCreate`, another user could theoretically claim
the same name. If they do, the chain rejects the broadcast with
an `account_already_exists` error, which the relay catches and
maps to the `already_registered` error code (the same code
returned by the pre-broadcast check). The user sees one
consistent error message either way, and no funds are spent on
the failed broadcast (Blurt rejects it before the fee is
collected).
#### ReDoS (Regex Denial-of-Service)
**Verdict: Covered.**
The only regex in relay code (`/^(-?\d+(?:\.\d+)?)\s+\w+$/` for
asset amount parsing) runs on chain responses, not user input,
and has no nested quantifiers. Linear time in input length.
#### Dependency confusion
**Verdict: Covered.**
All dependencies (`@beblurt/dblurt`, `hono`, `@hono/node-server`,
`zod`) are on the public npm registry. No private-registry names
that could be shadowed.
#### Supply-chain / typosquatting
**Verdict: Mitigated; deferred for deeper work.**
- Lockfile pins exact versions and hashes.
- `npm ci --omit=dev` in deployment installs only what the
lockfile specifies.
- Dependency count is deliberately small (4 runtime dependencies
for the relay) so a compromise is bounded in blast radius.
- Full npm package signing is an open industry problem; we use
what npm offers and accept the residual risk. Phase 5 can
evaluate `npm-audit-resolver`, `socket.dev`, or similar.
#### Key exposure
**Verdict: Covered by ADR-0002.**
See ADR-0002 for the live-keys policy. The relay's own active
key is read once at startup from a file with `0400`
permissions; the relay refuses to boot if the file is
world-readable. The WIF string is never logged, never returned
in responses, never transmitted to any external service.
#### Legal/operator exposure
**Verdict: Policy, not code; documented in SECURITY.md.**
Operator considerations (sanctioned-country trades, takedown
requests, DMCA) are addressed by the project's structural
choices: the frontend never facilitates the actual trade, the
orderbook is chain-native and not controlled by any frontend,
and `@morphit` release-discovery ops are pinned to a specific
public key to resist impersonation.
### What this ADR deliberately does not address
- Threats to the Blurt chain itself. If Blurt's consensus breaks,
Morphit breaks. That risk is inherent to the chain choice and
is addressed in SECURITY.md.
- Threats from the user's own device (malware on the client,
extension compromises, clipboard sniffers). Morphit minimizes
its attack surface (live keys only, no owner/active in memory)
but cannot defend against full local compromise.
- Physical access to the VPS. If the operator's server is
physically compromised, the relay's active key is readable.
Mitigation is the standard VPS-provider choice + disk
encryption + quarterly key rotation; none of these are code.
## Consequences
### Positive
- Contributors and auditors have a single source of truth for
"why isn't X a problem?" questions.
- Future phases can extend this ADR rather than argue each case
anew.
- Unambiguous separation between code-level defense and
operator-level responsibility.
### Negative
- ADRs tend to drift from reality. This one must be revisited at
the start of every phase (noted in the phase review template).
- Some verdicts ("Not-applicable") rely on current design
decisions (no PHP, no OAuth) that a future contributor might
reverse without realizing the impact. The ADR is the warning.
### Follow-up work
- The relay README adds operator-level security guidance: npm
audit, fail2ban, key rotation, BLURT balance monitoring.
- `docs/SECURITY.md` adds a user-facing threat-model section
referencing this ADR.
- A Phase-3a FAQ entry (`security_attack_vectors`) in all 10
locales points users at this material without requiring them
to read the ADR directly.
## References
- ADR-0001: `custom_json` replacement ops
- ADR-0002: live-keys policy
- ADR-0005: Phase 3 subphase split
- `docs/SECURITY.md`: user-facing threat model
- `docs/PHASE-3a-DESIGN.md`: relay-specific design + security review