Raxx · internal docs

internal · gated

CSRF approach decision — console.raxx.app

Date: 2026-05-12 Issue: #1910 Severity at filing: MEDIUM Decision options: A (App-level CSRF token), B (SameSite=Strict cookies), C (Both) Author: security-agent (nightly audit 2026-05-12)


1. Current state assessment

The console session cookie (console_session) is configured with SameSite=Lax, HttpOnly=True, and Secure=True (in production/staging). This is confirmed in three places: console/app/__init__.py line 44 sets the Flask app config, and console/app/blueprints/auth.py lines 427–434, 497–505, and 760–768 each call response.set_cookie(..., samesite="Lax") directly on the console_session cookie at session issuance (passkey login, TOTP verify, and Google OAuth callback respectively). There is no application-level CSRF token in use. Flask-WTF is absent from console/requirements.txt, and CSRFProtect is never initialized in create_app().

Four templates (customers/danger_zone.html, customers/detail.html, customers/invite.html) contain {{ csrf_token() if csrf_token is defined else '' }}. Because csrf_token is never defined in the Jinja2 context, every form renders an empty hidden field — meaning these templates were written with the expectation of Flask-WTF but the library was never wired in. The fields exist as dead markup, offering no protection.

Mutating endpoints on the console fall into three auth categories. First: session-cookie-gated endpoints decorated with @require_role or @require_rbac_role — these include the high-value POST routes for customer delete/reset, session revoke, deploy-freeze toggle, flag flip, secret rotation, RBAC grant creation/deletion, and ops dispatch. For these endpoints CSRF is the open gap. Second: TOTP-elevated endpoints — POST /api/secrets/<name>/rotate and POST /api/secrets/<name>/rotate-v2 require TOTP before executing; CSRF here would fail because the attacker cannot supply a valid TOTP code. Third: service-token or HMAC-gated internal endpoints — POST /api/internal/audit, POST /api/internal/deploys, POST /api/internal/deploys/<id>/status, POST /api/internal/heroku-log-drain, POST /freescout-webhook-rbac — these authenticate via Authorization: Bearer or HMAC-SHA256 signatures, not cookies, so SameSite and CSRF tokens are irrelevant to them. The CSRF exposure is entirely in category one.


2. Per-option threat model

Attack scenario matrix

