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.
Evidence: curl -I -X OPTIONS -H "Origin: https://attacker.com" returned Access-Control-Allow-Origin: https://attacker.com. Source: backend_v2/api/__init__.py:30 — CORS(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).
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).
FLASK_DEBUG defaults to True if env var unsetEvidence: backend_v2/api/__init__.py:35 — DEBUG=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.
SECRET_KEY falls back to a hardcoded stringEvidence: backend_v2/api/__init__.py:33 — SECRET_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.
/api/system/status leaks system + process fingerprint unauthenticatedEvidence: 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)
Evidence: backend_v2/api/__init__.py:36 — ENABLE_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.
HEROKU_API_KEY is long-lived admin credential in GH secretsEvidence: 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.
/api/compare-quotes (legacy) exposed unauth'd, no rate limitEvidence: 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.
/ returns app name + version unauth'dEvidence: @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.
/<path:path> is a catch-allEvidence: @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.
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.
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.
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
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)
The 5 items below are coded into this same PR:
FRONTEND_ORIGIN env allowlist/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.
H3, M1–M5, L1–L6 get filed as individual security cards labeled type:security for scheduled work.
After this PR merges + Cloudflare Access is live:
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 IPFLASK_DEBUG=False on Heroku (already set, but belt-and-suspenders)SECRET_KEY is set on Heroku (likely not; if unset, the app will refuse to boot after this PR — good signal)Results documented in docs/security/2026-04-24-security-review-round2.md.
pip-audit (covered by CI)bandit (covered by CI)Those are planned for a Round-3 review closer to MBT v1 public launch.