Raxx · internal docs

internal · gated

Defense-in-Depth to Production

Status: Design — pre-launch hardening baseline
Owner: software-architect
Date: 2026-07-11 UTC
Pair review: security-agent (adversarial review follows this doc)
Related: docs/plan-to-production.md (parallel PM doc); ADR-0138 (idempotency); ADR-0137 (wp-config-svc); ADR-0130/0131 (agent identity); docs/architecture/customer-audit-unified/design.md
Hardening epic: #94 (Production Release v1.0)


1. Preamble — design invariants this doc enforces

Every control described here is subordinate to these non-negotiable platform invariants. A design that requires violating any of them is not a design; it is a conflict that must be escalated.


2. Per-surface threat model

2.1 Order-firing / trading path

Assets: paper positions, paper cash balance, audit chain, live broker credentials (future).

Trust boundary: Authenticated Raptor session (raxx_session cookie) → session_authrequire_session → optional idempotency_key decorator → handler → MbtFillEnginepaper_orders table → audit write.

Threat (STRIDE) Top risks Layers today Single-layer gaps
Spoofing Replaying another user's idempotency key to execute their order Key scoped to (idem_key, scope_user_id, scope_endpoint) — cross-user key collision produces no conflict None — user scope isolates correctly
Tampering Modifying request body to change order parameters while reusing a key request_hash = SHA-256(sorted JSON body); mismatch → 422 Hash of None/empty body undefined (see HC-ORD-1)
Repudiation Denying an order was placed Audit write before response; replay tagged as action='idempotency_replay' (distinct row) Stale in-flight leaves orphan execution with no stored response (HC-ORD-2)
Info-disclosure Response body leaks order timing/amounts to unauthorized reader response_body JSONB in idempotency_keys table; visible to any Postgres reader with table access Column-level encryption deferred (open question ADR-0138 OQ2)
DoS Flooding idempotency table to fill Postgres 24h TTL + nightly cleanup job; table has per-user index; fail-closed 503 on Tier 1 preserves safety Cleanup job failure is silent; no per-user key-count cap (HC-ORD-3)
Elevation Bypassing paper-first gate via idempotency replay Gate checked inside handler on first execution; replay returns stored response from an already-gated execution. Replaying a paper order does not unlock live. FLAG_LIVE_MODE_RBAC_GATE=0 (default off) means RBAC gate is not enforced on the live path (HC-ORD-4)

Order-firing path deepest treatment — see Section 3.


2.2 AuthN / AuthZ surface

Assets: WebAuthn credential bindings, session tokens, recovery flow, RBAC role grants.