# Scenario Option A result Option B result Option C result One-line reasoning
1 Classic cross-origin form POST from https://evil.example.com/csrf-poc.html submitting <form action="https://console.raxx.app/console/deploy-freeze/toggle" method="POST"> via JavaScript autosubmit BLOCKED — server rejects because no valid CSRF token in body BLOCKED — Strict cookie not sent on cross-origin navigation BLOCKED — both layers independently catch it SameSite=Strict blocks cross-origin cookie transmission; CSRF token blocks if cookie somehow arrives
2 CSRF via <img src="https://console.raxx.app/console/some-endpoint?action=toggle"> against any GET-exposed mutating side effect NOT APPLICABLE — no GET routes mutate state; all mutations require explicit POST/PUT/DELETE methods and @require_role rejects GETs NOT APPLICABLE — same NOT APPLICABLE — same All console mutating routes require POST; no GET with side effects found in any blueprint
3 Cross-tab attack — operator opens https://evil.example.com/csrf-poc.html in Tab 2 while logged into console in Tab 1 BLOCKED — attacker's POST lacks the CSRF token BLOCKED — Strict cookie not sent on cross-site sub-resource requests including form POSTs from a different origin in another tab BLOCKED Strict cookie and CSRF token both block; Lax cookie IS sent in this scenario, so current state (no A, no B) lets this attack succeed
4 Subdomain attack — attacker controls evil.raxx.app (via subdomain takeover or compromised deployment) and uses it to forge a POST BLOCKED — CSRF token not accessible cross-origin (SOP); attacker cannot read or inject the token SUCCEEDS — SameSite=Strict does NOT protect against same-site (different subdomain on same eTLD+1) requests; evil.raxx.app and console.raxx.app share the raxx.app site boundary; Strict cookies ARE sent to console.raxx.app from evil.raxx.app per the SameSite spec BLOCKED — CSRF token provides the protection B alone lacks on this scenario This is the critical gap in Option B. Subdomain attacks bypass SameSite entirely.
5 Stored XSS on console used to bypass CSRF protection — attacker achieves script execution in the console origin, exfiltrates the CSRF token, then fires authenticated requests SUCCEEDS — XSS running same-origin can read the CSRF token from the DOM and include it in forged requests; CSRF tokens do not stop XSS-sourced attacks SUCCEEDS — XSS running same-origin ignores SameSite; cookies are sent with same-origin requests SUCCEEDS — neither mechanism stops XSS; XSS requires a separate finding and fix (input sanitization, CSP) CSRF is orthogonal to XSS; only Content Security Policy and output encoding address the XSS root cause
6 Browser back-button form re-submit — user navigates back to a POST form page with a stale CSRF token (e.g., after session expiry + re-auth) UX RISK — Flask-WTF CSRF tokens tied to the Flask secret key session, not per-form nonces; a re-authenticated session gets a new token, the stale form is rejected with 400; user must reload — minor friction NOT APPLICABLE — SameSite=Strict has no token concept; re-submits work if session is valid SAME as A for CSRF token behavior; Strict cookie doesn't add back-button friction beyond standard auth Flask-WTF uses HMAC-SHA1(secret_key, session_id, time) so tokens expire but are not per-request nonces unless configured; stale tokens reject cleanly
7 Mobile WebView / non-browser User-Agent (iOS WKWebView, Android WebView) — does SameSite apply? BLOCKED — CSRF token is independent of browser SameSite support PARTIAL — iOS WKWebView honors SameSite=Strict; Android WebView's SameSite support depends on OS version (Android 5.x/6.x WebView does not honor SameSite); old Android WebView treats all cookies as SameSite=None BLOCKED (CSRF token layer is the safety net when SameSite fails) The console is operator-facing only, not a customer-facing mobile surface; operator presumably uses modern browsers; risk is LOW in practice but real in principle
8 Session expiry mid-mutation + Strict cookie + external link re-auth — operator receives a FreeScout email notification with a direct link to https://console.raxx.app/console/customers/42/detail, clicks it from email client, session has expired NOT APPLICABLE — Option A doesn't affect this flow UX BROKEN — session expired; browser redirects to login; after Google OAuth callback the redirect lands back at the console login page, NOT the original deep link; this is because samesite=Strict means the session cookie is not sent on the redirect-landing from accounts.google.com, causing a second redirect loop or requiring the operator to manually re-navigate SAME UX IMPACT as B — Strict cookies break the post-auth redirect for any external-origin return URL This is the most consequential UX cost of Option B or C

Key scenario detail: Scenario 3 (cross-tab, current state)

Under the current Lax configuration, if an operator is logged in and visits a malicious page in another tab, an autosubmitted form POST to console.raxx.app WILL include the console_session cookie. The Lax rule only blocks cookies on requests initiated by cross-site navigation where the method is not a top-level navigation GET. A JavaScript-initiated fetch() or form.submit() does NOT qualify as a top-level navigation, but the SameSite=Lax specification (RFC 6265bis) has browser-specific interpretation: Chrome 76+ does treat cross-origin form POSTs as cross-site and withholds Lax cookies. However, the spec behavior is:

Sources: - https://web.dev/articles/samesite-cookies-explained - https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie/SameSite - https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis


3. Operational tradeoffs

Option A — App-level CSRF token (Flask-WTF): Implementation cost is moderate. Flask-WTF needs to be added to requirements.txt, CSRFProtect(app) initialized in create_app(), all server-side-rendered form templates updated with {{ csrf_token() }} hidden fields (four templates already have the placeholder; the rest of the mutating forms need to be audited), and all JavaScript-initiated fetch/HTMX POSTs updated to read the token from a cookie or meta tag and send it in a header (X-CSRFToken). HTMX endpoints (hx-post in secrets/index.html, _rotate_modal.html, _rotate_modal_v2.html, dashboard/_site_detail.html) and fetch-based endpoints (billing/alert_config.html, ops/index.html) each need the header injection. UX impact is low for normal flows but introduces a token-expiry edge case on very long sessions or back-button re-use (scenario 6). Test surface widens: every POST integration test needs to either disable CSRF for the test client or supply a valid token. Flask-WTF provides a WTF_CSRF_ENABLED = False config flag standard for test environments. CORS interaction is not a concern here because the console does not set Access-Control-Allow-Origin on its session-gated routes — the endpoints are same-origin only.

