Raxx · internal docs

internal · gated ↑ index

Security review — 2026-04-24 (round 1)

Scope: staging API at raxx-api-staging-1a19fb3873b9.herokuapp.com (and equivalent prod once enabled), GitHub Actions deploy pipeline, supporting Cloudflare infra. NOT in scope: production API (not live with users), mobile app (not built), Heroku Postgres (not yet user-populated).

Methodology: external probe of staging endpoints + source-code review of backend_v2/api/__init__.py + routes + middleware + deploy workflow files.

Severity rubric

Findings

CRITICAL

C1 — Permissive CORS reflecting arbitrary origins

Evidence: curl -I -X OPTIONS -H "Origin: https://attacker.com" returned Access-Control-Allow-Origin: https://attacker.com. Source: backend_v2/api/__init__.py:30CORS(app) with no origins allowlist. Flask-CORS defaults to *-equivalent when no argument is passed.

Risk: CSRF-adjacent attacks. Any malicious website visited by an authenticated user can make API calls in that user's browser context. Once WebAuthn / session auth ships, this lets attackers bypass the auth entirely via XHR from any origin.

Fix: read FRONTEND_ORIGIN env var (already set to https://staging.raxx.app in staging and https://raxx.app in prod per task #43 config:set). Pass to CORS(origins=...). Comma-separated list for multi-origin staging (localhost dev + staging).

C2 — All trading / data / backtest endpoints unauthenticated

Evidence: curl $STAGING/api/trading/account returns HTTP 200 with full paper-account structure (account_number, buying_power, cash, equity, etc.). curl -X POST $STAGING/api/backtest -d '{}' returns HTTP 400 (validation-only gate). No auth decorator on any route in backend_v2/api/routes/.

Risk: Currently masked only because Alpaca credentials AREN'T deployed to Heroku staging (status shows missing_env). Once Alpaca creds are pushed (imminent — status endpoint explicitly flags this), the trading endpoints will return real paper-account data to any internet caller. Backtest endpoint is also a compute-exhaustion + Alpaca-quota-exhaustion vector (DoS).

Fix: immediate — put Cloudflare Access (email magic-link) on api-staging.raxx.app. Same gate pattern we already use on raxx-mockups.pages.dev and the founders preview. Longer term — real auth middleware (WebAuthn session check on every protected route; that's the #194 scope).

C3 — FLASK_DEBUG defaults to True if env var unset

Evidence: backend_v2/api/__init__.py:35DEBUG=os.environ.get('FLASK_DEBUG', 'True') == 'True'. Default is True.

Risk: If any deploy misses setting FLASK_DEBUG=False (or the var gets cleared), Flask runs in debug mode — exposing stack traces, enabling Werkzeug debugger (remote code execution via PIN), and turning off several production-safety features. Currently safe because we explicitly set FLASK_DEBUG=False in Heroku config:set, but the default is a landmine.

Fix: flip default to 'False'. Requires explicit opt-in for dev.

C4 — SECRET_KEY falls back to a hardcoded string

Evidence: backend_v2/api/__init__.py:33SECRET_KEY=os.environ.get('SECRET_KEY', 'dev_key_for_development_only').

Risk: If SECRET_KEY is not set in Heroku config, the app runs with a publicly-known hardcoded secret. Flask uses SECRET_KEY to sign sessions (future), cookies (future), CSRF tokens (future), flask.flash state, and more. A known secret means forgeable sessions + CSRF bypass once those ship.

Fix: fail-fast at startup if SECRET_KEY isn't set AND we're not in FLASK_ENV=development. Dev gets the hardcoded fallback; staging/prod refuse to boot without a real secret.

HIGH

H1 — /api/system/status leaks system + process fingerprint unauthenticated

Evidence: curl-probe returned: Ubuntu kernel build version (#54~22.04.1-Ubuntu SMP Wed Mar 25 15:41:00 UTC 2026), app version (1.10.0), uptime (8d 18h 39m 22s), CPU %, memory %, thread count, internal cache keys + TTLs, service-health sub-statuses, current trading mode, missing env vars by name.

Risk: Aids attacker reconnaissance. Kernel version fingerprints OS patch level (relevant for kernel CVE targeting). App version fingerprints dependency set (can target known Flask 3.1.3 / werkzeug CVEs). Explicit list of missing env vars tells attacker what to target with credential-stuffing + env-injection attacks.

Fix: split into two endpoints: - /api/system/health (unauth'd, returns {"status": "ok"} only — liveness probe for load balancers / deploy health-check) - /api/system/status (auth'd; returns full detail for operator console)

H2 — Global rate limiting is OFF by default

Evidence: backend_v2/api/__init__.py:36ENABLE_GLOBAL_RATE_LIMIT=os.environ.get('ENABLE_GLOBAL_RATE_LIMIT', 'False') == 'True'. Default False. Individual @rate_limit decorators exist on only 2 routes (system.py:81 and examples.py:15). All trading / backtest / historical / options / market-data routes have ZERO rate limiting.

Risk: DoS via sustained request flood; compute exhaustion; Alpaca API quota exhaustion (attacker drains our 10,000-req/min quota, degrading real users); Anthropic API cost abuse (future). No protection at the edge — Heroku doesn't provide a WAF.

Fix: flip default to True in __init__.py. Set reasonable per-IP default (e.g., 100 req/min). Whitelist the deploy-health-check path.

H3 — HEROKU_API_KEY is long-lived admin credential in GH secrets

Evidence: HEROKU_API_KEY GH secret set 2026-04-22, no rotation policy, no scope (Heroku API keys are full-account admin by default).

Risk: Any contributor with repo write access can exfiltrate the secret via a malicious workflow. Secret compromise → full control of all Heroku apps (raxx-api-staging, raxx-api-prod) including heroku run bash shell into production. Task #46 tracks rotation but is pending.

Fix: (a) rotate now per #46; (b) set a rotation schedule (quarterly or upon personnel change); (c) investigate Heroku's scoped deploy-tokens if supported.

MEDIUM

M1 — /api/compare-quotes (legacy) exposed unauth'd, no rate limit

Evidence: backend_v2/api/__init__.py defines @app.route('/api/compare-quotes', methods=['GET']) inline — accepts symbol query param, hits market-data service. No auth, no rate limit.

Risk: Market data cost amplifier. Attacker can enumerate symbol space + refresh the cache constantly.

Fix: apply rate-limit decorator; consider auth gate.

M2 — Root route / returns app name + version unauth'd

Evidence: @app.route('/') returns {"name": "TradeMaster API", "version": "1.10.0", "status": "online"}.

Risk: App name + version is fingerprintable. Minor by itself but aids the H1 recon picture.

Fix: either return empty 200 (pure liveness) or authenticate this endpoint too.

M3 — Static-file serving route /<path:path> is a catch-all

Evidence: @app.route('/<path:path>', methods=['GET']) serves any file from backend_v2/static/ that exists, otherwise falls back to index.html.

Risk: If a sensitive file lands in the static folder (config backup, .env copy, DB dump), it becomes publicly downloadable. Also enables path-traversal probes.

Fix: limit to known extensions (.js, .css, .html, .png, etc.) and return 404 for everything else. send_from_directory itself already handles path traversal but the allow-anything approach is over-permissive.

M4 — Alpaca credentials staging-vs-prod rollout order

Evidence: staging status endpoint shows missing_env for ALPACA_PAPER_API_KEY / ALPACA_PAPER_API_SECRET. The deploy workflow does NOT currently heroku config:set for Alpaca — secrets exist in GH but aren't pushed to Heroku.

Risk: Once the deploy workflow is updated to push Alpaca creds (likely soon, since trading endpoints need them), the staging API immediately becomes a credential-exposure vector without auth. Do NOT push Alpaca keys to Heroku staging until C1 (CORS), C2 (Access gate), and C3 (debug default) are resolved.

Fix: block Alpaca env-var push behind auth-layer completion. Explicit pre-condition in #46 / the deploy workflow.

M5 — WebAuthn registration endpoint feature-flag state unknown in staging

Evidence: PR #194 merged; flag is webauthn_registration per backend_v2/api/feature_flags.yaml (needs verification). No observed Heroku config for the flag.

Risk: If the flag is accidentally on in staging before the full registration flow is hardened, attackers could register malicious credentials.

Fix: verify FLAG_WEBAUTHN_REGISTRATION=off in Heroku staging config until the endpoint has been security-reviewed end-to-end.

LOW

L1 — No DMARC / SPF / DKIM on moosequest.net (relevant when we start sending transactional email) L2 — Staging's direct-to-Heroku URL (raxx-api-staging-1a19fb3873b9.herokuapp.com) is reachable without Cloudflare — CF Access on the custom domain doesn't block the Heroku URL. Mitigation: Flask middleware that rejects requests without CF-Connecting-IP header (defense-in-depth). L3 — No Content-Security-Policy, X-Frame-Options, Strict-Transport-Security headers set on API responses (covered by a simple @app.after_request hook) L4 — No X-Content-Type-Options: nosniff header L5 — Sentry configured but PII-scrubber state unknown; not active with real users so low priority today L6 — Redis / session-store not yet used so session-hijacking not yet exploitable

INFO

I1 — gitleaks-action runs on every PR ✓ I2 — Bandit SAST runs ✓ I3 — pip-audit runs strict ✓ (after #228 hotfix landed) I4 — Dependabot / Renovate not configured — worth considering I5 — No 2FA enforcement on the GitHub org (organizational-level setting)

Critical-path fixes for THIS PR

The 5 items below are coded into this same PR:

  1. C1 — CORS lockdown via FRONTEND_ORIGIN env allowlist
  2. C3 — FLASK_DEBUG default flipped to False
  3. C4 — SECRET_KEY fail-fast in non-dev
  4. H2 — Global rate limit default flipped to True (100 req / 60s per-IP); deploy-health-check path whitelisted
  5. H1 — /api/system/status stripped to minimal unauth'd; full detail moved to /api/system/diagnostic (auth-gated placeholder until WebAuthn lands)

The remaining CRITICAL item — C2 (auth gate on all trading endpoints) — is addressed by Cloudflare Access on api-staging.raxx.app (infra change, not code). Set up in parallel.

Follow-up items (file as issues)

H3, M1–M5, L1–L6 get filed as individual security cards labeled type:security for scheduled work.

Round-2 verification plan

After this PR merges + Cloudflare Access is live:

  1. Re-run the probe curls from Round-1 and confirm: - CORS responds to arbitrary origin with no Access-Control-Allow-Origin header (or with explicit allowlisted origin only) - /api/trading/account returns 302 to Cloudflare Access login page, not 200 - /api/system/status returns minimal JSON only - Global rate-limit triggers after 100 req/min per IP
  2. Verify FLASK_DEBUG=False on Heroku (already set, but belt-and-suspenders)
  3. Verify SECRET_KEY is set on Heroku (likely not; if unset, the app will refuse to boot after this PR — good signal)
  4. Run a dry-run of the deploy workflow against the Access-gated URL with a service-token header; confirm health check works

Results documented in docs/security/2026-04-24-security-review-round2.md.

What this review explicitly did NOT do

Those are planned for a Round-3 review closer to MBT v1 public launch.