Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion .cursor/rules/git-workflow.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,27 @@ 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 <path>`) and re-stage by scope before committing.

7. **PR into `dev`**
- `git push -u origin <branch>`
- Open PR: **base = `dev`**, compare = your branch. Use **`Closes #N`** (or `Fixes #N`) when the PR completes issue **N**.

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 <commit>`
- 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.
Expand Down
4 changes: 2 additions & 2 deletions .github/ISSUE_BACKLOG/PRIORITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)
42 changes: 42 additions & 0 deletions docs/csrf.md
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 2 additions & 2 deletions docs/feature.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand Down Expand Up @@ -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)) — **сделано**

---
Expand Down
3 changes: 2 additions & 1 deletion docs/handlers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()` |
Expand Down
4 changes: 4 additions & 0 deletions oxyroute/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
145 changes: 145 additions & 0 deletions oxyroute/csrf.py
Original file line number Diff line number Diff line change
@@ -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)
80 changes: 80 additions & 0 deletions tests/test_csrf.py
Original file line number Diff line number Diff line change
@@ -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())
Loading