Option B — SameSite=Strict cookies only: The implementation is a 3-line change: three set_cookie calls in auth.py change samesite="Lax" to samesite="Strict". No template changes, no new dependencies. The material cost is UX breakage on external-origin navigation. Concretely: links to console pages sent via FreeScout ticket emails, Slack notifications (e.g., deploy-done pings), status-page alert links, and Google OAuth callback redirects will all arrive with the cookie absent. The operator will be dropped to the login page every time they click a console link from outside the console window. Post-Google-OAuth, the final redirect from accounts.google.com to console.raxx.app also arrives as a cross-site navigation, so Strict will strip the just-issued cookie on that first page load — the operator will see a login loop unless the auth flow uses a landing-page bounce within the console origin before issuing the session cookie. The Google OAuth callback currently issues the cookie directly in the response to the callback URL, which works with Lax (the redirect is top-level navigation) but will fail with Strict because accounts.google.com → console.raxx.app is cross-site. Fixing this requires an extra same-origin bounce redirect, adding implementation complexity that approaches Option A in effort while providing weaker coverage than Option C.

Option C — Both (Strict cookies + CSRF tokens): The combined cost is additive — the Flask-WTF work plus the three-line cookie change plus the Google OAuth bounce-redirect fix. The marginal protection Option A adds beyond Option B is specifically in scenario 4 (subdomain takeover) and scenario 7 (non-SameSite-aware clients). For this console, which is operator-facing only and hosted on a controlled subdomain space, the realistic threat of a *.raxx.app subdomain takeover is meaningful only if a subdomain points to a third-party service that could be squatted. That risk should be assessed separately (subdomain enumeration audit). The Google OAuth fix required by Option B is also required by Option C, making the incremental cost of going from B to C primarily the Flask-WTF integration and template work.


4. Recommendation

Recommended: Option C, implemented in two phases.

Phase 1 (immediate, low-effort): Change samesite="Lax" to samesite="Strict" in all three set_cookie calls in auth.py, and add a same-origin bounce redirect after the Google OAuth callback so the Strict cookie survives the post-OAuth landing. This addresses scenarios 1, 3, and 4 at minimum code change and closes the most accessible attack paths before launch.

Phase 2 (pre-launch, planned): Add Flask-WTF CSRFProtect, wire {{ csrf_token() }} into all mutating form templates (the four with the dead placeholder plus any others identified during template audit), and add X-CSRFToken header injection to the HTMX and fetch-based mutations. This adds the defense-in-depth layer that covers scenario 4 (subdomain takeover — the scenario where Option B alone fails) and scenario 7 (non-SameSite-aware clients).

The operator should not treat Option B alone as sufficient because the subdomain gap (scenario 4) is real and grows as the Raxx service surface expands to more *.raxx.app deployments (staging, velvet, probes). Each new subdomain is a potential future lateral pivot point. The CSRF token layer closes that gap regardless of subdomain state.

This recommendation is the security agent's informed analysis. The operator makes the final call. If launch timing requires deferring Phase 2, ensure #1910 remains open and explicitly tracked as a pre-launch close item.


5. Implementation pointers (do not implement — hand to feature-developer)

Option A: Flask-WTF integration

Requirements: Add Flask-WTF>=1.2.0,<2 to console/requirements.txt.

App init (console/app/__init__.py):

from flask_wtf.csrf import CSRFProtect
csrf = CSRFProtect()
# inside create_app(), after app.config.update(...):
csrf.init_app(app)

Config keys to add to app.config.update():

