diff --git a/.cursor/rules/git-workflow.mdc b/.cursor/rules/git-workflow.mdc index 5390f5e..e95d18b 100644 --- a/.cursor/rules/git-workflow.mdc +++ b/.cursor/rules/git-workflow.mdc @@ -27,7 +27,13 @@ alwaysApply: true - **By hand (same as CI):** `uv run ruff check` / `ruff format --check` on `oxyroute`, `tests`, `examples`; `cargo fmt --all -- --check`; `cargo clippy --all-targets -- -D warnings`; build + test as in CI. - **If CI or local checks fail:** the user will report it — fix that. Do not assume green; do not proactively burn tokens re-running the full suite after every edit. -6. **Atomic commits** — one logical change per commit (e.g. `feat:`, `fix:`, `test:`, `docs:`). **Do not** mix product code and [`.github/ISSUE_BACKLOG/`](.github/ISSUE_BACKLOG/) in the same commit; backlog updates: separate `docs:` / `chore:` commit if needed. +6. **Atomic commits** — one logical change per commit (e.g. `feat:`, `fix:`, `test:`, `docs:`). + - **Hard rule:** never mix product changes (`oxyroute/`, `src/`, `tests/`, runtime docs) with [`.github/ISSUE_BACKLOG/`](.github/ISSUE_BACKLOG/) in the same commit. + - If both are touched in a branch, split them into separate commits: + - Product commit: `feat:` / `fix:` / `test:` / `docs:` + - Backlog-only commit: `docs:` or `chore:` + - **Pre-commit gate (required):** run `git diff --cached --name-only` and verify staged paths are from one scope only (product **or** backlog). + - **If mixed staging is detected:** unstage (`git restore --staged `) and re-stage by scope before committing. 7. **PR into `dev`** - `git push -u origin ` @@ -35,6 +41,13 @@ alwaysApply: true 8. **After merge** — delete the local branch, keep `main` and `dev`; next issue: repeat from step 1. +## PR gate (required before opening PR) + +- Re-check commit boundaries with: + - `git log --name-only --oneline dev..HEAD` + - `git show --name-only --oneline ` +- If any commit mixes product + backlog paths, split/fix history before PR (prefer small follow-up commits over forceful history rewrites unless explicitly requested). + ## Exceptions - Trivial one-line doc fixes may still use a small branch + PR, or a maintainer chore — default is **branch + PR** for any code or test change. diff --git a/.github/ISSUE_BACKLOG/PRIORITIES.md b/.github/ISSUE_BACKLOG/PRIORITIES.md index 91d0f9b..cb7eb0b 100644 --- a/.github/ISSUE_BACKLOG/PRIORITIES.md +++ b/.github/ISSUE_BACKLOG/PRIORITIES.md @@ -15,7 +15,6 @@ This file tracks **priority tiers** for items in [bodies/](bodies/). The **next |---|------|-----------| | 8 | [08.md](bodies/08.md) | **JWK / JWKS** — [GitHub #8](https://github.com/QueryaHub/OxyRoute/issues/8). | | 9 | [09.md](bodies/09.md) | **OpenAPI depth** (optional) — `$ref` / `$defs`. | -| 28 | [28.md](bodies/28.md) | **CSRF** (optional) — [GitHub #53](https://github.com/QueryaHub/OxyRoute/issues/53). | ## Research / heavier (may slip past 0.2.0) @@ -36,11 +35,12 @@ This file tracks **priority tiers** for items in [bodies/](bodies/). The **next - **Sub-routers:** 21 / [#46](https://github.com/QueryaHub/OxyRoute/issues/46) — `APIRouter`, `include_router`, `docs/routing.md` - **CORS:** 24 / [#49](https://github.com/QueryaHub/OxyRoute/issues/49) — `CORSConfig`, `apply_cors`, `set_cors`, `docs/cors.md` - **Security headers:** 29 / [#54](https://github.com/QueryaHub/OxyRoute/issues/54) — `SecurityHeadersConfig`, `set_security_headers`, `docs/security-headers.md` +- **CSRF:** 28 / [#53](https://github.com/QueryaHub/OxyRoute/issues/53) — `CSRFConfig`, `apply_csrf`, `csrf_layer`, `docs/csrf.md` ## Roadmap phasing (summary) 1. **P0:** 4, 17; **18** / **#47 (form)** done (order flexible). -2. **P1:** 8, 53; **#48** / **#46** / **#49 (CORS)** / **#54 (security headers)** done; 9 as polish. +2. **P1:** 8; **#48** / **#46** / **#49 (CORS)** / **#54 (security headers)** / **#53 (CSRF)** done; 9 as polish. 3. **Research:** 50, 51, 52 — as capacity allows. [← Back to README](README.md) diff --git a/docs/csrf.md b/docs/csrf.md new file mode 100644 index 0000000..ebff010 --- /dev/null +++ b/docs/csrf.md @@ -0,0 +1,42 @@ +# CSRF (optional) + +[← Documentation index](index.md) + +[Cross-Site Request Forgery (CSRF)](https://owasp.org/www-community/attacks/csrf/) is a **browser** risk: a logged-in user’s **cookies** (session, `HttpOnly` auth) can be sent to your site on a request you did not intend (e.g. a malicious form or script on another site). It does not apply the same way to stateless **Bearer token** flows where the client sets `Authorization` itself. + +## When to use this module + +- **Use** `CSRFConfig` / `apply_csrf` in `oxyroute/csrf.py` when your app uses **cookie-based** sessions or any cookie that the browser sends automatically, **and** the browser is allowed to hit **mutating** routes (`POST` / `PUT` / `PATCH` / `DELETE`). + +- **Usually skip** for pure **JSON APIs** with only `Authorization: Bearer ...` and **no** session cookie — the attacker’s page cannot set `Authorization` for your API origin. + +## How it works (double-submit) + +1. The server (or a prior response) issues a **random token** and sets it in a **cookie** (e.g. via :meth:`oxyroute.csrf.CSRFConfig.set_cookie_value` on :class:`oxyroute.response.Response`). + +2. The client must send the **same** value in a header (default: `X-CSRF-Token`). + +3. The pre-route check compares cookie and header with **constant-time** equality. Mismatch or missing value → **403** JSON: `{"error":"csrf",...}`. + +4. The check runs **before** the request body is read on the app path (same as :meth:`oxyroute.app.App.set_middleware`). + +[SameSite](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value) on the session cookie (e.g. `Lax` or `Strict`) is a separate layer; double-submit is an extra control when you still need cross-site or legacy flows that cookies alone do not cover. + +## Combining with CORS + +Use a **single** `set_middleware` stack. CORS preflight is handled in :func:`oxyroute.cors.apply_cors`; run CSRF **inside** the `chain` so `OPTIONS` preflight is not subject to the CSRF token rule: + +```python +from oxyroute import CORSConfig, apply_cors +from oxyroute.csrf import CSRFConfig, csrf_layer + +cors = CORSConfig(allow_origins=["https://app.example"], allow_methods=[...]) +csrf = CSRFConfig() +apply_cors(app, cors, chain=csrf_layer(csrf)) +``` + +## See also + +- [Handlers](handlers.md) — `set_middleware`, `Response`, cookies +- [CORS](cors.md) +- [OWASP CSRF Prevention](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html) diff --git a/docs/feature.md b/docs/feature.md index 1028121..6cf8454 100644 --- a/docs/feature.md +++ b/docs/feature.md @@ -57,7 +57,7 @@ | Тема | Зазор | Комментарий | |------|--------|-------------| | **CORS** | `CORSConfig` + `apply_cors` / `set_cors` | Preflight и заголовки на ответах; см. [cors.md](cors.md). | -| **CSRF** | Нет | Для cookie-сессий и форм часто нужны токены. | +| **CSRF** | `CSRFConfig` / `apply_csrf` / `csrf_layer` | См. [csrf.md](csrf.md); double-submit, для Bearer-only API чаще не нужен. | | **Rate limiting** | Нет | | | **Security headers** (HSTS, CSP, …) | `SecurityHeadersConfig` + `set_security_headers` | [security-headers.md](security-headers.md); merge не перезаписывает уже заданные в `Response` имена. | | **JWKS / ротация ключей** | Частично | В бэклоге [#8](https://github.com/QueryaHub/OxyRoute/issues/8). | @@ -127,7 +127,7 @@ OxyRoute осознанно **уже** в нише: **быстрый маршр - [#50](https://github.com/QueryaHub/OxyRoute/issues/50) — HTTP/2 (док/Granian) ([`25.md`](../.github/ISSUE_BACKLOG/bodies/25.md)) - [#51](https://github.com/QueryaHub/OxyRoute/issues/51) — SSE ([`26.md`](../.github/ISSUE_BACKLOG/bodies/26.md)) - [#52](https://github.com/QueryaHub/OxyRoute/issues/52) — WebSocket ([`27.md`](../.github/ISSUE_BACKLOG/bodies/27.md)) -- [#53](https://github.com/QueryaHub/OxyRoute/issues/53) — CSRF ([`28.md`](../.github/ISSUE_BACKLOG/bodies/28.md)) +- [#53](https://github.com/QueryaHub/OxyRoute/issues/53) — CSRF ([`28.md`](../.github/ISSUE_BACKLOG/bodies/28.md), [csrf.md](csrf.md)) — **сделано** - [#54](https://github.com/QueryaHub/OxyRoute/issues/54) — security headers ([`29.md`](../.github/ISSUE_BACKLOG/bodies/29.md), [security-headers.md](security-headers.md)) — **сделано** --- diff --git a/docs/handlers.md b/docs/handlers.md index 7b4342d..2039673 100644 --- a/docs/handlers.md +++ b/docs/handlers.md @@ -87,7 +87,7 @@ There is no **`register_exception_handler`** API yet; map custom exception types - Return **`None`**: continue with normal routing and body read. - Return **any other value**: use the same mapping as a route return value (`Response`, dict, `str`, etc.); the response is sent and **the route handler and body are skipped** (e.g. cheap CORS preflight on `OPTIONS` without consuming a `POST` body). -For a configurable **`allow_origins` / `allow_methods` / `allow_headers`** flow plus native merging of CORS headers into normal responses, use **`CORSConfig`** and **`apply_cors`** (see [cors.md](cors.md)). For a **browser security** header preset (HSTS, `X-Content-Type-Options`, etc.), use **`SecurityHeadersConfig`** and **`set_security_headers`** (see [security-headers.md](security-headers.md)). +For a configurable **`allow_origins` / `allow_methods` / `allow_headers`** flow plus native merging of CORS headers into normal responses, use **`CORSConfig`** and **`apply_cors`** (see [cors.md](cors.md)). For a **browser security** header preset (HSTS, `X-Content-Type-Options`, etc.), use **`SecurityHeadersConfig`** and **`set_security_headers`** (see [security-headers.md](security-headers.md)). For **CSRF** when you rely on **cookies** and mutating methods, use **`CSRFConfig`** and **`apply_csrf`** (or **`csrf_layer`** with `apply_cors`); see [csrf.md](csrf.md). ## See also @@ -96,3 +96,4 @@ For a configurable **`allow_origins` / `allow_methods` / `allow_headers`** flow - [Dependencies](dependencies.md) - [CORS](cors.md) - [Security headers](security-headers.md) +- [CSRF](csrf.md) diff --git a/docs/index.md b/docs/index.md index d0aceb3..160bdbe 100644 --- a/docs/index.md +++ b/docs/index.md @@ -43,6 +43,7 @@ Granian still invokes a Python `App` object; the “win” is doing routing, bod | [Handlers](handlers.md) | Injected parameters, return types, JSON encoding | | [CORS](cors.md) | `CORSConfig`, preflight, `apply_cors` | | [Security headers](security-headers.md) | `SecurityHeadersConfig`, HSTS, CSP | +| [CSRF](csrf.md) | Double-submit, `apply_csrf`, `csrf_layer` + CORS | | [JWT](jwt.md) | `require_jwt`, HS* / RSA / EC PEM, `decode_jwt_hs` (HS* tests) | | [Dependencies](dependencies.md) | `Depends`, `dependencies=[...]`, `freeze` | | [OpenAPI](openapi.md) | `openapi.json` route, title, `openapi_json()` | diff --git a/oxyroute/__init__.py b/oxyroute/__init__.py index 01e7969..5e5ec1a 100644 --- a/oxyroute/__init__.py +++ b/oxyroute/__init__.py @@ -3,6 +3,7 @@ from oxyroute._oxyroute import decode_jwt_hs from oxyroute.app import App, Depends from oxyroute.cors import CORSConfig, apply_cors +from oxyroute.csrf import CSRFConfig, apply_csrf, csrf_layer from oxyroute.exceptions import HTTPException from oxyroute.response import Response from oxyroute.router import APIRouter @@ -12,12 +13,15 @@ "APIRouter", "App", "CORSConfig", + "CSRFConfig", "Depends", "HTTPException", "Response", "SecurityHeadersConfig", "__version__", "apply_cors", + "apply_csrf", + "csrf_layer", "decode_jwt_hs", ] __version__ = "0.1.0" diff --git a/oxyroute/csrf.py b/oxyroute/csrf.py new file mode 100644 index 0000000..0d72d52 --- /dev/null +++ b/oxyroute/csrf.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import hmac +import re +import secrets +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any, TypeAlias + +from oxyroute.app import App +from oxyroute.response import Response + +__all__ = ["CSRFConfig", "apply_csrf", "csrf_layer"] + +_Middleware: TypeAlias = Callable[[Any, Any], Response | None] +_DEFAULT_UNSAFE = frozenset({"POST", "PUT", "PATCH", "DELETE"}) + +_COOKIE_PAIR = re.compile(r"([^=]+)=(.*)") + + +@dataclass +class CSRFConfig: + """ + **Double-submit** CSRF: the client must send the same secret in a **cookie** and in + a **header** (or header only vs cookie — both present and equal). Stateless; no + server-side session table. + + This matters when the browser **automatically** sends a session cookie; pure APIs that + use only ``Authorization: Bearer ...`` and no auth cookies can usually **omit** CSRF. + + :meth:`guard` runs in pre-route middleware (before the body) — safe for large POSTs: + the check only reads ``Cookie`` and the header. + + For **CORS + CSRF**, register CORS first and pass :func:`csrf_layer` as ``chain`` to + :func:`oxyroute.cors.apply_cors` (see the CSRF doc page in ``docs/``). + + *SameSite* on the cookie (``Lax``/``Strict``) helps against cross-site form posts; the + double-submit is an extra check when you must support cookies across flows that + ``SameSite`` does not cover. + """ + + cookie_name: str = "oxyroute_csrf" + header_name: str = "X-CSRF-Token" + unsafe_methods: frozenset[str] = field(default_factory=lambda: _DEFAULT_UNSAFE) + # Set-Cookie flags when you build the cookie (see :meth:`set_cookie_value`) + secure: bool = False + + def issue_token(self) -> str: + return secrets.token_urlsafe(32) + + def set_cookie_value(self, token: str) -> str: + """ + One ``Set-Cookie`` line (append via :attr:`oxyroute.Response.cookies`). + **HttpOnly** is omitted so browser JS *can* mirror the value into a header; if you + only use traditional forms, you can set ``HttpOnly`` and pass the token in a hidden + field instead of this header. + """ + p = f"{self.cookie_name}={token}; Path=/; SameSite=Lax" + if self.secure: + p += "; Secure" + return p + + def guard(self, scope: Any) -> Response | None: + """ + If the request must be protected and validation fails, return **403**; else + return ``None`` to continue. + """ + m = str(getattr(scope, "method", "") or "").upper() + if m not in self.unsafe_methods: + return None + a = _read_cookie(scope, self.cookie_name) + b = _read_header(scope, self.header_name) + if not a or not b: + return _csrf_403("missing") + if len(a) != len(b) or not hmac.compare_digest(a.encode("utf-8"), b.encode("utf-8")): + return _csrf_403("mismatch") + return None + + +def _csrf_403(detail: str) -> Response: + return Response( + status=403, + body={"error": "csrf", "detail": detail}, + headers={"content-type": "application/json; charset=utf-8"}, + ) + + +def _read_header(scope: Any, name: str) -> str: + h = getattr(scope, "headers", None) + if h is None: + return "" + v = h.get(name, "") or h.get(name.lower(), "") + return str(v or "").strip() + + +def _read_cookie(scope: Any, name: str) -> str: + h = getattr(scope, "headers", None) + if h is None: + return "" + raw = str(h.get("cookie", "") or h.get("Cookie", "") or "") + for part in raw.split(";"): + part = part.strip() + mo = _COOKIE_PAIR.match(part) + if not mo: + continue + if mo.group(1).strip() == name: + return mo.group(2).strip().strip('"') + return "" + + +def csrf_layer(config: CSRFConfig) -> _Middleware: + """ + A ``(scope, protocol)`` callback suitable for ``chain=`` in :func:`apply_cors` or + for composing other pre-route hooks: runs CSRF :meth:`CSRFConfig.guard` and returns + a response or ``None``. + """ + + def _mw(scope: Any, _protocol: Any) -> Response | None: + return config.guard(scope) + + return _mw + + +def apply_csrf( + app: App, + config: CSRFConfig, + *, + chain: _Middleware | None = None, +) -> None: + """ + Installs **one** :meth:`oxyroute.app.App.set_middleware` that runs :meth:`CSRFConfig.guard` + first, then ``chain`` (if any), then continues routing. Replaces any previous + pre-route callback — combine manually or use :func:`csrf_layer` inside + :func:`oxyroute.cors.apply_cors`. + """ + + def _mw(scope: Any, protocol: Any) -> Response | None: + d = config.guard(scope) + if d is not None: + return d + if chain is not None: + return chain(scope, protocol) + return None + + app.set_middleware(_mw) diff --git a/tests/test_csrf.py b/tests/test_csrf.py new file mode 100644 index 0000000..b9372b9 --- /dev/null +++ b/tests/test_csrf.py @@ -0,0 +1,80 @@ +"""CSRF double-submit (issue #53).""" + +from __future__ import annotations + +import asyncio +import json + +import httpx +from oxyroute import App +from oxyroute.csrf import CSRFConfig, apply_csrf + +_HDR = "X-CSRF-Token" +CK = "oxyroute_csrf" +TOK = "known-test-token" # not secret strength; pre-route compare only + + +def test_csrf_mismatch_403() -> None: + cfg = CSRFConfig(cookie_name=CK, header_name=_HDR) + app = App() + apply_csrf(app, cfg) + + @app.post("/p") + def _p() -> str: + return "ok" + + async def _run() -> None: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + r = await c.post( + "/p", + headers={_HDR: "wrong", "Cookie": f"{CK}={TOK}"}, + ) + assert r.status_code == 403, r.text + j = json.loads(r.text) + assert j.get("error") == "csrf" + assert j.get("detail") == "mismatch" + + asyncio.run(_run()) + + +def test_csrf_missing_403() -> None: + cfg = CSRFConfig(cookie_name=CK, header_name=_HDR) + app = App() + apply_csrf(app, cfg) + + @app.post("/p") + def _p2() -> str: + return "nope" + + async def _run() -> None: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + r = await c.post("/p") + assert r.status_code == 403 + j = json.loads(r.text) + assert j.get("detail") == "missing" + + asyncio.run(_run()) + + +def test_csrf_ok_when_cookie_and_header_match() -> None: + cfg = CSRFConfig(cookie_name=CK, header_name=_HDR) + app = App() + apply_csrf(app, cfg) + + @app.post("/p") + def _p3() -> str: + return "yes" + + async def _run() -> None: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + r = await c.post( + "/p", + headers={_HDR: TOK, "Cookie": f"{CK}={TOK}"}, + ) + assert r.status_code == 200 + assert r.text == "yes" + + asyncio.run(_run())