|
| 1 | +"""End-to-end CSRF tests with a real browser. |
| 2 | +
|
| 3 | +Verifies the server-rendered CSRF protection actually works the way the |
| 4 | +unit tests claim: token gets rendered into the page from the same request |
| 5 | +that mints the cookie, htmx submissions carry the header, classic forms |
| 6 | +carry the hidden field, the cookie is HttpOnly (no JS access). |
| 7 | +""" |
| 8 | + |
| 9 | +from __future__ import annotations |
| 10 | + |
| 11 | +from typing import TYPE_CHECKING |
| 12 | + |
| 13 | +import pytest |
| 14 | + |
| 15 | +if TYPE_CHECKING: |
| 16 | + from playwright.sync_api import Page |
| 17 | + |
| 18 | +pytestmark = pytest.mark.e2e |
| 19 | + |
| 20 | + |
| 21 | +CSRF_COOKIE_NAME = "csrf_token" |
| 22 | +CSRF_HEADER_NAME = "x-csrf-token" |
| 23 | + |
| 24 | + |
| 25 | +# --------------------------------------------------------------------------- |
| 26 | +# Cookie is HttpOnly |
| 27 | +# --------------------------------------------------------------------------- |
| 28 | + |
| 29 | + |
| 30 | +def test_csrf_cookie_is_httponly(app_server: str, auth_page: Page) -> None: |
| 31 | + """The csrf_token cookie must be HttpOnly so JavaScript cannot read it. |
| 32 | +
|
| 33 | + Templates render the token server-side via {{ request.state.csrf_token }}; |
| 34 | + no client-side code needs to read it from the cookie. |
| 35 | + """ |
| 36 | + auth_page.goto(app_server) |
| 37 | + |
| 38 | + # Read all cookies set on this origin via the BrowserContext (which sees |
| 39 | + # HttpOnly cookies); then via document.cookie (which does NOT see them). |
| 40 | + server_visible = {c["name"]: c for c in auth_page.context.cookies()} |
| 41 | + js_visible = auth_page.evaluate("document.cookie") |
| 42 | + |
| 43 | + assert CSRF_COOKIE_NAME in server_visible, "CSRF cookie was not set by the server" |
| 44 | + cookie = server_visible[CSRF_COOKIE_NAME] |
| 45 | + assert cookie["httpOnly"] is True, f"CSRF cookie must be HttpOnly, got {cookie}" |
| 46 | + assert CSRF_COOKIE_NAME not in js_visible, f"CSRF cookie must NOT appear in document.cookie; got: {js_visible}" |
| 47 | + |
| 48 | + |
| 49 | +# --------------------------------------------------------------------------- |
| 50 | +# Wizard form carries hx-headers with the same token as the cookie |
| 51 | +# --------------------------------------------------------------------------- |
| 52 | + |
| 53 | + |
| 54 | +def test_wizard_form_renders_hx_headers_with_csrf_token(app_server: str, auth_page: Page) -> None: |
| 55 | + """The wizard's <form hx-ext='json-enc'> must carry an hx-headers |
| 56 | + attribute containing X-CSRF-Token equal to the cookie value, so the |
| 57 | + next htmx POST passes the middleware's double-submit check. |
| 58 | + """ |
| 59 | + auth_page.goto(f"{app_server}/forms/wizard/create-project") |
| 60 | + auth_page.wait_for_load_state("networkidle") |
| 61 | + |
| 62 | + # Pull the cookie value the browser holds (BrowserContext sees HttpOnly). |
| 63 | + cookie_value = next(c["value"] for c in auth_page.context.cookies() if c["name"] == CSRF_COOKIE_NAME) |
| 64 | + assert cookie_value, "CSRF cookie value empty" |
| 65 | + |
| 66 | + # Read the hx-headers attribute on the wizard form. |
| 67 | + hx_headers = auth_page.locator("#wizard-step-form").get_attribute("hx-headers") |
| 68 | + assert hx_headers is not None, "wizard form is missing hx-headers" |
| 69 | + assert "X-CSRF-Token" in hx_headers, f"hx-headers does not name X-CSRF-Token: {hx_headers}" |
| 70 | + assert cookie_value in hx_headers, ( |
| 71 | + f"hx-headers token does not match cookie value; hx-headers={hx_headers!r} cookie={cookie_value!r}" |
| 72 | + ) |
| 73 | + |
| 74 | + |
| 75 | +def test_wizard_step_submission_sends_csrf_header(app_server: str, auth_page: Page) -> None: |
| 76 | + """When the wizard form is submitted via htmx, the actual XHR request |
| 77 | + must carry an X-CSRF-Token header that matches the cookie. This is the |
| 78 | + end-to-end verification that hx-headers reaches the network layer. |
| 79 | + """ |
| 80 | + auth_page.goto(f"{app_server}/forms/wizard/create-project") |
| 81 | + auth_page.wait_for_load_state("networkidle") |
| 82 | + |
| 83 | + captured: dict[str, str | None] = {"header": None} |
| 84 | + |
| 85 | + def _on_request(request) -> None: |
| 86 | + if request.method == "POST" and "/forms/wizard/" in request.url: |
| 87 | + captured["header"] = request.headers.get(CSRF_HEADER_NAME) |
| 88 | + |
| 89 | + auth_page.on("request", _on_request) |
| 90 | + |
| 91 | + # Fill the project name minimally and submit the first step. Whether or |
| 92 | + # not validation passes is irrelevant -- we only need the request to fire |
| 93 | + # so we can inspect the header it carried. |
| 94 | + name_field = auth_page.locator("[name='name']").first |
| 95 | + if name_field.count() > 0: |
| 96 | + name_field.fill("e2e-csrf-test") |
| 97 | + auth_page.locator("button[type='submit']").first.click() |
| 98 | + auth_page.wait_for_load_state("networkidle") |
| 99 | + |
| 100 | + assert captured["header"] is not None, "wizard step POST did NOT include X-CSRF-Token header" |
| 101 | + |
| 102 | + cookie_value = next(c["value"] for c in auth_page.context.cookies() if c["name"] == CSRF_COOKIE_NAME) |
| 103 | + assert captured["header"] == cookie_value, ( |
| 104 | + f"X-CSRF-Token header does not match cookie; header={captured['header']!r} cookie={cookie_value!r}" |
| 105 | + ) |
| 106 | + |
| 107 | + |
| 108 | +# --------------------------------------------------------------------------- |
| 109 | +# Classic <form method=POST> carries a hidden csrf_token input |
| 110 | +# --------------------------------------------------------------------------- |
| 111 | + |
| 112 | + |
| 113 | +def test_invite_register_has_hidden_csrf_field(app_server: str, page: Page) -> None: |
| 114 | + """The invite-register form must include a hidden csrf_token input that |
| 115 | + matches the cookie. This is the form-encoded path -- no JS, no headers. |
| 116 | +
|
| 117 | + Uses the unauthenticated `page` fixture because invite-register is the |
| 118 | + pre-login flow. We need an invite key that the server will accept; in |
| 119 | + the test app this is unconfigured, so this test verifies the rendering |
| 120 | + contract on whichever response we get back. |
| 121 | + """ |
| 122 | + # In the test app, the invite key is invalid -- but we still want to see |
| 123 | + # what's rendered. If we get the form back, the hidden field must be |
| 124 | + # there with the cookie value. |
| 125 | + page.goto(f"{app_server}/invite/test-invite-key", wait_until="networkidle") |
| 126 | + |
| 127 | + # If we got the register form (not an error page), check the hidden field. |
| 128 | + form = page.locator("form[action*='/invite/'][action*='/register']") |
| 129 | + if form.count() == 0: |
| 130 | + pytest.skip("Invite key not configured in test app; cannot exercise the form path here.") |
| 131 | + |
| 132 | + hidden = form.locator("input[name='csrf_token']") |
| 133 | + assert hidden.count() == 1, "invite-register form is missing hidden csrf_token input" |
| 134 | + rendered = hidden.get_attribute("value") |
| 135 | + assert rendered, "hidden csrf_token has empty value" |
| 136 | + |
| 137 | + cookie_value = next(c["value"] for c in page.context.cookies() if c["name"] == CSRF_COOKIE_NAME) |
| 138 | + assert rendered == cookie_value, ( |
| 139 | + f"hidden field value does not match cookie; field={rendered!r} cookie={cookie_value!r}" |
| 140 | + ) |
| 141 | + |
| 142 | + |
| 143 | +# --------------------------------------------------------------------------- |
| 144 | +# Negative: a forged POST without the token is rejected (proves the |
| 145 | +# middleware actually rejects, not just that the server sends the header) |
| 146 | +# --------------------------------------------------------------------------- |
| 147 | + |
| 148 | + |
| 149 | +def test_post_without_csrf_token_is_rejected(app_server: str, auth_page: Page) -> None: |
| 150 | + """A POST that omits both the X-CSRF-Token header and the csrf_token |
| 151 | + form field must be rejected with 403, even when the session cookie is |
| 152 | + present. |
| 153 | +
|
| 154 | + This is the proof that the middleware actually enforces -- without it, |
| 155 | + all the rendering work is theatre. Done via page.evaluate so the fetch |
| 156 | + runs inside the browser context and inherits the session cookie that |
| 157 | + auth_page already established. |
| 158 | + """ |
| 159 | + auth_page.goto(app_server) |
| 160 | + |
| 161 | + # Issue a same-origin POST from the page itself so the session cookie |
| 162 | + # is included. Deliberately omit BOTH the X-CSRF-Token header and the |
| 163 | + # csrf_token form field; the middleware must reject it. |
| 164 | + status = auth_page.evaluate( |
| 165 | + """async () => { |
| 166 | + const r = await fetch('/forms/wizard/create-project/step/identity', { |
| 167 | + method: 'POST', |
| 168 | + headers: {'Content-Type': 'application/json'}, |
| 169 | + body: '{}', |
| 170 | + credentials: 'same-origin', |
| 171 | + }); |
| 172 | + return r.status; |
| 173 | + }""" |
| 174 | + ) |
| 175 | + |
| 176 | + assert status == 403, f"POST without CSRF token must be rejected; got status={status}" |
0 commit comments