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
8 changes: 4 additions & 4 deletions .cursor/rules/git-workflow.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -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.

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 @@ -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)

Expand All @@ -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)
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions docs/feature.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |

---
Expand Down Expand Up @@ -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)) — **сделано**

---

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

- [Routing](routing.md)
- [JWT](jwt.md)
- [Dependencies](dependencies.md)
- [CORS](cors.md)
- [Security headers](security-headers.md)
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()` |
Expand Down
47 changes: 47 additions & 0 deletions docs/security-headers.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions oxyroute/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -14,6 +15,7 @@
"Depends",
"HTTPException",
"Response",
"SecurityHeadersConfig",
"__version__",
"apply_cors",
"decode_jwt_hs",
Expand Down
7 changes: 7 additions & 0 deletions oxyroute/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
49 changes: 49 additions & 0 deletions oxyroute/security_headers.py
Original file line number Diff line number Diff line change
@@ -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
88 changes: 81 additions & 7 deletions src/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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<Py<PyAny>>,
config: &Option<Py<PyAny>>,
scope: Bound<'_, PyAny>,
mapped: HandlerMap,
if_absent: bool,
) -> PyResult<HandlerMap> {
let Some(c) = cors else {
let Some(c) = config else {
return Ok(mapped);
};
let pairs: Vec<(String, String)> = c
Expand All @@ -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;
}
Expand Down Expand Up @@ -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<bool> {
// `oxyroute.Response` (dataclass): not a plain dict; has instance attributes
// `status` / `body` / `headers` (and optional `cookies`). Avoid `isinstance` /
Expand Down
8 changes: 8 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Py<PyAny>>) -> PyResult<()> {
let mut st = self.state.write();
st.security_headers = config;
Ok(())
}

fn handle_rsgi<'py>(
this: PyRef<'py, Self>,
py: Python<'py>,
Expand Down
4 changes: 4 additions & 0 deletions src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ pub struct AppState {
pub middleware: Option<Py<PyAny>>,
/// Optional Python CORS config (e.g. :class:`oxyroute.cors.CORSConfig`) for response headers.
pub cors: Option<Py<PyAny>>,
/// Optional :class:`oxyroute.security_headers.SecurityHeadersConfig` (or compatible
/// ``response_header_pairs``) merged if those header names are not already set.
pub security_headers: Option<Py<PyAny>>,
}

impl AppState {
Expand All @@ -101,6 +104,7 @@ impl AppState {
include_openapi: true,
middleware: None,
cors: None,
security_headers: None,
}
}

Expand Down
Loading
Loading