WTF_CSRF_ENABLED=True,
WTF_CSRF_TIME_LIMIT=3600,  # token valid for 1h; adjust to match 8h session if preferred
WTF_CSRF_HEADERS=["X-CSRFToken"],  # for JSON/HTMX endpoints

Server-side-rendered forms: The four templates with {{ csrf_token() if csrf_token is defined else '' }} will auto-populate once Flask-WTF is initialized — no template surgery needed for those. All other mutating form templates (identify with grep -rn 'method="POST"' console/app/templates/) need <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> added inside the <form> tag.

HTMX endpoints (hx-post in secrets/index.html, _rotate_modal.html, _rotate_modal_v2.html, dashboard/_site_detail.html): Add hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}' to the HTMX element, or set it globally via:

<meta name="csrf-token" content="{{ csrf_token() }}">
<script>
  document.addEventListener('htmx:configRequest', function(evt) {
    evt.detail.headers['X-CSRFToken'] = document.querySelector('meta[name="csrf-token"]').content;
  });
</script>

fetch()-based endpoints (billing/alert_config.html, ops/index.html): Read the token from the meta tag and include it in the headers:

headers: { 'X-CSRFToken': document.querySelector('meta[name="csrf-token"]').content }

Service-token / HMAC endpoints to exempt: CSRFProtect applies globally by default. Exempt routes that use Bearer/HMAC auth (they are not session-cookie endpoints, so CSRF is moot, and the CSRF check would break CI/CD pipelines):

@csrf.exempt
@bp.route("/api/internal/audit", methods=["POST"])
def ingest_audit_event(): ...

Routes to exempt: /api/internal/audit, /api/internal/deploys (POST), /api/internal/deploys/<id>/status, /api/internal/heroku-log-drain, /freescout-webhook-rbac.

Test suite: Add WTF_CSRF_ENABLED=False to the test app config (set via config_overrides in the existing test fixture pattern) or use app.config["WTF_CSRF_ENABLED"] = False in conftest.py. Do not use @csrf.exempt on application routes as a test workaround — disable at the app level in test mode only.

CORS interaction: The console sets no Access-Control-Allow-Origin headers and is not a CORS-enabled API. No CORS changes required.

Three locations in console/app/blueprints/auth.py: - Line 433: samesite="Lax"samesite="Strict" (passkey TOTP verify → session issuance) - Line 503: samesite="Lax"samesite="Strict" (TOTP verify → session issuance) - Line 767: samesite="Lax"samesite="Strict" (Google OAuth callback → session issuance)

Also update the Flask app config at console/app/__init__.py line 44:

SESSION_COOKIE_SAMESITE="Strict",

Google OAuth bounce-redirect fix required for Strict: The google_callback route currently issues the cookie in its response to GET /auth/google/callback. Under Strict, the browser won't send that cookie on the next top-level navigation. The fix: after writing the cookie, redirect to an internal same-origin landing page (/auth/landing?next=/console/dashboard) that the browser fetches same-origin (Strict cookie IS sent to same-origin), and from there redirect to the final destination. This costs one extra round-trip but survives Strict semantics.

Option C: Order of operations and rollout plan

  1. Phase 1 — cookie Strict change (same PR as OAuth bounce-redirect fix): - Change the three set_cookie calls - Add the SESSION_COOKIE_SAMESITE="Strict" app config - Add the same-origin bounce-redirect after Google OAuth callback - Test: verify login flows from passkey, TOTP, and Google OAuth all complete correctly; verify clicking a console link from a Slack DM prompts re-auth (expected, acceptable)

  2. Phase 2 — Flask-WTF CSRF tokens (separate PR): - Add Flask-WTF to requirements - Initialize CSRFProtect in create_app() - Wire templates (form fields + HTMX header injection + fetch header injection) - Exempt service-token/HMAC routes - Disable CSRF in test config - Deploy to staging; run full integration test suite; verify no 400 CSRF errors on any legitimate mutation path

Both phases can ship independently. Phase 1 is a smaller diff with measurable security improvement. Phase 2 closes the remaining subdomain gap and future-proofs against SameSite policy drift.


References