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

15 KiB

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.

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