From 4e579a3df50dfed407ecc0de2a370b4e6c647deb Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Apr 2026 00:21:13 +0300 Subject: [PATCH 1/2] feat: SecurityHeadersConfig and set_security_headers (Closes #54) - Rust: state.security_headers, set_security_headers, merge if-absent then CORS replace - oxyroute.security_headers: HSTS on https only, optional CSP/Permissions-Policy, extra - tests, docs/security-headers.md, backlog; App.set_security_headers --- .github/ISSUE_BACKLOG/PRIORITIES.md | 4 +- docs/feature.md | 4 +- docs/handlers.md | 3 +- docs/index.md | 1 + docs/security-headers.md | 47 +++++++++++++++ oxyroute/__init__.py | 2 + oxyroute/app.py | 7 +++ oxyroute/security_headers.py | 49 ++++++++++++++++ src/dispatch.rs | 88 ++++++++++++++++++++++++++--- src/lib.rs | 8 +++ src/state.rs | 4 ++ tests/test_security_headers.py | 78 +++++++++++++++++++++++++ 12 files changed, 283 insertions(+), 12 deletions(-) create mode 100644 docs/security-headers.md create mode 100644 oxyroute/security_headers.py create mode 100644 tests/test_security_headers.py diff --git a/.github/ISSUE_BACKLOG/PRIORITIES.md b/.github/ISSUE_BACKLOG/PRIORITIES.md index 29afec7..91d0f9b 100644 --- a/.github/ISSUE_BACKLOG/PRIORITIES.md +++ b/.github/ISSUE_BACKLOG/PRIORITIES.md @@ -16,7 +16,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). | -| 29 | [29.md](bodies/29.md) | **Security headers preset** — [GitHub #54](https://github.com/QueryaHub/OxyRoute/issues/54). | ## Research / heavier (may slip past 0.2.0) @@ -36,11 +35,12 @@ This file tracks **priority tiers** for items in [bodies/](bodies/). The **next - **HTTPException:** 23 / [#48](https://github.com/QueryaHub/OxyRoute/issues/48) — `oxyroute.exceptions`, `docs/handlers.md` (per-type `register_exception_handler` not in scope) - **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` ## Roadmap phasing (summary) 1. **P0:** 4, 17; **18** / **#47 (form)** done (order flexible). -2. **P1:** 8, 53, 54; **#48** / **#46** / **#49 (CORS)** done; 9 as polish. +2. **P1:** 8, 53; **#48** / **#46** / **#49 (CORS)** / **#54 (security headers)** done; 9 as polish. 3. **Research:** 50, 51, 52 — as capacity allows. [← Back to README](README.md) diff --git a/docs/feature.md b/docs/feature.md index fb95762..1028121 100644 --- a/docs/feature.md +++ b/docs/feature.md @@ -59,7 +59,7 @@ | **CORS** | `CORSConfig` + `apply_cors` / `set_cors` | Preflight и заголовки на ответах; см. [cors.md](cors.md). | | **CSRF** | Нет | Для cookie-сессий и форм часто нужны токены. | | **Rate limiting** | Нет | | -| **Security headers** (HSTS, CSP, …) | Ручные заголовки | Нет пресетов. | +| **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). | --- @@ -128,7 +128,7 @@ OxyRoute осознанно **уже** в нише: **быстрый маршр - [#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)) -- [#54](https://github.com/QueryaHub/OxyRoute/issues/54) — security headers ([`29.md`](../.github/ISSUE_BACKLOG/bodies/29.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 7b20b98..7b4342d 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 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)). ## See also @@ -95,3 +95,4 @@ For a configurable **`allow_origins` / `allow_methods` / `allow_headers`** flow - [JWT](jwt.md) - [Dependencies](dependencies.md) - [CORS](cors.md) +- [Security headers](security-headers.md) diff --git a/docs/index.md b/docs/index.md index 71a5047..d0aceb3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -42,6 +42,7 @@ Granian still invokes a Python `App` object; the “win” is doing routing, bod | [Routing](routing.md) | Path patterns, methods, 404s | | [Handlers](handlers.md) | Injected parameters, return types, JSON encoding | | [CORS](cors.md) | `CORSConfig`, preflight, `apply_cors` | +| [Security headers](security-headers.md) | `SecurityHeadersConfig`, HSTS, CSP | | [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/docs/security-headers.md b/docs/security-headers.md new file mode 100644 index 0000000..ebddff6 --- /dev/null +++ b/docs/security-headers.md @@ -0,0 +1,47 @@ +# Security headers (preset) + +[← Documentation index](index.md) + +A small preset reduces mistakes when hardening **browser-facing** HTTP responses. OxyRoute merges headers after your handler runs (and before CORS, when configured), **only for names that are not already set** on the response, so you can still override a single value with :class:`oxyroute.response.Response` headers. + +## Usage + +```python +from oxyroute import App, SecurityHeadersConfig + +app = App() +app.set_security_headers( + SecurityHeadersConfig( + hsts="max-age=31536000; includeSubDomains", + content_security_policy="default-src 'none'; frame-ancestors 'none'; base-uri 'none'", + ) +) +``` + +`set_security_headers(None)` disables the preset. + +## HSTS and HTTPS + +`Strict-Transport-Security` is emitted **only** when the RSGI `scope.scheme` is `https`. Use it in production where TLS terminates (or the reverse proxy sets the forwarded scheme the server sees as `https`). **Do not** send a long `max-age` over plain `http` in dev — some browsers will cache HSTS and keep forcing HTTPS, which is painful for local work. + +**Staging** behind the same host name as production: a low `max-age` or a separate hostname avoids baking short-lived dev certs into long-lived HSTS on real domains. + +## Fields + +| Field | Default | Header | +|--------|---------|--------| +| `hsts` | `None` | `Strict-Transport-Security` (only if `https`) | +| `x_content_type_options` | `nosniff` | `X-Content-Type-Options` | +| `x_frame_options` | `DENY` | `X-Frame-Options` | +| `referrer_policy` | `strict-origin-when-cross-origin` | `Referrer-Policy` | +| `content_security_policy` | `None` | `Content-Security-Policy` (usually app-specific) | +| `permissions_policy` | `None` | `Permissions-Policy` | +| `extra` | `{}` | Additional `str` → `str` pairs | + +CORS, when set via :func:`oxyroute.cors.apply_cors` or :meth:`oxyroute.app.App.set_cors`, is merged **after** this preset and may replace the same header name (for `Access-Control-*` there is no overlap in normal use). + +## See also + +- [CORS](cors.md) +- [Handlers](handlers.md) — return types and `Response` headers +- [MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP) and [OWASP](https://owasp.org/) for each header diff --git a/oxyroute/__init__.py b/oxyroute/__init__.py index 1bacc58..01e7969 100644 --- a/oxyroute/__init__.py +++ b/oxyroute/__init__.py @@ -6,6 +6,7 @@ from oxyroute.exceptions import HTTPException from oxyroute.response import Response from oxyroute.router import APIRouter +from oxyroute.security_headers import SecurityHeadersConfig __all__ = [ "APIRouter", @@ -14,6 +15,7 @@ "Depends", "HTTPException", "Response", + "SecurityHeadersConfig", "__version__", "apply_cors", "decode_jwt_hs", diff --git a/oxyroute/app.py b/oxyroute/app.py index 0001104..b171a49 100644 --- a/oxyroute/app.py +++ b/oxyroute/app.py @@ -104,6 +104,13 @@ def set_cors(self, config: Any | None) -> None: """ self._app.set_cors(config) + def set_security_headers(self, config: Any | None) -> None: + """ + Optional preset (e.g. :class:`oxyroute.security_headers.SecurityHeadersConfig`) — merged + into responses when a header of the same name is not already set. Pass ``None`` to disable. + """ + self._app.set_security_headers(config) + def get( self, path: str, diff --git a/oxyroute/security_headers.py b/oxyroute/security_headers.py new file mode 100644 index 0000000..e30dba6 --- /dev/null +++ b/oxyroute/security_headers.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +__all__ = ["SecurityHeadersConfig"] + + +@dataclass +class SecurityHeadersConfig: + """ + Preset for common **browser security** response headers. Register with + :meth:`oxyroute.app.App.set_security_headers`. + + The native layer calls ``response_header_pairs(scope)`` and merges each pair only if a header + with the same name is **not** already present (so explicit :class:`oxyroute.response.Response` + headers win). CORS, when configured, is merged after this preset. + + **HSTS** is added only when ``scope.scheme == "https"``; use **only in production** behind TLS + (or reverse proxy that sets ``X-Forwarded-Proto`` — OxyRoute still uses the RSGI ``scheme``; + in dev over plain HTTP, leave ``hsts`` unset to avoid long-lived HSTS in browsers). + """ + + hsts: str | None = None + x_content_type_options: str | None = "nosniff" + x_frame_options: str | None = "DENY" + referrer_policy: str | None = "strict-origin-when-cross-origin" + content_security_policy: str | None = None + permissions_policy: str | None = None + extra: dict[str, str] = field(default_factory=dict) + + def response_header_pairs(self, scope: Any) -> list[tuple[str, str]]: + out: list[tuple[str, str]] = [] + scheme = str(getattr(scope, "scheme", "") or "").lower() + if self.hsts and scheme == "https": + out.append(("Strict-Transport-Security", self.hsts)) + if self.x_content_type_options: + out.append(("X-Content-Type-Options", self.x_content_type_options)) + if self.x_frame_options: + out.append(("X-Frame-Options", self.x_frame_options)) + if self.referrer_policy: + out.append(("Referrer-Policy", self.referrer_policy)) + if self.content_security_policy: + out.append(("Content-Security-Policy", self.content_security_policy)) + if self.permissions_policy: + out.append(("Permissions-Policy", self.permissions_policy)) + for k, v in self.extra.items(): + out.append((k, v)) + return out diff --git a/src/dispatch.rs b/src/dispatch.rs index 4463f55..9b06817 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -162,6 +162,10 @@ pub async fn run_rsgi( let s = state.read(); s.cors.clone() }); + let security_cfg = Python::with_gil(|_py| { + let s = state.read(); + s.security_headers.clone() + }); if (method == "GET" || method == "HEAD") && path == "/openapi.json" { let (inc, doc) = { let st = state.read(); @@ -210,7 +214,21 @@ pub async fn run_rsgi( } }; let mapped = match Python::with_gil(|py| { - merge_cors_to_handler_map(py, &cors_cfg, scope.bind(py).clone(), mapped) + merge_config_response_headers( + py, + &security_cfg, + scope.bind(py).clone(), + mapped, + true, + ) + }) { + Ok(m) => m, + Err(e) => { + return send_python_error(&protocol, &method, &path, e).await; + } + }; + let mapped = match Python::with_gil(|py| { + merge_config_response_headers(py, &cors_cfg, scope.bind(py).clone(), mapped, false) }) { Ok(m) => m, Err(e) => { @@ -688,7 +706,15 @@ pub async fn run_rsgi( } }; let mapped = match Python::with_gil(|py| { - merge_cors_to_handler_map(py, &cors_cfg, scope.bind(py).clone(), mapped) + merge_config_response_headers(py, &security_cfg, scope.bind(py).clone(), mapped, true) + }) { + Ok(m) => m, + Err(e) => { + return send_python_error(&protocol, &method, &path, e).await; + } + }; + let mapped = match Python::with_gil(|py| { + merge_config_response_headers(py, &cors_cfg, scope.bind(py).clone(), mapped, false) }) { Ok(m) => m, Err(e) => { @@ -811,13 +837,16 @@ async fn send_handler_map( } } -fn merge_cors_to_handler_map( +/// `if_absent`: only add a header if no same-name (case-insensitive) header is already present +/// (``security`` preset). `false` replaces/merges like CORS (``replace`` / duplicate header names). +fn merge_config_response_headers( py: Python<'_>, - cors: &Option>, + config: &Option>, scope: Bound<'_, PyAny>, mapped: HandlerMap, + if_absent: bool, ) -> PyResult { - let Some(c) = cors else { + let Some(c) = config else { return Ok(mapped); }; let pairs: Vec<(String, String)> = c @@ -826,10 +855,14 @@ fn merge_cors_to_handler_map( if pairs.is_empty() { return Ok(mapped); } - Ok(merge_cors(mapped, &pairs)) + if if_absent { + Ok(merge_header_pairs_if_absent(mapped, &pairs)) + } else { + Ok(merge_header_pairs_replace(mapped, &pairs)) + } } -fn merge_cors(mapped: HandlerMap, extra: &[(String, String)]) -> HandlerMap { +fn merge_header_pairs_replace(mapped: HandlerMap, extra: &[(String, String)]) -> HandlerMap { if extra.is_empty() { return mapped; } @@ -868,6 +901,47 @@ fn merge_cors(mapped: HandlerMap, extra: &[(String, String)]) -> HandlerMap { } } +fn merge_header_pairs_if_absent(mapped: HandlerMap, extra: &[(String, String)]) -> HandlerMap { + if extra.is_empty() { + return mapped; + } + match mapped { + HandlerMap::WithHeaders { + status, + body, + mut headers, + } => { + for (a, b) in extra { + if !headers.iter().any(|(k, _)| k.eq_ignore_ascii_case(a)) { + headers.push((a.clone(), b.clone())); + } + } + HandlerMap::WithHeaders { + status, + body, + headers, + } + } + HandlerMap::Simple { + status, + body, + content_type, + } => { + let mut headers = vec![("content-type".to_string(), content_type)]; + for (a, b) in extra { + if !headers.iter().any(|(k, _)| k.eq_ignore_ascii_case(a)) { + headers.push((a.clone(), b.clone())); + } + } + HandlerMap::WithHeaders { + status, + body, + headers, + } + } + } +} + fn is_oxyroute_response(_py: Python<'_>, b: &Bound<'_, PyAny>) -> PyResult { // `oxyroute.Response` (dataclass): not a plain dict; has instance attributes // `status` / `body` / `headers` (and optional `cookies`). Avoid `isinstance` / diff --git a/src/lib.rs b/src/lib.rs index 7d83a6b..5910db8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -324,6 +324,14 @@ impl App { Ok(()) } + /// Optional security-headers preset; ``response_header_pairs(scope)`` (see + /// ``oxyroute.security_headers``), merged only for header names not already on the response. + fn set_security_headers(&self, config: Option>) -> PyResult<()> { + let mut st = self.state.write(); + st.security_headers = config; + Ok(()) + } + fn handle_rsgi<'py>( this: PyRef<'py, Self>, py: Python<'py>, diff --git a/src/state.rs b/src/state.rs index 8016412..bfe9e4b 100644 --- a/src/state.rs +++ b/src/state.rs @@ -78,6 +78,9 @@ pub struct AppState { pub middleware: Option>, /// Optional Python CORS config (e.g. :class:`oxyroute.cors.CORSConfig`) for response headers. pub cors: Option>, + /// Optional :class:`oxyroute.security_headers.SecurityHeadersConfig` (or compatible + /// ``response_header_pairs``) merged if those header names are not already set. + pub security_headers: Option>, } impl AppState { @@ -101,6 +104,7 @@ impl AppState { include_openapi: true, middleware: None, cors: None, + security_headers: None, } } diff --git a/tests/test_security_headers.py b/tests/test_security_headers.py new file mode 100644 index 0000000..f9fe812 --- /dev/null +++ b/tests/test_security_headers.py @@ -0,0 +1,78 @@ +"""Security header preset (issue #54).""" + +from __future__ import annotations + +import asyncio + +import httpx +from oxyroute import App, SecurityHeadersConfig + + +def test_security_headers_merged_on_get() -> None: + app = App() + app.set_security_headers( + SecurityHeadersConfig( + hsts="max-age=60", + x_frame_options="SAMEORIGIN", + ) + ) + + @app.get("/h") + def _h() -> str: + return "x" + + async def _run() -> None: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="https://test") as c: + r = await c.get("/h") + assert r.status_code == 200 + assert (r.headers.get("x-content-type-options") or "") == "nosniff" + assert (r.headers.get("x-frame-options") or "") == "SAMEORIGIN" + assert (r.headers.get("strict-transport-security") or "") == "max-age=60" + assert (r.headers.get("referrer-policy") or "") == "strict-origin-when-cross-origin" + + asyncio.run(_run()) + + +def test_security_headers_hsts_not_on_http() -> None: + app = App() + app.set_security_headers( + SecurityHeadersConfig( + hsts="max-age=60", + ) + ) + + @app.get("/h") + def _h2() -> str: + return "x" + + async def _run() -> None: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + r = await c.get("/h") + assert r.status_code == 200 + assert "strict-transport-security" not in (k.lower() for k in r.headers) + + asyncio.run(_run()) + + +def test_security_headers_does_not_override_response_header() -> None: + from oxyroute import Response + + app = App() + app.set_security_headers(SecurityHeadersConfig(x_frame_options="DENY")) + + @app.get("/h") + def _h3() -> Response: + return Response( + body="a", + headers={"X-Frame-Options": "ALLOWALL"}, + ) + + async def _run() -> None: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + r = await c.get("/h") + assert (r.headers.get("x-frame-options") or "") == "ALLOWALL" + + asyncio.run(_run()) From d763a720e15618d958c2b58e03fc2bc746b6e7fc Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Apr 2026 00:21:51 +0300 Subject: [PATCH 2/2] chore: agent does not run make test / CI suite unless asked --- .cursor/rules/git-workflow.mdc | 8 ++++---- AGENTS.md | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.cursor/rules/git-workflow.mdc b/.cursor/rules/git-workflow.mdc index ba38d27..5390f5e 100644 --- a/.cursor/rules/git-workflow.mdc +++ b/.cursor/rules/git-workflow.mdc @@ -22,10 +22,10 @@ alwaysApply: true 4. **Implement** in the branch (Rust/Python/docs as needed). -5. **Validate before commit/push** (match CI in [`.github/workflows/ci.yml`](.github/workflows/ci.yml)) - - **One-shot (recommended):** `make test` from the repo root — runs `uv` sync, ruff, `cargo fmt`/`clippy`, `maturin develop --uv`, and pytest in a temp directory (see [`Makefile`](Makefile)). - - **By hand (same as CI / `make test`):** `uv run ruff check` / `ruff format --check` on `oxyroute`, `tests`, `examples`; `cargo fmt --all -- --check`; `cargo clippy --all-targets -- -D warnings`; then build + test as in CI. - - **Project owner:** runs `make test` (or equivalent) before push themselves. If something fails locally or in CI, they will report it — then fix the reported problem; do not assume a green run on the agent side unless you actually executed the checks in this environment. +5. **Validate before commit/push** (match CI in [`.github/workflows/ci.yml`](.github/workflows/ci.yml)) — **humans run this; the agent does not** run ruff/cargo/`make test`/pytest/maturin unless the user **explicitly** asks. + - **One-shot (recommended for people):** `make test` from the repo root — `uv` sync, ruff, `cargo fmt`/`clippy`, `maturin develop --uv`, pytest in a temp dir (see [`Makefile`](Makefile)). + - **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. diff --git a/AGENTS.md b/AGENTS.md index e2f958e..3038ce7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ # Notes for AI agents (OxyRoute) - **Git / branches / PRs / validation** — [`.cursor/rules/git-workflow.mdc`](.cursor/rules/git-workflow.mdc) (`alwaysApply`). -- **Local full check before push:** from repo root run `make test` (see [`Makefile`](Makefile)). Maintainers run this themselves before pushing; if a check fails, they will report it — then address that failure, rather than assuming everything passed without a run in your session. +- **Validation (ruff, cargo, `make test`, pytest, maturin):** the **maintainer runs these** before push. **Do not** run that full pipeline in the agent unless the user explicitly asks (e.g. “run `make test`”, “fix the failing test”). If something failed in CI or locally, the user will say so — then fix what they report.