Trust boundary: Browser → Cloudflare WAF → Raptor /api/auth/* → py-webauthn → webauthn_credentials + sessions tables.

Threat Top risks Layers Single-layer gaps
Spoofing Authenticating as another user by forging an assertion py-webauthn signature verification + RP ID pinned to raxx.app + sign count check Challenge storage is in-memory per-process; multi-dyno Heroku cannot validate a challenge issued on a different dyno (HC-AUTH-1)
Tampering Flipping role or session user_id in a cookie Session cookie is HMAC-signed; raw token stored only as SHA-256 SameSite=Lax (not Strict) allows cross-site top-level navigation to carry the cookie (HC-AUTH-2)
Repudiation Denying a login or credential registration Every auth event in audit_log; every 401 written with path and reason
Info-disclosure Account enumeration via /register/options Always returns 202 FLAG_SIGNUP_ALREADY_ENROLLED_REDIRECT leaks email_already_enrolled in the 202 body — acceptable per ADR but documented here as a one-layer exception
DoS Brute-force credential recovery Per-IP and per-email rate limits on /recovery/* and /register/* Rate limit bucket config not documented in infra runbooks — needs ops visibility
Elevation Recovery email token used by inbox-level attacker 15-min TTL, single-use, SHA-256 stored Inbox compromise is the single path to full account reset; 72-hour new-account live-trade cooldown is the backstop. Accepted residual risk.

Internal machine-auth paths (/api/internal/*):
Multiple auth patterns co-exist: HMAC token (FreeScout webhook), Bearer token (WAF events), X-Admin-Service-Token (jobs, merges). The X-Admin-Service-Token is a single shared secret. If it leaks, all /api/internal/jobs/* and /api/internal/merges* routes are compromised. No per-route isolation or per-caller identity today. See HC-AUTH-3.


2.3 Billing / payments

Assets: Stripe checkout sessions, Apple IAP receipts, subscription state, billing action log.

Threat Top risks Layers Single-layer gaps
Spoofing Fake Stripe webhook event STRIPE_WEBHOOK_SECRET HMAC signature verification by Queue service (ADR-0127) If webhook secret is not rotated on disclosure, old events remain valid indefinitely
Tampering Double-checkout (two Stripe sessions for one action) Idempotency-Key on billing endpoints (Tier 2 fail-open) + Stripe-side idempotency key (Queue layer) Fail-open means both layers can be bypassed simultaneously if the idempotency store is down
Repudiation Denying a billing action was taken by an operator console_billing_action_log with admin_id, action_type, created_at Log table has no KMS HMAC integrity — it can be mutated by the DB owner role
Info-disclosure Apple IAP server-notification containing subscriber state Dispatcher dedup on notification_uuid IAP endpoint auth model (Apple IP range or JWT-signed notifications per StoreKit 2) not documented (HC-BILL-1)
DoS Spinning up Stripe checkout sessions at rate to exhaust budget Rate limit on /api/billing/checkout-session Per-user rate limiting on checkout not confirmed today
Elevation Attacker grants themselves Pro+ via crafted billing event Subscription state changes require Stripe event verification; mirror-sync is UPSERT No second-factor step-up for tier upgrade — acceptable for billing tier changes

2.4 Edge / perimeter

Assets: Heroku origin URL, vault.raxx.app, ci.moosequest.net, CI pipeline config.

Threat Top risks Layers Single-layer gaps
Spoofing (CF bypass) Direct-to-origin request skipping CF WAF, BFM, rate limits FLAG_ENFORCE_CF_ORIGIN checks CF-Connecting-IP presence Only checks header presence, not source IP against CF published ranges. Attacker who can set the header on a direct-to-origin request bypasses the guard. HC-EDGE-1.
Tampering Malicious PR modifying .woodpecker/ pipeline files to exfiltrate CI secrets wp-config-svc signs config; WP validates Ed25519 JWT PR pipeline runs before merge with PR author's code; if secrets are injected into the PR pipeline context, malicious .woodpecker/*.yaml modifications can read them. HC-EDGE-2.
Spoofing (CF Access) Machine caller spoofing CF Access service token CF Access non_identity policy + Infisical machine identity (ADR-0130) — two independent gates BFM does not recognize CF Access service tokens; WAF skip rule required (see feedback: cf_access_does_not_bypass_bot_fight_mode)
DoS Woodpecker forge context deadline (the July-09 incident) wp-config-svc reduces per-event GitHub API calls from 80 serial to 1+N parallel Still in Phase 0 shadow mode; fallback to native forge is the current active path
Elevation WP admin token leaked from SSM grants full Woodpecker API access SSM SecureString + CloudWatch audit Single admin token; no per-caller scope on the WP admin API

2.5 Secrets — Infisical vault + SSM + agent identity

Assets: All service credentials (Stripe, Postmark, Heroku, Apple IAP, broker OAuth), GitHub App private keys, agent machine identities.

Threat Top risks Layers Single-layer gaps
Disclosure Vault credential read by unauthorized agent CF Access outer gate + Infisical RBAC per identity (ADR-0130) Header-injecting proxy holds CF service token in env — proxy process compromise exposes the token
Tampering Credential rotation falsely reported as successful AFFECTED_SITES propagation + audit trail Bug pattern from #986 (closed): silent skip of non-Heroku AFFECTED_SITES entries. Durability of fix must be verified.
Repudiation Agent reads secret with no attribution Per-agent machine identity → Infisical audit log per-identity Infisical audit log retention is 90 days; export to append-only chain is deferred (ADR-0130 OQ2)
Info-disclosure Multi-line PEM key partially leaks in CI logs ::add-mask:: only masks the first line Per project memory: mask line-by-line for PEM keys; any key seen in logs must be rotated
Elevation Handoff agent inherits admin identity HANDOFF-INVARIANT-1 (ADR-0131): per-task scoped identity, never admin SC-HANDOFF-GATE-01 provisioning script not yet shipped; manual checklist is the current gate

2.6 Data — audit log, idempotency_keys, GDPR

Assets: customer_audit_events, audit_log, idempotency_keys, PII in users, shadow tables, GDPR DSR machinery.

Threat Top risks Layers Single-layer gaps
Tampering Insider modifies audit rows REVOKE UPDATE, DELETE from raptor_app role (invariant I-11); HMAC-SHA-256 + KMS chain (invariant I-9) DATABASE_URL (owner role) retains full DDL and DML. Owner role must not be the running application credential. HC-DATA-1.
Repudiation Claiming an audit event never happened Append-only DDL + HMAC chain; SC-A17 CI gate enforces action namespace allowlist HMAC write behavior on KMS unavailability (fail-open vs fail-closed) not documented. HC-DATA-2.
Info-disclosure PII retained past DSR erasure window Soft-delete + 30-day cooling + pseudonymization; audit rows retain hashed actor_id for 2 years If actor_id hash uses low-entropy input alone, hash may be reversible. HC-DATA-3.
DoS idempotency_keys cleanup job fails silently Nightly job at 04:00 UTC No alerting on cleanup job failure; silent table growth degrades query performance. HC-ORD-3.

3. Order-firing path — deep threat model

The idempotency mechanism is the primary correctness control on the path that moves paper money. The following findings represent the highest-confidence attack or failure vectors.

3.1 In-flight race: business logic success + UPDATE failure (double-fire)

Threat: Handler executes successfully — paper_orders row created, audit written — but the UPDATE SET status='completed' fails before commit (DB connection lost, gunicorn worker killed at exactly the wrong moment).

Effect: Row stays in_flight. After 30 seconds, a retry sees stale_in_flight → 409 with no stored response. User retries with a new idempotency key → second order placed. This is a double-fire on the paper portfolio.

Why the 30s stale detection doesn't fully protect here: The stale detection prevents re-execution via the same key. It does not prevent the user from issuing a new key after seeing the 409. There is no "check your orders before retrying" signal in the current 409 response body.

Hardening (HC-ORD-2): 1. The handler's side effects (paper_orders INSERT, audit write) and the idempotency UPDATE must be wrapped in the same DB transaction. If the UPDATE fails, the handler's side effects roll back. At-most-once is then guaranteed by the DB commit boundary. 2. If transaction wrapping is not possible for a specific handler (e.g., it calls an external broker), add a pre-commit idempotency check: before executing the external call, mark status='executing' (new intermediate state) with a savepoint; on external call success, complete the UPDATE in the same transaction block. 3. The stale_in_flight 409 response body must include "check_order_status": true to prompt the client to inspect GET /api/trading/orders before retrying with a new key. 4. Document as a v1 known limitation in the trading panel UI.

3.2 Request-hash normalization undefined for edge inputs

Threat: get_json(silent=True) returns None on malformed body or wrong Content-Type. The current spec calls for SHA256(sorted_json_body) but does not define behavior when the body is None.

Effect A (real risk): None body hashes to a constant string (e.g., SHA256("null")). A legitimate retry after a timeout that produced no body will hash to that constant and trigger a 422 body-mismatch against the original non-null hash. Client retries with a new key → double-fire.

Effect B (security): Two requests with empty bodies produce the same hash regardless of intent, collapsing them to one idempotent operation. Harmless for order endpoints (an order with no body should be rejected by the handler before idempotency anyway), but worth documenting.

Hardening (HC-ORD-1): Before computing the hash, validate body = request.get_json(silent=True) is a non-None dict. If not, return 400 before the idempotency check runs. Define canonical serialization as json.dumps(body, sort_keys=True, separators=(',', ':'), ensure_ascii=True). Add an integration test that submits the same body with different whitespace and confirms 200+replay on retry.

3.3 Live-mode gate: FLAG_LIVE_MODE_RBAC_GATE off by default

Threat: The @require_live_mode_role decorator is a transparent passthrough when FLAG_LIVE_MODE_RBAC_GATE=0. The flag is off in the current default configuration. If a live broker credential were accidentally activated, any authenticated user could reach the live order path with no RBAC gate.

Assessment: The paper-first graduation gate (paper_profitable_gate_met) is a separate mechanism checked inside the handler. However, it is distinct from the RBAC gate and may have its own failure modes. Two independent gates are stronger than one.

Hardening (HC-ORD-4): FLAG_LIVE_MODE_RBAC_GATE=1 must be set on both staging and prod before any live broker credential is activated. This is a pre-launch configuration requirement, not a code change.

3.4 Replay after live-mode access revocation

Threat: User is granted live-mode access, places a live order (key K), access is revoked. User retries with key K within the 24h TTL — the stored replay response is returned. The live order confirmation is replayed without re-checking current access.

Assessment: This is correct behavior within the design. The replay returns a stored response from an execution that was authorized at the time. The 24h window is the exposure period after revocation.

Hardening (HC-ORD-5): On live_mode_grants revocation, proactively set expires_at = now() on all idempotency_keys rows for that user on POST /api/trading/orders and POST /api/trading/orders/ic. This collapses the replay window to zero on revocation rather than waiting for TTL expiry.

3.5 Decorator stack ordering: missing runtime assertion

Threat: If @idempotency_key is applied before @require_session, g.current_user_id is not set. The middleware falls back to scope_user_id = NULL, collapsing all service-call keys into the same namespace. Two users with key "abc" on the same endpoint would conflict.

Hardening (HC-ORD-6): Add a runtime assertion in the decorator: if g.current_user_id is not set when fail_closed=True, raise RuntimeError("idempotency_key applied before require_session"). This surfaces misconfiguration at development time (Sentry alert, test failure) rather than silently in production.


4. Residual-risk register

Must close before launch (7 items)

ID Surface Risk Action
HC-AUTH-1 Auth WebAuthn challenge in-memory on multi-dyno → auth non-deterministic on >1 dyno Move challenge store to Postgres webauthn_challenges table or Redis before any dyno scaling
HC-EDGE-1 Edge CF-Connecting-IP header presence check only — direct-to-origin bypass by header injection Implement Phase 2: validate source IP against CF published IP ranges
HC-EDGE-2 CI PR pipeline .woodpecker/*.yaml modifications can exfiltrate secrets injected in PR context Confirm and restrict Woodpecker secret injection scope to trusted branches only, not PR events
HC-ORD-2 Order Business logic success + idempotency UPDATE failure = orphan execution, new-key retry = double-fire Wrap handler + idempotency UPDATE in same DB transaction; add recovery UX guidance
HC-ORD-4 Order FLAG_LIVE_MODE_RBAC_GATE=0 default — live trading path has no RBAC gate if live cred is activated Set FLAG_LIVE_MODE_RBAC_GATE=1 on staging and prod before any live broker credential is configured
HC-DATA-1 Data DATABASE_URL (owner role) used by running app bypasses REVOKE UPDATE/DELETE on audit tables Verify Raptor connects as raptor_app role, not the owner credential; add CI assertion
HC-BILL-1 Billing Apple IAP server-notification endpoint auth model undocumented Document and enforce Apple server-notification origin validation (Apple IP range or JWT per StoreKit 2)

Accept for v1 (9 items)

ID Surface Risk Accepted because
HC-AUTH-2 Auth SameSite=Lax allows top-level cross-site navigation to carry session cookie Required for discoverable passkey UX; origin check on state-mutating routes is already present
HC-AUTH-3 Auth X-Admin-Service-Token is shared across all /api/internal/* routes Pre-launch volume is low; rotation on disclosure is the mitigation; per-route isolation is post-launch
HC-ORD-1 Order Request body hash normalization undefined for None/empty body Add explicit 400 before hash step; low exploitability — all instrumented endpoints expect non-empty JSON
HC-ORD-3 Order Idempotency cleanup job failure is silent; no per-user key-count cap Table indexed; 24h TTL limits growth; alerting on cleanup job failure is a post-launch improvement
HC-ORD-5 Order Revoked live-mode access does not expire existing idempotency_keys replay rows within 24h TTL Operator must cancel orders on revocation (document in live-mode revocation runbook)
HC-ORD-6 Order Wrong decorator order is silent in production Add runtime assertion in decorator; low risk given small endpoint list at v1
HC-DATA-2 Data HMAC write behavior on KMS unavailability not documented KMS unavailability is an AWS outage; document fail-closed behavior as the required posture
HC-DATA-3 Data actor_id hash in 2-year audit rows may be reversible for low-entropy inputs Hash input includes user_id integer + action + timestamp — not email-only; residual risk is low
HC-EDGE-3 Edge wp-config-svc still in Phase 0 shadow mode; native forge is the active path Phase 0 is the designed safe rollout path; fallback to forge is always available

5. Security considerations — GDPR checklist


6. Open questions (operator decisions required before sub-cards are claimed)

  1. HC-AUTH-1 timing: Raptor is currently on 1 dyno. At what user volume does scaling trigger? This determines whether the Postgres challenge store migration is pre-launch or post-launch.
  2. HC-EDGE-2 Woodpecker PR secret scope: Does the current Woodpecker configuration inject production secrets into PR-context pipeline runs? sre-agent to verify before launch.
  3. HC-DATA-1 role confirmation: Is the Heroku DATABASE_URL credential used by the running Raptor dyno the raptor_app role or the owner role? sre-agent to run SELECT current_user; on prod DB.
  4. response_body encryption (ADR-0138 OQ2): Encrypt idempotency_keys.response_body at column level (pgcrypto), or accept DB-level readers see order details? Operator decision required before Wave 3 rollout.