diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4d35c13..e9e8a29 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -15,15 +15,23 @@ jobs: - "3.12" - "3.13" - "3.14-dev" + steps: - uses: actions/checkout@v4 + - name: Set up Python uses: actions/setup-python@v5 with: - python-version: ${{ matrix.python }} + python-version: ${{ matrix.python }} + - name: Install toolchain - run: pip install ruff + run: pip install ruff==0.13.2 pytest + + - name: Install package + run: pip install -e . + - name: Unit tests - run: python -m unittest tests/*/*.py + run: python -m pytest + - name: Lint run: ruff check secure tests diff --git a/CHANGELOG.md b/CHANGELOG.md index 4475853..2af07d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Placeholder for upcoming changes. -## [2.0.0] - 2025-12-13 +## [2.0.1] - 2026-04-21 + +This is the first stable v2 release. Version `2.0.0` was burned and should be skipped when tagging or publishing. ### Breaking Changes @@ -27,7 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Docs -- Expanded README with usage examples, advanced pipeline guidance, and updated framework integration references. +- Expanded README with usage examples, advanced pipeline guidance, updated framework integration references, and v2 migration guidance. ## [1.0.1] - 2024-10-18 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e9a81fe..78e0d39 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,13 +15,13 @@ Thanks for helping make `secure` better. The following guidance keeps contributi ``` 3. Install the tooling used by the project: ```bash - pip install ruff + pip install pytest ruff ``` - _Optional:_ `uv` is the package manager used by the project for releases; you can use `uv add ...` to manage dependencies, but it is not required for local development. + _Optional:_ `uv` is the package manager used by the project for releases; you can use `uv run pytest` and `uv add ...` to manage dependencies, but it is not required for local development. ## Running tests, linting, and formatting -- **Run unit tests:** `python -m unittest tests/*/*.py` +- **Run unit tests:** `pytest` - **Run the linter:** `ruff check` - **Apply formatting / fix issues:** `ruff format` @@ -57,7 +57,7 @@ Run these commands before opening a pull request. If you rely on a different Pyt ## Pull request checklist -- [ ] I have run `python -m unittest tests/*/*.py` locally (or a representative suite) and addressed any failures. +- [ ] I have run `pytest` locally (or a representative suite) and addressed any failures. - [ ] I have run `ruff check` and `ruff format` (when formatting attr). - [ ] Documentation updates describe the new behavior (new header docs, framework guidance, etc.). - [ ] If applicable, I have updated the release notes/CHANGELOG entry for new user-visible behavior. diff --git a/README.md b/README.md index 9557170..0f0d115 100644 --- a/README.md +++ b/README.md @@ -1,475 +1,145 @@ # secure -A small, focused library for adding modern security headers to Python web applications. +HTTP security headers for Python web applications, centered on one object: `Secure`. [![PyPI Version](https://img.shields.io/pypi/v/secure.svg)](https://pypi.org/project/secure/) [![Python Versions](https://img.shields.io/pypi/pyversions/secure.svg)](https://pypi.org/project/secure/) [![License](https://img.shields.io/pypi/l/secure.svg)](https://github.com/TypeError/secure/blob/main/LICENSE) [![GitHub Stars](https://img.shields.io/github/stars/TypeError/secure.svg)](https://github.com/TypeError/secure/stargazers) ---- +`secure` exists to keep header policy out of ad hoc view code. Instead of copying header strings into routes, middleware, and framework-specific hooks, you configure one `Secure` instance and apply it consistently. -## Introduction +Hand-written header code tends to drift. Headers get missed, defaults vary between apps, and sync or async framework details leak into otherwise simple code. `secure` gives you a small public API, opinionated presets, and typed builders when you need to go beyond the defaults. -Security headers are one of the simplest ways to raise the security bar for a web application, but they are often applied inconsistently across frameworks and deployments. +## Install -`secure` gives you a single, modern, well typed API for configuring and applying HTTP security headers in Python. It focuses on: - -- Good defaults that are safe to adopt. -- A small, explicit API instead of a large framework. -- Support for both synchronous and asynchronous response objects. -- Framework agnostic integration so you can use the same configuration everywhere. - -The package is published on PyPI as `secure` and imported with: - -```python -import secure -``` - ---- - -## Why use `secure` - -- Apply essential security headers with a few lines of code. -- Share one configuration across multiple frameworks and applications. -- Start from secure presets, then customize as your needs grow. -- Keep header logic out of your views and handlers. -- Use one library for FastAPI, Starlette, Flask, Django, and more. -- Rely on modern Python 3.10+ features and full type hints for better editor support. - -If you want your app to ship with a strong security baseline without pulling in a heavyweight dependency, `secure` is designed for you. - ---- - -## Supported frameworks - -`secure` integrates with a range of popular Python web frameworks. The core API is framework independent, and each framework uses the same `Secure` object and methods. - -| Framework | Documentation | -| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------ | -| [aiohttp](https://docs.aiohttp.org) | [Integration Guide](https://github.com/TypeError/secure/blob/main/docs/frameworks.md#aiohttp) | -| [Bottle](https://bottlepy.org) | [Integration Guide](https://github.com/TypeError/secure/blob/main/docs/frameworks.md#bottle) | -| [CherryPy](https://cherrypy.dev/) | [Integration Guide](https://github.com/TypeError/secure/blob/main/docs/frameworks.md#cherrypy) | -| [Dash](https://dash.plotly.com/) | [Integration Guide](https://github.com/TypeError/secure/blob/main/docs/frameworks.md#dash) | -| [Django](https://www.djangoproject.com) | [Integration Guide](https://github.com/TypeError/secure/blob/main/docs/frameworks.md#django) | -| [Falcon](https://falconframework.org) | [Integration Guide](https://github.com/TypeError/secure/blob/main/docs/frameworks.md#falcon) | -| [FastAPI](https://fastapi.tiangolo.com) | [Integration Guide](https://github.com/TypeError/secure/blob/main/docs/frameworks.md#fastapi) | -| [Flask](http://flask.pocoo.org) | [Integration Guide](https://github.com/TypeError/secure/blob/main/docs/frameworks.md#flask) | -| [Masonite](https://docs.masoniteproject.com/) | [Integration Guide](https://github.com/TypeError/secure/blob/main/docs/frameworks.md#masonite) | -| [Morepath](https://morepath.readthedocs.io) | [Integration Guide](https://github.com/TypeError/secure/blob/main/docs/frameworks.md#morepath) | -| [Pyramid](https://trypyramid.com) | [Integration Guide](https://github.com/TypeError/secure/blob/main/docs/frameworks.md#pyramid) | -| [Quart](https://quart.palletsprojects.com/en/latest/) | [Integration Guide](https://github.com/TypeError/secure/blob/main/docs/frameworks.md#quart) | -| [Responder](https://responder.kennethreitz.org/) | [Integration Guide](https://github.com/TypeError/secure/blob/main/docs/frameworks.md#responder) | -| [Sanic](https://sanicframework.org) | [Integration Guide](https://github.com/TypeError/secure/blob/main/docs/frameworks.md#sanic) | -| [Shiny](https://shiny.posit.co/py/) | [Integration Guide](https://github.com/TypeError/secure/blob/main/docs/frameworks.md#shiny) | -| [Starlette](https://www.starlette.io/) | [Integration Guide](https://github.com/TypeError/secure/blob/main/docs/frameworks.md#starlette) | -| [Tornado](https://www.tornadoweb.org/) | [Integration Guide](https://github.com/TypeError/secure/blob/main/docs/frameworks.md#tornado) | -| [TurboGears](https://turbogears.org/) | [Integration Guide](https://github.com/TypeError/secure/blob/main/docs/frameworks.md#turbogears) | - ---- - -## Features - -- **Secure headers** - Apply headers like `Strict-Transport-Security`, `Content-Security-Policy`, `X-Content-Type-Options`, `X-Frame-Options`, and more. - -- **Presets with secure defaults** - Start from opinionated presets like `Preset.BASIC` and `Preset.STRICT`, then customize as needed. - -- **Policy builders** - Compose complex policies such as CSP and Permissions Policy through a fluent API. - -- **Framework agnostic** - Works with sync and async response objects and does not depend on any single framework. - -- **Zero external dependencies** - Easy to audit and suitable for security sensitive environments. - -- **Modern Python design** - Uses Python 3.10+ features and full type hints so your editor and type checker can help you. - ---- - -## Requirements - -- **Python 3.10 or higher** - - `secure` targets modern Python and is currently tested on Python 3.10 through 3.13. - - It uses features introduced in Python 3.10, including: - - - Union type operator (`|`) for cleaner type annotations. - - Structural pattern matching (`match`). - - Improved typing and annotations. - - `functools.cached_property` for efficient lazy computation. - - If you need support for Python 3.6 through 3.9, use version `0.3.0` of the library. - -- **Dependencies** - - This library has no external dependencies outside of the Python standard library. - ---- - -## Installation - -You can install `secure` with your preferred Python package manager. - -### Using `uv` +`secure` requires Python 3.10+ and has no external dependencies. ```bash uv add secure ``` -### Using `pip` - ```bash pip install secure ``` ---- - ## Quick start -The core entry point is the `Secure` class. A typical simple setup looks like this: +Start with `Secure.with_default_headers()`. It uses `Preset.BALANCED`, the recommended default for most applications. ```python -import secure - -secure_headers = secure.Secure.with_default_headers() +from secure import Secure -# For a synchronous framework -secure_headers.set_headers(response) +class Response: + def __init__(self): + self.headers = {} -# For an asynchronous framework -await secure_headers.set_headers_async(response) +response = Response() +Secure.with_default_headers().set_headers(response) ``` -`Secure.with_default_headers()` is equivalent to `Secure.from_preset(Preset.BALANCED)`, the recommended default profile. +`Secure` applies headers to response objects that expose either: -`set_headers` and `set_headers_async` both operate on a response object that either: +- `response.set_header(name, value)` +- `response.headers[name] = value` -- Exposes a `set_header(name, value)` method, or -- Exposes a mutable `headers` mapping that supports item assignment. +The quick start uses the `response.headers[name] = value` form. -If your framework uses a different contract, see the framework specific guides or use `header_items()` to apply headers manually. +Use `set_headers()` for synchronous response objects. Use `set_headers_async()` in async code or when the response object may use async setters. -## Middleware - -`secure.middleware` re-exports `SecureWSGIMiddleware` and `SecureASGIMiddleware`. Each middleware accepts a `Secure` instance (defaulting to `Secure.with_default_headers()`), overwrites headers by default, and only appends duplicates when a normalized name is included in `multi_ok` (the default `secure.MULTI_OK` includes `Content-Security-Policy`). - -### WSGI (Flask + Django) +## Presets -Wrap any WSGI stack with `SecureWSGIMiddleware`, and pass a configured `Secure` instance if you need a custom CSP or additional headers. +Most applications should start with `BALANCED`. ```python -from flask import Flask -from secure import Secure -from secure.middleware import SecureWSGIMiddleware +from secure import Preset, Secure -secure_headers = Secure.with_default_headers() -app = Flask(__name__) -app.wsgi_app = SecureWSGIMiddleware(app.wsgi_app, secure=secure_headers) +balanced = Secure.from_preset(Preset.BALANCED) +basic = Secure.from_preset(Preset.BASIC) +strict = Secure.from_preset(Preset.STRICT) ``` -For Django, apply the headers through a middleware class since Django’s middleware pipeline wraps requests and responses rather than the raw WSGI callable: +- `Preset.BALANCED`: recommended default. Modern baseline with CSP, HSTS, referrer policy, permissions policy, and common browser protections. +- `Preset.BASIC`: compatibility-oriented. Adds legacy and interoperability headers that some deployments still expect. +- `Preset.STRICT`: hardened profile. Tightens CSP, disables caching, and denies framing. -```python -from secure import Secure +Choose `BALANCED` unless you have a specific reason to prefer `BASIC` or `STRICT`. -class SecureHeadersMiddleware: - def __init__(self, get_response): - self.get_response = get_response - self.secure = Secure.with_default_headers() - - def __call__(self, request): - response = self.get_response(request) - self.secure.set_headers(response) - return response -``` - -Register the class in your `MIDDLEWARE` setting to enforce security headers on every response. +## Middleware -### ASGI (FastAPI + Shiny for Python) +If your framework supports app-wide middleware, prefer that over setting headers one response at a time. -`SecureASGIMiddleware` modifies only HTTP scopes (WebSocket messages pass through untouched). Mount it manually or via FastAPI’s `add_middleware`, and pass any `Secure` instance if you need to adjust the defaults. +### WSGI ```python -from fastapi import FastAPI +from flask import Flask from secure import Secure -from secure.middleware import SecureASGIMiddleware +from secure.middleware import SecureWSGIMiddleware +app = Flask(__name__) secure_headers = Secure.with_default_headers() -app = FastAPI() -app.add_middleware(SecureASGIMiddleware, secure=secure_headers) -``` - -If you need to tailor the CSP, build a custom `Secure` instance before wiring the middleware: -```python -from secure import ContentSecurityPolicy - -secure_headers = Secure( - csp=ContentSecurityPolicy().default_src("'self'").script_src("https://trusted.cdn") -) -app = SecureASGIMiddleware(app, secure=secure_headers) +app.wsgi_app = SecureWSGIMiddleware(app.wsgi_app, secure=secure_headers) ``` -Shiny for Python apps can be wrapped in the same way: +### ASGI ```python -from shiny import App +from fastapi import FastAPI from secure import Secure from secure.middleware import SecureASGIMiddleware +app = FastAPI() secure_headers = Secure.with_default_headers() -app = SecureASGIMiddleware(App(), secure=secure_headers) -``` - -### Customizing `multi_ok` - -Pass the `multi_ok` argument to either middleware to append additional occurrences of headers that must appear multiple times (for example, when downstream code already emits a `Content-Security-Policy` line). - ---- -## Default secure headers - -When you call `Secure.with_default_headers()` (or `Secure.from_preset(Preset.BALANCED)`), `secure` configures the recommended defaults that balance security and usability: - -```http -Cross-Origin-Opener-Policy: same-origin -Cross-Origin-Resource-Policy: same-origin -Content-Security-Policy: default-src 'self'; base-uri 'self'; font-src 'self' https: data:; form-action 'self'; frame-ancestors 'self'; img-src 'self' data:; object-src 'none'; script-src 'self'; script-src-attr 'none'; style-src 'self' https: 'unsafe-inline'; upgrade-insecure-requests -Strict-Transport-Security: max-age=31536000; includeSubDomains -Permissions-Policy: geolocation=(), microphone=(), camera=() -Referrer-Policy: strict-origin-when-cross-origin -Server: -X-Content-Type-Options: nosniff -X-Frame-Options: SAMEORIGIN +app.add_middleware(SecureASGIMiddleware, secure=secure_headers) ``` -These defaults limit cross origin data leaks, mitigate clickjacking and MIME sniffing, and enforce a conservative Content Security Policy you can extend later. Balanced omits `Cache-Control` as well as the legacy/compatibility headers (`X-Permitted-Cross-Domain-Policies`, `X-DNS-Prefetch-Control`, `Origin-Agent-Cluster`, `X-Download-Options`, `X-XSS-Protection`), so add them manually if your deployment still depends on them. +Use `SecureWSGIMiddleware` when you can wrap a WSGI app directly. Use `SecureASGIMiddleware` when you want app-wide coverage in an ASGI stack such as FastAPI, Starlette, or Shiny. ---- +## Advanced usage -## Presets +Most applications can stop at a preset. When you need to tune a specific header, keep `Secure` as the entry point and pass builder objects into it. -If you prefer to think in terms of profiles instead of individual headers, `secure` provides presets via the `Preset` enum and `Secure.from_preset`. +### Custom policy ```python -from secure import Preset, Secure - -# Recommended defaults for most applications -balanced_headers = Secure.from_preset(Preset.BALANCED) - -# Helmet-parity defaults for compatibility-focused setups -basic_headers = Secure.from_preset(Preset.BASIC) - -# Hardened defaults for security-focused deployments -strict_headers = Secure.from_preset(Preset.STRICT) -``` - -### BALANCED preset - -The `BALANCED` preset is the new recommended default and matches `Secure.with_default_headers()`. It balances security with compatibility while keeping response headers relatively tight: - -```http -Cross-Origin-Opener-Policy: same-origin -Cross-Origin-Resource-Policy: same-origin -Content-Security-Policy: default-src 'self'; base-uri 'self'; font-src 'self' https: data:; form-action 'self'; frame-ancestors 'self'; img-src 'self' data:; object-src 'none'; script-src 'self'; script-src-attr 'none'; style-src 'self' https: 'unsafe-inline'; upgrade-insecure-requests -Strict-Transport-Security: max-age=31536000; includeSubDomains -Permissions-Policy: geolocation=(), microphone=(), camera=() -Referrer-Policy: strict-origin-when-cross-origin -Server: -X-Content-Type-Options: nosniff -X-Frame-Options: SAMEORIGIN -``` - -Balanced omits `Cache-Control` and the legacy/resource headers included by `Preset.BASIC`, but you can still add them manually if your deployment relies on them. - -### BASIC preset - -The `BASIC` preset matches Helmet.js defaults and ships with a broader compatibility-focused header set. It is useful when you require the same collection of headers Helmet enables out of the box: - -```http -Cross-Origin-Opener-Policy: same-origin -Cross-Origin-Resource-Policy: same-origin -Content-Security-Policy: default-src 'self'; base-uri 'self'; font-src 'self' https: data:; form-action 'self'; frame-ancestors 'self'; img-src 'self' data:; object-src 'none'; script-src 'self'; script-src-attr 'none'; style-src 'self' https: 'unsafe-inline'; upgrade-insecure-requests -Strict-Transport-Security: max-age=31536000; includeSubDomains -Referrer-Policy: no-referrer -X-Permitted-Cross-Domain-Policies: none -X-DNS-Prefetch-Control: off -X-Content-Type-Options: nosniff -X-Frame-Options: SAMEORIGIN -Origin-Agent-Cluster: ?1 -X-Download-Options: noopen -X-XSS-Protection: 0 -``` +from secure import ContentSecurityPolicy, Secure, StrictTransportSecurity -This preset still avoids `Cache-Control` and `Server` but includes the extra headers that Helmet adds for historical/compatibility reasons. - -### STRICT preset - -The `STRICT` preset enables stronger protections and is a better fit for security focused deployments that can tolerate tighter restrictions. It is conceptually similar to: - -```http -Cache-Control: no-store, max-age=0 -Cross-Origin-Embedder-Policy: require-corp -Cross-Origin-Opener-Policy: same-origin -Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none' -Strict-Transport-Security: max-age=63072000; includeSubDomains -Permissions-Policy: geolocation=(), microphone=(), camera=() -Referrer-Policy: no-referrer -Server: -X-Content-Type-Options: nosniff -X-Frame-Options: DENY -``` - -Start with `BALANCED` and move to `STRICT` once you have validated that your application works correctly with the stricter Content Security Policy, caching, and frame restrictions. `STRICT` no longer sets HSTS preload by default, so you can opt-in separately when you are ready. - ---- - -## Policy builders - -`secure` lets you build rich header values through small, focused builder classes. Two common examples are `ContentSecurityPolicy` and `PermissionsPolicy`. - -### Content Security Policy - -```python -from secure import Secure -from secure.headers import ContentSecurityPolicy - -csp = ( - ContentSecurityPolicy() - .default_src("'self'") - .script_src("'self'", "cdn.typeerror.com") - .style_src("'unsafe-inline'") - .img_src("'self'", "images.typeerror.com") - .connect_src("'self'", "api.typeerror.com") +secure_headers = Secure( + csp=( + ContentSecurityPolicy() + .default_src("'self'") + .img_src("'self'", "https://images.example.com") + .script_src("'self'", "https://cdn.example.com") + ), + hsts=StrictTransportSecurity().max_age(63072000).include_subdomains(), ) - -secure_headers = Secure(csp=csp) -``` - -Resulting header: - -```http -Content-Security-Policy: default-src 'self'; script-src 'self' cdn.typeerror.com; style-src 'unsafe-inline'; img-src 'self' images.typeerror.com; connect-src 'self' api.typeerror.com ``` -You can treat the CSP builder as a safe string builder for CSP directives and keep all CSP logic in one place. +### Optional validation -### Permissions Policy +The validation pipeline is optional. Use it when headers are being composed dynamically and you want stricter checks before emission. ```python from secure import Secure -from secure.headers import PermissionsPolicy - -permissions = ( - PermissionsPolicy().geolocation("'self'").camera("'none'").microphone("'none'") -) - -secure_headers = Secure(permissions=permissions) -``` - -Resulting header: - -```http -Permissions-Policy: geolocation=(self), camera=(), microphone=() -``` - -Other headers, such as `StrictTransportSecurity`, `CrossOriginOpenerPolicy`, `CrossOriginEmbedderPolicy`, `ReferrerPolicy`, `Server`, and `XFrameOptions`, also have small builder classes that mirror their directive structure. - ---- - -## Advanced usage: header pipeline and validation - -For most applications, it is enough to construct a `Secure` instance and call `set_headers` or `set_headers_async`. If you want stronger guarantees and clearer failure modes, you can run headers through an explicit pipeline. - -```python -import logging - -from secure import COMMA_JOIN_OK, DEFAULT_ALLOWED_HEADERS, MULTI_OK, Secure - -logger = logging.getLogger("secure") secure_headers = ( Secure.with_default_headers() - .allowlist_headers( - allowed=DEFAULT_ALLOWED_HEADERS, - allow_extra=["X-My-App-Header"], - on_unexpected="warn", # "raise" (default), "drop", or "warn" - allow_x_prefixed=False, - logger=logger, - ) - .deduplicate_headers( - action="raise", # "raise" (default), "first", "last", or "concat" - comma_join_ok=COMMA_JOIN_OK, - multi_ok=MULTI_OK, - logger=logger, - ) - .validate_and_normalize_headers( - on_invalid="drop", # "drop" (default), "warn", or "raise" - strict=False, - allow_obs_text=False, - logger=logger, - ) + .allowlist_headers() + .deduplicate_headers() + .validate_and_normalize_headers() ) ``` -Key ideas: - -- `allowlist_headers` enforces a case insensitive allowlist of header names and decides what to do with unexpected headers. -- `deduplicate_headers` resolves repeated header names so that you end up with clean `name, value` pairs. -- `validate_and_normalize_headers` validates header names and values, then freezes them into a single valued, immutable mapping exposed via the `.headers` property. -- After the pipeline runs through `validate_and_normalize_headers()`, `Secure` uses the normalized `.headers` mapping when `set_headers` or `set_headers_async` apply the headers, ensuring dropped entries never reach the wire and sanitized values replace unsafe input. - -If you need to emit multi valued headers, such as multiple `Set-Cookie` fields, you can bypass the single valued mapping and work with `header_items()` directly: - -```python -for name, value in secure_headers.header_items(): - response.headers.add(name, value) -``` - -This pipeline gives you a repeatable, testable flow for going from high level policy objects to concrete headers on the wire. - ---- +If you need manual emission for an unsupported response contract, iterate over `secure_headers.header_items()`. ## Framework examples -Below are simple examples for a synchronous and an asynchronous framework. See the framework specific guides for more detailed patterns. - -### Shiny for Python - -#### Recommended: ASGI middleware wrapper - -Wraps the Shiny ASGI application and injects headers by intercepting the ASGI `http.response.start` message. - -```python -from secure import Secure -from secure.middleware import SecureASGIMiddleware -from shiny import App, ui - -secure_headers = Secure.with_default_headers() - -app_ui = ui.page_fluid("Hello Shiny!") - - -def server(input, output, session): - pass - - -app = App(app_ui, server) - -app = SecureASGIMiddleware(app, secure=secure_headers) -``` +See [docs/frameworks.md](./docs/frameworks.md) for the full matrix. The most common patterns are: ### FastAPI -#### Recommended: `add_middleware` (ASGI) - -Injects headers by intercepting the ASGI `http.response.start` message. - ```python from fastapi import FastAPI from secure import Secure @@ -477,69 +147,13 @@ from secure.middleware import SecureASGIMiddleware app = FastAPI() secure_headers = Secure.with_default_headers() - - -@app.get("/") -def read_root(): - return {"Hello": "World"} - - app.add_middleware(SecureASGIMiddleware, secure=secure_headers) ``` -#### Alternative: route-level hook (@app.middleware("http")) - -Applies headers directly to the response object returned by `call_next`. - -```python -from fastapi import FastAPI -from secure import Secure - -app = FastAPI() -secure_headers = Secure.with_default_headers() - - -@app.middleware("http") -async def add_security_headers(request, call_next): - response = await call_next(request) - await secure_headers.set_headers_async(response) - return response - - -@app.get("/") -def read_root(): - return {"Hello": "World"} -``` - -### Starlette - -#### Recommended: `add_middleware` (ASGI) - -```python -from secure import Secure -from secure.middleware import SecureASGIMiddleware -from starlette.applications import Starlette -from starlette.responses import JSONResponse - -secure_headers = Secure.with_default_headers() - -app = Starlette() -app.add_middleware(SecureASGIMiddleware, secure=secure_headers) - - -@app.route("/") -async def read_root(request): - return JSONResponse({"hello": "world"}) -``` - ### Flask -#### Recommended: `after_request` hook - -Applies headers directly to the Flask `Response` object. - ```python -from flask import Flask, Response +from flask import Flask from secure import Secure app = Flask(__name__) @@ -547,129 +161,27 @@ secure_headers = Secure.with_default_headers() @app.after_request -def add_security_headers(response: Response): +def add_security_headers(response): secure_headers.set_headers(response) return response - - -@app.route("/") -def home(): - return "Hello, world" - - -if __name__ == "__main__": - app.run() ``` -#### Alternative: WSGI middleware (`app.wsgi_app`) - -Wraps the WSGI application and injects headers by wrapping `start_response`. -Useful for deployment-level / framework-agnostic WSGI setups. +### Starlette ```python -from flask import Flask from secure import Secure -from secure.middleware.wsgi import SecureWSGIMiddleware +from secure.middleware import SecureASGIMiddleware +from starlette.applications import Starlette -app = Flask(__name__) +app = Starlette() secure_headers = Secure.with_default_headers() - - -@app.get("/") -def home(): - return {"Hello": "World"} - - -app.wsgi_app = SecureWSGIMiddleware(app.wsgi_app, secure=secure_headers) - -if __name__ == "__main__": - app.run() +app.add_middleware(SecureASGIMiddleware, secure=secure_headers) ``` ---- - -## Error handling and logging - -`secure` is designed to fail fast and clearly when something is misconfigured, with hooks for logging and diagnostics. - -### Applying headers - -`set_headers` and `set_headers_async` may raise: - -- `HeaderSetError` when the underlying response object refuses a header or an unexpected error occurs while setting one. -- `AttributeError` when the response object implements neither `set_header(name, value)` nor a mutable `headers` mapping. -- `RuntimeError` from `set_headers` if it detects that the only available setter is asynchronous. In that case, use `set_headers_async` instead. - -### Validation helpers - -The pipeline methods may raise `ValueError` when configured to do so: - -- `allowlist_headers` with `on_unexpected="raise"` when encountering an unexpected header name. -- `deduplicate_headers` with `action="raise"` when it cannot safely resolve duplicates. -- `validate_and_normalize_headers` with `on_invalid="raise"` or when it detects invalid or duplicate entries during normalization. - -Passing a `logger` into these methods is recommended in production so you can see which headers were rejected and why, even when you choose `"drop"` or `"warn"` modes instead of raising. - ---- - -## Documentation - -For additional examples, framework specific helpers, and more detailed guidance, see the documentation in the `docs` directory: - -- Configuration details. -- Framework integration notes. -- Reference for header builder classes. -- Migration notes for the v2.0.0 release and preset/default changes: - -Documentation: - ---- - -## Attribution - -`secure` implements recommendations from widely used security resources: - -- [MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers) (licensed under [CC-BY-SA 2.5](https://creativecommons.org/licenses/by-sa/2.5/)) -- [OWASP Secure Headers Project](https://owasp.org/www-project-secure-headers/) (licensed under [CC-BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/)) - -Attribution comments are included in the source code where appropriate. - ---- - -## Resources - -- [OWASP Secure Headers Project](https://owasp.org/www-project-secure-headers/) -- [Mozilla Web Security Guidelines](https://infosec.mozilla.org/guidelines/web_security) -- [MDN Web Docs: HTTP Headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers) -- [web.dev security guidance](https://web.dev) -- [W3C](https://www.w3.org) - ---- - -## License - -This project is licensed under the terms of the [MIT License](https://opensource.org/licenses/MIT). - ---- - -## Contributing - -Issues and pull requests are welcome. If you’d like to discuss an idea, please open a GitHub issue so we can align on the design before implementation. See [CONTRIBUTING](https://github.com/TypeError/secure/blob/main/CONTRIBUTING.md) for details. - ---- - -## Code of Conduct - -See [CODE_OF_CONDUCT](https://github.com/TypeError/secure/blob/main/CODE_OF_CONDUCT.md) for our Code of Conduct. - ---- - -## Changelog - -See [CHANGELOG](https://github.com/TypeError/secure/blob/main/CHANGELOG.md) for a detailed list of changes by release. - ---- - -## Acknowledgements +## Links -Thank you to everyone who contributes ideas, issues, pull requests, and feedback, as well as the maintainers of MDN and OWASP resources that this project builds on. +- [Documentation index](./docs/README.md) +- [Installation](./docs/installation.md) +- [Usage](./docs/usage.md) +- [Framework integration](./docs/frameworks.md) +- [Migration notes](./docs/migration.md) diff --git a/docs/README.md b/docs/README.md index fedfb79..f2c69a0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,135 +1,32 @@ -# Secure Headers Documentation - -Welcome to the documentation for **Secure Headers**, a flexible Python library for managing HTTP security headers. This guide will help you get started with configuring headers, integrating with various web frameworks, and understanding how each security header works. - ---- - -## 📖 Table of Contents - -- [Getting Started](#getting-started) -- [Supported Frameworks](#supported-frameworks) -- [Security Headers](#security-headers) -- [Additional Resources](#additional-resources) -- [Migration Notes](./migration.md) -- [Contributing](#contributing) - ---- - -## 🚀 Getting Started - -To quickly get started using Secure Headers, check out the basic configuration guide in the main README: - -- [Quick Start Guide](../README.md#quick-start) - -For installation instructions, see the [Installation section](./installation.md). - -For usage examples, see the [Usage Guide](./usage.md). - -For detailed configuration options, see the [Configuration Guide](./configuration.md). - ---- - -## 🔧 Supported Frameworks - -Secure Headers is compatible with many popular Python web frameworks. Below are the integration guides for each supported framework, consolidated in the [Frameworks Integration Guide](./frameworks.md): - -| Framework | Documentation | -| ----------------------------------------------------- | ----------------------------------------------- | -| [aiohttp](https://docs.aiohttp.org) | [Integration Guide](./frameworks.md#aiohttp) | -| [Bottle](https://bottlepy.org) | [Integration Guide](./frameworks.md#bottle) | -| [CherryPy](https://cherrypy.dev/) | [Integration Guide](./frameworks.md#cherrypy) | -| [Django](https://www.djangoproject.com) | [Integration Guide](./frameworks.md#django) | -| [Falcon](https://falconframework.org) | [Integration Guide](./frameworks.md#falcon) | -| [FastAPI](https://fastapi.tiangolo.com) | [Integration Guide](./frameworks.md#fastapi) | -| [Flask](http://flask.pocoo.org) | [Integration Guide](./frameworks.md#flask) | -| [Masonite](https://docs.masoniteproject.com/) | [Integration Guide](./frameworks.md#masonite) | -| [Morepath](https://morepath.readthedocs.io) | [Integration Guide](./frameworks.md#morepath) | -| [Pyramid](https://trypyramid.com) | [Integration Guide](./frameworks.md#pyramid) | -| [Quart](https://quart.palletsprojects.com/en/latest/) | [Integration Guide](./frameworks.md#quart) | -| [Responder](https://responder.kennethreitz.org/) | [Integration Guide](./frameworks.md#responder) | -| [Sanic](https://sanicframework.org) | [Integration Guide](./frameworks.md#sanic) | -| [Starlette](https://www.starlette.io/) | [Integration Guide](./frameworks.md#starlette) | -| [Tornado](https://www.tornadoweb.org/) | [Integration Guide](./frameworks.md#tornado) | -| [TurboGears](https://turbogears.org/) | [Integration Guide](./frameworks.md#turbogears) | - -If your framework is not listed here, Secure Headers can likely still be integrated. Refer to the [Custom Framework Integration Guide](./frameworks.md#custom-frameworks) for general integration tips. - ---- - -## 🛡️ Security Headers - -Secure Headers supports many critical HTTP security headers. Below is a list of headers you can configure, along with detailed documentation for each: - -- [Cache-Control](./headers/cache_control.md) - Configure caching behavior to protect sensitive content. - -- [Content-Security-Policy](./headers/content_security_policy.md) - Prevent XSS and data injection attacks by controlling allowed content sources. - -- [Cross-Origin-Embedder-Policy](./headers/cross_origin_embedder_policy.md) - Enhance cross-origin security by specifying cross-origin resource policies. - -- [Cross-Origin-Opener-Policy](./headers/cross_origin_opener_policy.md) - Prevent attackers from accessing your global objects via cross-origin documents. - -- [Cross-Origin-Resource-Policy](./headers/cross-origin-resource-policy.md) - Declare which origins can load your resources to prevent unintended data leaks. - -- [Custom Headers](./headers/custom_header.md) - Define and manage custom HTTP headers for advanced configurations. - -- [Permissions-Policy](./headers/permissions_policy.md) - Control access to browser features such as geolocation, camera, and microphone. - -- [Referrer-Policy](./headers/referrer_policy.md) - Manage how much referrer information is shared during navigation. - -- [Server](./headers/server.md) - Hide or customize the `Server` header to prevent exposing your server details. - -- [Strict-Transport-Security (HSTS)](./headers/strict_transport_security.md) - Ensure that communication is only over HTTPS by enforcing strict transport security. - -- [X-Content-Type-Options](./headers/x_content_type_options.md) - Prevent MIME-sniffing attacks by ensuring the browser follows the declared `Content-Type`. - -- [X-Frame-Options](./headers/x_frame_options.md) - Protect against clickjacking by controlling whether your content can be framed. - -- [X-DNS-Prefetch-Control](./headers/dns_prefetch_control.md) - Control DNS prefetching to avoid leaking outbound link information. - -- [X-Permitted-Cross-Domain-Policies](./headers/x-permitted-cross-domain-policies.md) - Limit legacy cross-domain policy files for Flash/Silverlight compatibility. - ---- - -## 📚 Additional Resources - -- [MDN Web Docs - HTTP Headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers) - Explore more about HTTP headers and their use cases. - -- [OWASP Secure Headers Project](https://owasp.org/www-project-secure-headers/) - Learn about security best practices for HTTP headers from OWASP. - -- [Mozilla Observatory](https://observatory.mozilla.org/) - A security tool to check the implementation of security headers on your site. - -- [Security Headers by Scott Helme](https://securityheaders.com/) - A free tool to test your site for missing security headers. - -- [HSTS Preload List](https://hstspreload.org/) - Learn about adding your domain to the HTTP Strict Transport Security (HSTS) preload list. - -- [CSP Evaluator by Google](https://csp-evaluator.withgoogle.com/) - A tool for analyzing Content Security Policies to ensure strong security practices. - ---- - -## 💬 Contributing - -We welcome contributions! If you'd like to contribute or have any feedback, feel free to: - -- **Open an Issue**: Report bugs or request features. -- **Submit a Pull Request**: Contribute code or documentation improvements. -- **Contact Us**: Reach out via [GitHub](https://github.com/TypeError/secure). +# Documentation + +Use this index when you know what you need. For a first pass, start with the [top-level README](../README.md). + +## Start here + +- [Installation](./installation.md) +- [Usage](./usage.md) +- [Framework integration](./frameworks.md) +- [Migration notes](./migration.md) + +## Reference + +- [Configuration](./configuration.md) +- [Security considerations](./security_considerations.md) + +## Header builders + +- [Cache-Control](./headers/cache_control.md) +- [Content-Security-Policy](./headers/content_security_policy.md) +- [Cross-Origin-Embedder-Policy](./headers/cross_origin_embedder_policy.md) +- [Cross-Origin-Opener-Policy](./headers/cross_origin_opener_policy.md) +- [Cross-Origin-Resource-Policy](./headers/cross-origin-resource-policy.md) +- [Custom Header](./headers/custom_header.md) +- [Permissions-Policy](./headers/permissions_policy.md) +- [Referrer-Policy](./headers/referrer_policy.md) +- [Server](./headers/server.md) +- [Strict-Transport-Security](./headers/strict_transport_security.md) +- [X-Content-Type-Options](./headers/x_content_type_options.md) +- [X-DNS-Prefetch-Control](./headers/dns_prefetch_control.md) +- [X-Frame-Options](./headers/x_frame_options.md) +- [X-Permitted-Cross-Domain-Policies](./headers/x-permitted-cross-domain-policies.md) diff --git a/docs/configuration.md b/docs/configuration.md index 69bc6ad..ee671e0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,60 +1,42 @@ # Configuration Guide -## Overview +This guide covers the parts of `secure` you are most likely to customize after the quick start. Keep `Secure` as the public entry point and pass it the builders you need. -This guide provides detailed information on how to configure `secure` beyond the default settings. You can customize security headers, override default behavior, and extend the functionality to meet your application’s unique security requirements. +## Default configuration ---- +`Secure.with_default_headers()` uses `Preset.BALANCED`, which provides a modern baseline while keeping the header set lean: -## Default Headers +- **Cross-Origin-Opener-Policy:** `same-origin` +- **Cross-Origin-Resource-Policy:** `same-origin` +- **Content-Security-Policy:** `default-src 'self'; base-uri 'self'; font-src 'self' https: data:; form-action 'self'; frame-ancestors 'self'; img-src 'self' data:; object-src 'none'; script-src 'self'; script-src-attr 'none'; style-src 'self' https: 'unsafe-inline'; upgrade-insecure-requests` +- **Strict-Transport-Security:** `max-age=31536000; includeSubDomains` +- **Permissions-Policy:** `geolocation=(), microphone=(), camera=()` +- **Referrer-Policy:** `strict-origin-when-cross-origin` +- **Server:** empty string +- **X-Content-Type-Options:** `nosniff` +- **X-Frame-Options:** `SAMEORIGIN` -`Secure.with_default_headers()` uses `Preset.BALANCED`, which configures a consistent, modern baseline. The defaults cover browser isolation, MIME safety, and legacy compatibility guards while keeping the header set lean: +Balanced intentionally skips `Cache-Control` and the compatibility headers (`X-Permitted-Cross-Domain-Policies`, `X-DNS-Prefetch-Control`, `Origin-Agent-Cluster`, `X-Download-Options`, `X-XSS-Protection`). Add them explicitly when your deployment still depends on them. -- **Cross-Origin-Opener-Policy:** `same-origin` – isolates the browsing context to prevent exploitation of shared global objects. -- **Cross-Origin-Resource-Policy:** `same-origin` – prevents cross-origin resources from being retrieved unless explicitly permitted. -- **Content-Security-Policy:** `default-src 'self'; base-uri 'self'; font-src 'self' https: data:; form-action 'self'; frame-ancestors 'self'; img-src 'self' data:; object-src 'none'; script-src 'self'; script-src-attr 'none'; style-src 'self' https: 'unsafe-inline'; upgrade-insecure-requests` – a conservative, CSP-first profile with no inline scripts and forced HTTPS upgrades. -- **Strict-Transport-Security (HSTS):** `max-age=31536000; includeSubDomains` – enforces HTTPS for browsers for one year. -- **Permissions-Policy:** `geolocation=(), microphone=(), camera=()` – disables a few sensitive browser features by default. -- **Referrer-Policy:** `strict-origin-when-cross-origin` – balances privacy and analytics by trimming cross-origin referrer data. -- **Server:** empty string – hides the underlying server software. -- **X-Content-Type-Options:** `nosniff` – blocks MIME sniffing attacks. -- **X-Frame-Options:** `SAMEORIGIN` – prevents framing by other origins. +## Customizing individual builders -Balanced intentionally skips `Cache-Control` and the older compatibility headers (`X-Permitted-Cross-Domain-Policies`, `X-DNS-Prefetch-Control`, `Origin-Agent-Cluster`, `X-Download-Options`, `X-XSS-Protection`), but you can add them manually when your deployment still depends on them. +All public header builders are re-exported from `secure`, so most applications can stay on the package-level API. -### Applying Default Headers +### `X-Frame-Options` -To quickly apply this configuration, use: - -```python -secure_headers = Secure.with_default_headers() -``` - -This is the simplest way to secure your application without manually configuring each individual header. However, for applications with specific security requirements, you can customize headers as needed. - ---- - -## Customizing Headers - -Each security header can be customized to meet your application’s unique needs. Below are examples of how to modify some commonly used headers. - -### Example: Customizing `X-Frame-Options` to Allow Same-Origin Embedding - -If you want to allow your site to be embedded in an iframe, but only by pages from the same origin, use the following configuration: +If you want to allow framing only from the same origin, use: ```python from secure import Secure, XFrameOptions -secure_headers = Secure( - xfo=XFrameOptions().sameorigin() -) +secure_headers = Secure(xfo=XFrameOptions().sameorigin()) ``` -This protects against clickjacking while maintaining functionality for same-origin embedding, such as internal dashboards. +This protects against clickjacking while still allowing same-origin embedding. -### Example: Customizing `Strict-Transport-Security` +### `Strict-Transport-Security` -To ensure that all subdomains of your site are accessed over HTTPS, and to add your domain to the HSTS preload list, you can configure `Strict-Transport-Security` like this: +To enforce HTTPS for all subdomains and opt into preload when you are ready, configure HSTS explicitly: ```python from secure import Secure, StrictTransportSecurity @@ -64,33 +46,22 @@ secure_headers = Secure( ) ``` -This configuration enforces HTTPS for 2 years (`max-age=63072000`), applies the rule to all subdomains, and preloads your site into browsers' HSTS lists. - ---- +This enforces HTTPS for two years, applies the rule to subdomains, and opts into the preload list. -## Extending Default Behavior +## Adding custom headers -You can also extend the default behavior by adding custom headers. This is useful when your application requires additional non-standard security headers. - -### Example: Adding a Custom Header +Use `CustomHeader` for application-specific response headers that do not have a dedicated builder: ```python -from secure import CustomHeader +from secure import CustomHeader, Secure custom_header = CustomHeader("X-Custom-Header", "CustomValue") - secure_headers = Secure(custom=[custom_header]) ``` -In this example, a custom HTTP header `X-Custom-Header` is added to the response, allowing you to inject additional security policies or tracking information as required by your application. +## Starting from a preset ---- - -## Combining Presets with Customization - -You can use one of the built-in presets as a starting point and then further customize specific headers to meet your security needs. Every `Secure` instance exposes its configuration as a list of header builders via `headers_list`, so you can replace, reorder, or extend that list to adjust individual headers even after instantiation. - -### Example: Customizing a Preset +Every `Secure` instance exposes its configured builders through `headers_list`, so you can replace or extend a preset after construction: ```python from secure import Preset, Secure, StrictTransportSecurity @@ -103,18 +74,20 @@ secure_headers.headers_list = [ if header.header_name != "Strict-Transport-Security" ] secure_headers.headers_list.append( - StrictTransportSecurity() - .max_age(63072000) - .include_subdomains() + StrictTransportSecurity().max_age(63072000).include_subdomains() ) ``` -This replaces the preset’s `Strict-Transport-Security` builder with a custom one while keeping the remaining headers unchanged. +This replaces the preset HSTS builder while leaving the rest of the preset untouched. + +## Validation and normalization ---- +If you need stronger guarantees before emission, `Secure` also exposes optional pipeline helpers: -## Summary +- `allowlist_headers(...)` filters or rejects unexpected header names in the current `headers_list`. +- `deduplicate_headers(...)` resolves duplicate header names in `headers_list` before you build a single-valued mapping. +- `validate_and_normalize_headers(...)` validates and normalizes the current `header_items()`, then caches the single-valued mapping used by `.headers`, `set_headers()`, and `set_headers_async()`. -`secure` offers flexibility in how you configure your security headers. Whether you’re using the default settings, customizing individual headers, or adding custom headers, the library allows you to secure your application effectively. For more advanced use cases, consider combining presets with custom configurations. +If you intentionally emit duplicate headers such as multiple `Content-Security-Policy` values, use `header_items()` instead of `.headers`. -For more details on each supported header, refer to the [Security Headers Documentation](./headers). +For per-header builder details, see the docs under [headers](./headers). diff --git a/docs/frameworks.md b/docs/frameworks.md index 4673848..c3d4a8a 100644 --- a/docs/frameworks.md +++ b/docs/frameworks.md @@ -1,12 +1,34 @@ # Framework Integration -`secure` supports several popular Python web frameworks. Below are examples showing how to set the default security headers in each framework, along with a brief introduction and links to each project. Additionally, we provide guidance for integrating Secure Headers with custom or unsupported frameworks. +`secure` keeps the same `Secure` object across frameworks. What changes is how you attach it. -## Table of Contents +Some sections below are first-class integrations with clear framework-level hooks or middleware. Others are intentionally minimal fallback examples where support is thinner or framework APIs vary by version. + +## How to choose an integration style + +- Use `set_headers()` when the response object is synchronous and you are already inside a response hook, middleware callback, or view. +- Use `set_headers_async()` in async middleware, hooks, or handlers when you want one helper that works safely across async response objects. +- Use `SecureWSGIMiddleware` when you want app-wide coverage and can wrap a WSGI application directly. +- Use `SecureASGIMiddleware` when you want app-wide coverage in an ASGI stack such as FastAPI, Starlette, or Shiny. + +Prefer middleware when your framework makes it easy and you want app-wide coverage. Use per-response setters when you are integrating into an existing hook, view, or minimal handler path. + +## Uvicorn `Server` header + +Uvicorn adds `Server: uvicorn` by default. If you want `secure` to control the `Server` header, disable Uvicorn's default header with `--no-server-header` or `server_header=False`. + +```python +import uvicorn + +uvicorn.run(app, host="0.0.0.0", port=8000, server_header=False) +``` + +## Table of contents - [aiohttp](#aiohttp) - [Bottle](#bottle) - [CherryPy](#cherrypy) +- [Dash](#dash) - [Django](#django) - [Falcon](#falcon) - [FastAPI](#fastapi) @@ -17,42 +39,17 @@ - [Quart](#quart) - [Responder](#responder) - [Sanic](#sanic) +- [Shiny](#shiny) - [Starlette](#starlette) - [Tornado](#tornado) - [TurboGears](#turbogears) -- [Web2py](#web2py) -- [Custom Frameworks](#custom-frameworks) - ---- - -### Note: Overriding the `Server` Header in Uvicorn-based Frameworks - -If you're using Uvicorn as the ASGI server (commonly used with frameworks like FastAPI, Starlette, and others), Uvicorn automatically injects a `Server: uvicorn` header into all HTTP responses by default. This can lead to multiple `Server` headers when using `secure` to set a custom `Server` header. - -To prevent Uvicorn from adding its default `Server` header, you can disable it by passing the `--no-server-header` option when running Uvicorn, or by setting `server_header=False` in the `uvicorn.run()` method: - -```python -import uvicorn - -uvicorn.run( - app, - host="0.0.0.0", - port=8000, - server_header=False, # Disable Uvicorn's default Server header -) -``` - -If you're using Uvicorn via Gunicorn (e.g., with the `UvicornWorker`), note that this setting is not passed through automatically. In such cases, you may need to subclass the worker to fully override the `Server` header. - -For more information, refer to the [Uvicorn Settings](https://www.uvicorn.org/settings/#http). - ---- +- [Custom frameworks](#custom-frameworks) ## aiohttp -**[aiohttp](https://docs.aiohttp.org)** is an asynchronous HTTP client/server framework for asyncio and Python. It's designed for building efficient web applications with asynchronous capabilities. +Async framework with first-class middleware support. -### Middleware Example +### Recommended: middleware with `set_headers_async()` ```python from aiohttp import web @@ -60,19 +57,18 @@ from secure import Secure secure_headers = Secure.with_default_headers() + @web.middleware async def add_security_headers(request, handler): response = await handler(request) await secure_headers.set_headers_async(response) return response -app = web.Application(middlewares=[add_security_headers]) -app.router.add_get("/", lambda request: web.Response(text="Hello, world")) -web.run_app(app) +app = web.Application(middlewares=[add_security_headers]) ``` -### Single Route Example +### Alternative: set headers in a single handler ```python from aiohttp import web @@ -80,62 +76,53 @@ from secure import Secure secure_headers = Secure.with_default_headers() -async def handle(request): + +async def home(request): response = web.Response(text="Hello, world") await secure_headers.set_headers_async(response) return response - -app = web.Application() -app.router.add_get("/", handle) -web.run_app(app) ``` ---- - ## Bottle -**[Bottle](https://bottlepy.org)** is a fast, simple, and lightweight WSGI micro web-framework for Python. It's perfect for small applications and rapid prototyping. +Small WSGI framework with request hooks. -### Middleware Example +### Recommended: `after_request` hook with `set_headers()` ```python -from bottle import Bottle, response, run +from bottle import Bottle, response from secure import Secure -secure_headers = Secure.with_default_headers() app = Bottle() +secure_headers = Secure.with_default_headers() + @app.hook("after_request") def add_security_headers(): secure_headers.set_headers(response) - -run(app, host="localhost", port=8080) ``` -### Single Route Example +### Fallback: set headers in a route ```python -from bottle import Bottle, response, run +from bottle import Bottle, response from secure import Secure -secure_headers = Secure.with_default_headers() app = Bottle() +secure_headers = Secure.with_default_headers() + @app.route("/") -def index(): +def home(): secure_headers.set_headers(response) return "Hello, world" - -run(app, host="localhost", port=8080) ``` ---- - ## CherryPy -**[CherryPy](https://cherrypy.dev)** is a minimalist, object-oriented web framework that allows developers to build web applications in a way similar to building other Python applications. +Minimal fallback example. CherryPy exposes the response object in the handler, so handler-level mutation is the practical integration point. -### Middleware Example +### Minimal fallback: set headers in the exposed method ```python import cherrypy @@ -143,49 +130,31 @@ from secure import Secure secure_headers = Secure.with_default_headers() -class HelloWorld: - @cherrypy.expose - def index(self): - cherrypy.response.headers.update(secure_headers.headers) - return b"Hello, world" - -cherrypy.quickstart(HelloWorld()) -``` - -### Single Route Example - -```python -import cherrypy -from secure import Secure - -secure_headers = Secure.with_default_headers() -class HelloWorld: +class App: @cherrypy.expose def index(self): - cherrypy.response.headers.update(secure_headers.headers) + secure_headers.set_headers(cherrypy.response) return b"Hello, world" -cherrypy.quickstart(HelloWorld()) -``` ---- +cherrypy.quickstart(App()) +``` ## Dash -**[Dash](https://dash.plotly.com/)** is a Python framework for building interactive data apps and dashboards, built on top of Plotly.js, React, and Flask. +Dash runs on top of Flask, so the usual Flask integration patterns apply. -### Middleware Example +### Recommended: Flask `after_request` on `app.server` ```python import dash from dash import html from secure import Secure -secure_headers = Secure.with_default_headers() - app = dash.Dash(__name__) server = app.server +secure_headers = Secure.with_default_headers() app.layout = html.Div("Hello Dash!") @@ -196,47 +165,46 @@ def add_security_headers(response): return response ``` -#### Alternative: WSGI middleware +### Alternative: `SecureWSGIMiddleware` ```python import dash from dash import html from secure import Secure -from secure.middleware.wsgi import SecureWSGIMiddleware - -secure_headers = Secure.with_default_headers() +from secure.middleware import SecureWSGIMiddleware app = dash.Dash(__name__) -server = app.server # Flask app underneath Dash +server = app.server +secure_headers = Secure.with_default_headers() app.layout = html.Div("Hello Dash!") - server.wsgi_app = SecureWSGIMiddleware(server.wsgi_app, secure=secure_headers) ``` ---- - ## Django -**[Django](https://www.djangoproject.com)** is a high-level Python web framework that encourages rapid development and clean, pragmatic design. +Django is usually best integrated through Django middleware rather than raw WSGI wrapping. -### Middleware Example +### Recommended: Django middleware class + +Register the middleware class in your Django `MIDDLEWARE` setting. ```python -from django.http import HttpResponse from secure import Secure -secure_headers = Secure.with_default_headers() -def set_secure_headers(get_response): - def middleware(request): - response = get_response(request) - secure_headers.set_headers(response) +class SecureHeadersMiddleware: + def __init__(self, get_response): + self.get_response = get_response + self.secure = Secure.with_default_headers() + + def __call__(self, request): + response = self.get_response(request) + self.secure.set_headers(response) return response - return middleware ``` -### Single Route Example +### Fallback: set headers in a view ```python from django.http import HttpResponse @@ -244,19 +212,18 @@ from secure import Secure secure_headers = Secure.with_default_headers() + def home(request): response = HttpResponse("Hello, world") secure_headers.set_headers(response) return response ``` ---- - ## Falcon -**[Falcon](https://falconframework.org)** is a minimalist WSGI library for building speedy web APIs and app backends. +Falcon exposes a clean response middleware hook. -### Middleware Example +### Recommended: Falcon middleware ```python import falcon @@ -264,20 +231,16 @@ from secure import Secure secure_headers = Secure.with_default_headers() + class SecureMiddleware: def process_response(self, req, resp, resource, req_succeeded): secure_headers.set_headers(resp) -app = falcon.App(middleware=[SecureMiddleware()]) - -class HelloWorldResource: - def on_get(self, req, resp): - resp.text = "Hello, world" -app.add_route("/", HelloWorldResource()) +app = falcon.App(middleware=[SecureMiddleware()]) ``` -### Single Route Example +### Fallback: set headers in the resource ```python import falcon @@ -285,22 +248,22 @@ from secure import Secure secure_headers = Secure.with_default_headers() + class HelloWorldResource: def on_get(self, req, resp): resp.text = "Hello, world" secure_headers.set_headers(resp) + app = falcon.App() app.add_route("/", HelloWorldResource()) ``` ---- - ## FastAPI -**[FastAPI](https://fastapi.tiangolo.com)** is a modern, fast web framework for building APIs with Python 3.6+. +ASGI framework. Middleware is the clearest default. -#### Recommended: `SecureASGIMiddleware` +### Recommended: `SecureASGIMiddleware` ```python from fastapi import FastAPI @@ -309,10 +272,11 @@ from secure.middleware import SecureASGIMiddleware app = FastAPI() secure_headers = Secure.with_default_headers() + app.add_middleware(SecureASGIMiddleware, secure=secure_headers) ``` -#### Alternative: route-level hook with `@app.middleware("http")` +### Alternative: `@app.middleware("http")` ```python from fastapi import FastAPI @@ -321,6 +285,7 @@ from secure import Secure app = FastAPI() secure_headers = Secure.with_default_headers() + @app.middleware("http") async def add_security_headers(request, call_next): response = await call_next(request) @@ -328,7 +293,7 @@ async def add_security_headers(request, call_next): return response ``` -### Single Route Example +### Fallback: set headers in one route ```python from fastapi import FastAPI, Response @@ -337,34 +302,34 @@ from secure import Secure app = FastAPI() secure_headers = Secure.with_default_headers() + @app.get("/") -def read_root(response: Response): +def home(response: Response): secure_headers.set_headers(response) - return {"Hello": "World"} + return {"hello": "world"} ``` ---- - ## Flask -**[Flask](https://flask.palletsprojects.com)** is a lightweight WSGI web application framework. +WSGI framework with a straightforward response hook. -### Middleware Example +### Recommended: `after_request` ```python -from flask import Flask, Response +from flask import Flask from secure import Secure app = Flask(__name__) secure_headers = Secure.with_default_headers() + @app.after_request -def add_security_headers(response: Response): +def add_security_headers(response): secure_headers.set_headers(response) return response ``` -#### Alternative: WSGI middleware +### Alternative: `SecureWSGIMiddleware` ```python from flask import Flask @@ -377,70 +342,29 @@ secure_headers = Secure.with_default_headers() app.wsgi_app = SecureWSGIMiddleware(app.wsgi_app, secure=secure_headers) ``` -### Single Route Example - -```python -from flask import Flask, Response -from secure import Secure - -app = Flask(__name__) -secure_headers = Secure.with_default_headers() - -@app.route("/") -def home(): - response = Response("Hello, world") - secure_headers.set_headers(response) - return response -``` - ---- - ## Masonite -**[Masonite](https://docs.masoniteproject.com)** is a modern and developer-friendly Python web framework. +Minimal fallback example. Masonite routing and response APIs vary by version, so apply `Secure` to the response object you actually return. -### Middleware Example +### Minimal fallback: apply to the response you return ```python -from masonite.foundation import Application -from masonite.response import Response from secure import Secure -app = Application() secure_headers = Secure.with_default_headers() -def add_security_headers(response: Response): - secure_headers.set_headers(response) - return response -``` - -### Single Route Example -```python -from masonite.request import Request -from masonite.response import Response -from masonite.foundation import Application -from secure import Secure - -app = Application() -secure_headers = Secure.with_default_headers() - -@app.route("/") -def home(request: Request, response: Response): - return add_security_headers(response.view("Hello, world")) +def home(response): + rendered = response.json({"hello": "world"}) + secure_headers.set_headers(rendered) + return rendered ``` ---- - ## Morepath -**[Morepath](https://morepath.readthedocs.io)** is a Python web framework that provides URL to object mapping. - -### Middleware Example - -Morepath doesn’t have middleware. Use per-view settings as shown in the single route example. +Minimal fallback example. Morepath does not expose a conventional middleware layer for this, so view-level mutation is the practical integration point. -### Single Route Example +### Minimal fallback: set headers in the view ```python import morepath @@ -448,82 +372,82 @@ from secure import Secure secure_headers = Secure.with_default_headers() + class App(morepath.App): pass + @App.path(path="") class Root: pass + @App.view(model=Root) -def hello_world(self, request): +def home(self, request): response = morepath.Response("Hello, world") secure_headers.set_headers(response) return response - -morepath.run(App()) ``` ---- - ## Pyramid -**[Pyramid](https://trypyramid.com)** is a small, fast Python web framework. +Pyramid applications commonly use tweens for cross-cutting response changes. + +### Recommended: tween -### Middleware Example +Register the tween in your `Configurator` with `config.add_tween("yourpackage.security.add_security_headers")`. ```python -from pyramid.config import Configurator -from pyramid.response import Response from secure import Secure secure_headers = Secure.with_default_headers() + def add_security_headers(handler, registry): def tween(request): response = handler(request) secure_headers.set_headers(response) return response + return tween ``` -### Single Route Example +### Fallback: set headers in a view ```python -from pyramid.config import Configurator from pyramid.response import Response from secure import Secure secure_headers = Secure.with_default_headers() -def hello_world(request): + +def home(request): response = Response("Hello, world") secure_headers.set_headers(response) return response ``` ---- - ## Quart -**[Quart](https://quart.palletsprojects.com)** is an async Python web framework. +Async Flask-compatible framework. -### Middleware Example +### Recommended: `after_request` with `set_headers_async()` ```python -from quart import Quart, Response +from quart import Quart from secure import Secure app = Quart(__name__) secure_headers = Secure.with_default_headers() + @app.after_request -async def add_security_headers(response: Response): +async def add_security_headers(response): await secure_headers.set_headers_async(response) return response ``` -### Single Route Example +### Fallback: set headers in a route ```python from quart import Quart, Response @@ -532,20 +456,19 @@ from secure import Secure app = Quart(__name__) secure_headers = Secure.with_default_headers() + @app.route("/") -async def index(): +async def home(): response = Response("Hello, world") await secure_headers.set_headers_async(response) return response ``` ---- - ## Responder -**[Responder](https://responder.kennethreitz.org)** is a fast web framework for building APIs. +Minimal fallback example. Route handlers typically own the response, so route-level mutation is the practical integration point. -### Middleware Example +### Minimal fallback: set headers in the route ```python import responder @@ -554,19 +477,6 @@ from secure import Secure api = responder.API() secure_headers = Secure.with_default_headers() -@api.route("/") -async def add_security_headers(req, resp): - await secure_headers.set_headers_async(resp) -``` - -### Single Route Example - -```python -import responder -from secure import Secure - -api = responder.API() -secure_headers = Secure.with_default_headers() @api.route("/") async def home(req, resp): @@ -574,50 +484,50 @@ async def home(req, resp): await secure_headers.set_headers_async(resp) ``` ---- - ## Sanic -**[Sanic](https://sanicframework.org)** is a Python web framework written for fast performance. +Sanic exposes response middleware for app-wide coverage. -### Middleware Example +### Recommended: response middleware with `set_headers_async()` ```python -from sanic import Sanic, response +from sanic import Sanic from secure import Secure -app = Sanic("SecureApp") +app = Sanic("secure-app") secure_headers = Secure.with_default_headers() + @app.middleware("response") -async def add_security_headers(request, resp): - secure_headers.set_headers(resp) - return resp +async def add_security_headers(request, response): + await secure_headers.set_headers_async(response) + return response ``` -### Single Route Example +Use route-level setters when you only need a small integration or do not want extra app wiring in tests. + +### Fallback: set headers in a route ```python from sanic import Sanic, response from secure import Secure -app = Sanic("SecureApp") +app = Sanic("secure-app") secure_headers = Secure.with_default_headers() -@app.route("/") -async def index(request): + +@app.get("/") +async def home(request): resp = response.text("Hello, world") - secure_headers.set_headers(resp) + await secure_headers.set_headers_async(resp) return resp ``` ---- - ## Shiny -**[Shiny](https://shiny.posit.co/py/)** is a fully reactive framework for building rich, interactive web apps in pure Python—without needing to learn JavaScript or front-end frameworks. +Shiny applications are ASGI apps, so ASGI middleware is the cleanest and most direct path. -### Middleware Example +### Recommended: `SecureASGIMiddleware` ```python from secure import Secure @@ -634,36 +544,34 @@ def server(input, output, session): app = App(app_ui, server) - app = SecureASGIMiddleware(app, secure=secure_headers) ``` ---- - ## Starlette -**[Starlette](https://www.starlette.io)** is a lightweight ASGI framework. +ASGI framework. Use ASGI middleware unless you only need route-level control. -### Middleware Example +### Recommended: `SecureASGIMiddleware` ```python from secure import Secure from secure.middleware import SecureASGIMiddleware from starlette.applications import Starlette -from starlette.responses import JSONResponse +from starlette.responses import PlainTextResponse +from starlette.routing import Route secure_headers = Secure.with_default_headers() -app = Starlette() -app.add_middleware(SecureASGIMiddleware, secure=secure_headers) +async def home(request): + return PlainTextResponse("Hello, world") -@app.route("/") -async def read_root(request): - return JSONResponse({"hello": "world"}) + +app = Starlette(routes=[Route("/", home)]) +app.add_middleware(SecureASGIMiddleware, secure=secure_headers) ``` -### Single Route Example +### Alternative: set headers in an endpoint ```python from secure import Secure @@ -674,69 +582,49 @@ from starlette.routing import Route secure_headers = Secure.with_default_headers() -async def homepage(request): +async def home(request): response = Response("Hello, world") await secure_headers.set_headers_async(response) return response -app = Starlette(routes=[Route("/", homepage)]) +app = Starlette(routes=[Route("/", home)]) ``` ---- - ## Tornado -**[Tornado](https://www.tornadoweb.org)** is a Python web framework designed for asynchronous networking. +Minimal fallback example. Tornado usually applies headers inside request handlers, so handler-level mutation is the practical integration point. -### Middleware Example - -Tornado doesn't directly support middleware, but you can use it in each request handler as shown in the single route example. - -### Single Route Example +### Minimal fallback: set headers in the handler ```python -import tornado.ioloop import tornado.web from secure import Secure secure_headers = Secure.with_default_headers() + class MainHandler(tornado.web.RequestHandler): def get(self): self.write("Hello, world") secure_headers.set_headers(self) -``` ---- + +app = tornado.web.Application([(r"/", MainHandler)]) +``` ## TurboGears -**[TurboGears](https://turbogears.org)** is a full-stack framework. +Minimal fallback example. If you do not already have a framework-level hook in place, controller-level mutation is the practical integration point. -### Middleware Example +### Minimal fallback: set headers in the controller ```python -from tg import AppConfig, Response, TGController, expose +from tg import Response, TGController, expose from secure import Secure secure_headers = Secure.with_default_headers() -class SecureMiddleware: - def __init__(self, req, resp): - secure_headers.set_headers(resp) - -config = AppConfig(minimal=True, root_controller=TGController) -config["middleware"] = [SecureMiddleware] -``` - -### Single Route Example - -```python -from tg import AppConfig, Response, TGController, expose -from secure import Secure - -secure_headers = Secure.with_default_headers() class RootController(TGController): @expose() @@ -744,61 +632,35 @@ class RootController(TGController): response = Response("Hello, world") secure_headers.set_headers(response) return response -``` - ---- -## Web2py -**[Web2py](http://www.web2py.com)** is a free web framework designed for rapid development of database-driven applications. +root = RootController() +``` -### Middleware Example +## Custom frameworks -Web2py doesn't directly support middleware, but you can use it in each route. +If your framework is not listed here, the integration rule is still simple: configure one `Secure` instance, then apply it to the response as late as possible before it is sent. -### Single Route Example +### Recommended: use the response object's setter or headers mapping ```python -from gluon import current from secure import Secure secure_headers = Secure.with_default_headers() -def index(): - secure_headers.set_headers(current.response) - return "Hello, world" -``` - ---- - -## Custom Frameworks - -If you are using a framework that is not listed here, `secure` can still be integrated. Most frameworks offer a way to manipulate response headers, which is all you need to apply security headers. - -### General Steps: -1. **Identify the Response Object**: Each framework typically has a response object or an equivalent that allows you to modify HTTP headers. - -2. **Set Headers**: Use the `set_headers()` or `set_headers_async()` method to inject security headers into the response before sending it back to the client. - -3. **Asynchronous Support**: For asynchronous frameworks, ensure that you're calling the correct async version of methods. +def add_security_headers(response): + secure_headers.set_headers(response) + return response +``` -### Example: +### Fallback: emit header pairs manually ```python from secure import Secure secure_headers = Secure.with_default_headers() -def add_secure_headers(response): - secure_headers.set_headers(response) - return response - -# Apply the `add_secure_headers` function wherever your framework handles responses. +for name, value in secure_headers.header_items(): + response.headers[name] = value ``` - ---- - -### Need Help? - -If you encounter any issues integrating Secure Headers with your custom framework, feel free to open an issue on our [GitHub repository](https://github.com/TypeError/secure) or consult the framework's documentation for handling response headers. diff --git a/docs/headers/cache_control.md b/docs/headers/cache_control.md index 26ef61b..b4a616c 100644 --- a/docs/headers/cache_control.md +++ b/docs/headers/cache_control.md @@ -17,12 +17,13 @@ This is a secure baseline intended to prevent storage of sensitive responses. ```python from secure import CacheControl, Secure -secure = Secure( +secure_headers = Secure( cache=CacheControl().no_store().max_age(0) ) ``` If you don’t configure any directives, the default value is emitted. +`Preset.STRICT` includes `Cache-Control: no-store, max-age=0`; `Preset.BASIC` and `Preset.BALANCED` leave caching unchanged unless you add this builder. ## Common recipes diff --git a/docs/headers/content_security_policy.md b/docs/headers/content_security_policy.md index 4cab849..bf92509 100644 --- a/docs/headers/content_security_policy.md +++ b/docs/headers/content_security_policy.md @@ -19,6 +19,7 @@ default-src 'self'; script-src 'self'; style-src 'self'; object-src 'none'; base ``` This matches `HeaderDefaultValue.CONTENT_SECURITY_POLICY`. +The built-in presets use explicit CSP builders rather than this bare builder default; `Preset.BASIC` and `Preset.BALANCED` add `font-src`, `img-src`, `script-src-attr`, `style-src`, and `upgrade-insecure-requests`. ## Best-practice baseline @@ -49,7 +50,7 @@ csp = ( .base_uri(ContentSecurityPolicy.keyword("self")) ) -secure = Secure(csp=csp) +secure_headers = Secure(csp=csp) ``` Then apply headers in your framework integration: @@ -59,11 +60,11 @@ Then apply headers in your framework integration: from flask import Flask, Response app = Flask(__name__) -secure = Secure(csp=csp) +secure_headers = Secure(csp=csp) @app.after_request def add_security_headers(response: Response) -> Response: - secure.set_headers(response) + secure_headers.set_headers(response) return response ``` @@ -79,7 +80,7 @@ csp_report_only = ( .script_src(ContentSecurityPolicy.keyword("self")) ) -secure = Secure(csp=csp_report_only) +secure_headers = Secure(csp=csp_report_only) ``` Use `.enforce()` to switch back to the enforcing header name. @@ -240,15 +241,12 @@ In your HTML rendering, use the same nonce: ## References - MDN: Content-Security-Policy - - [https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy) - MDN: CSP guide - - [https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP) - OWASP Secure Headers Project - - [https://owasp.org/www-project-secure-headers/#content-security-policy](https://owasp.org/www-project-secure-headers/#content-security-policy) ## Attribution diff --git a/docs/headers/cross-origin-resource-policy.md b/docs/headers/cross-origin-resource-policy.md index 363aca9..bd82be4 100644 --- a/docs/headers/cross-origin-resource-policy.md +++ b/docs/headers/cross-origin-resource-policy.md @@ -12,19 +12,22 @@ This header is commonly used to reduce cross-origin data leaks by controlling wh - **`same-site`**: Useful when you need to share resources across subdomains on the same “site” but not with unrelated sites. - **`cross-origin`**: Most permissive; allow any origin to load the resource (use intentionally, not by accident). -## Configuration with `secure` +## Configuration with `Secure` The `CrossOriginResourcePolicy` class provides a fluent API for setting CORP directives and integrates cleanly with `Secure(...)`. ### Example Configuration ```python +from secure import CrossOriginResourcePolicy, Secure + secure_headers = Secure( corp=CrossOriginResourcePolicy().same_origin() ) ``` > Library default: if you do not change it, the library’s default value is `same-origin`. +> Presets: `Preset.BASIC` and `Preset.BALANCED` include `same-origin`; `Preset.STRICT` does not add CORP by default. ### Methods Available diff --git a/docs/headers/cross_origin_embedder_policy.md b/docs/headers/cross_origin_embedder_policy.md index c76f428..13a419a 100644 --- a/docs/headers/cross_origin_embedder_policy.md +++ b/docs/headers/cross_origin_embedder_policy.md @@ -28,7 +28,7 @@ COEP is a **single-value** header (choose one): If you want “no-op” behavior, you must explicitly choose it: ```python -from secure.headers import CrossOriginEmbedderPolicy +from secure import CrossOriginEmbedderPolicy coep = CrossOriginEmbedderPolicy().unsafe_none() ``` @@ -43,22 +43,23 @@ Some powerful browser features require your document to be **cross-origin isolat ## Usage with `Secure` ```python -from secure import Secure -from secure.headers import CrossOriginEmbedderPolicy, CrossOriginOpenerPolicy +from secure import CrossOriginEmbedderPolicy, CrossOriginOpenerPolicy, Secure -secure = Secure( +secure_headers = Secure( coep=CrossOriginEmbedderPolicy().require_corp(), coop=CrossOriginOpenerPolicy().same_origin(), ) # Inspect emitted headers: -print(secure.header_items()) +print(secure_headers.header_items()) ``` +`Preset.STRICT` includes COEP by default; `Preset.BASIC` and `Preset.BALANCED` do not. + ## Header builder API ```python -from secure.headers import CrossOriginEmbedderPolicy +from secure import CrossOriginEmbedderPolicy coep = ( CrossOriginEmbedderPolicy() diff --git a/docs/headers/cross_origin_opener_policy.md b/docs/headers/cross_origin_opener_policy.md index bb2c6d2..a3751f9 100644 --- a/docs/headers/cross_origin_opener_policy.md +++ b/docs/headers/cross_origin_opener_policy.md @@ -16,13 +16,12 @@ The `Cross-Origin-Opener-Policy` (COOP) response header controls whether documen - **`noopener-allow-popups`**: Always isolates into a new BCG (except when opened by a same-origin document that also uses `noopener-allow-popups`). Useful when you need to isolate **same-origin** apps from each other (e.g., `/chat` vs `/passwords`) while still allowing popups. - **`unsafe-none`**: Opts out of COOP isolation. -## Configuration with `secure` +## Configuration with `Secure` Use the `CrossOriginOpenerPolicy` builder and pass it into `Secure(...)`: ```python -from secure import Secure -from secure.headers import CrossOriginOpenerPolicy +from secure import CrossOriginOpenerPolicy, Secure secure_headers = Secure( coop=CrossOriginOpenerPolicy().same_origin() @@ -47,6 +46,8 @@ Escape hatches: ## Example Usage ```python +from secure import CrossOriginOpenerPolicy, Secure + coop = CrossOriginOpenerPolicy().same_origin() print(coop.header_name) # 'Cross-Origin-Opener-Policy' print(coop.header_value) # 'same-origin' diff --git a/docs/headers/custom_header.md b/docs/headers/custom_header.md index 697a065..7909772 100644 --- a/docs/headers/custom_header.md +++ b/docs/headers/custom_header.md @@ -2,26 +2,28 @@ ## Purpose -The `CustomHeader` class allows the creation and management of custom HTTP headers with arbitrary names and values. This is particularly useful for adding non-standard headers to HTTP responses or requests, such as headers specific to your application or infrastructure. +The `CustomHeader` class lets you create arbitrary HTTP response headers when `secure` does not provide a dedicated builder. ## Best Practices -- Custom headers should follow the convention of using a prefix like `X-` (e.g., `X-Custom-Header`), although this is no longer a requirement as per the latest RFC. -- Be cautious when adding custom headers to avoid potential conflicts or leaking sensitive information. +- Prefer standard header names when they exist; use custom names only for application- or infrastructure-specific behavior. +- If you use `allowlist_headers(...)`, remember that custom names may need to be added through `allow_extra=...`. -## Configuration with `secure` +## Configuration with `Secure` -The `CustomHeader` class in `secure` provides flexibility for developers to define and set custom HTTP headers as needed. You can specify both the header name and value and update the value later if necessary. +Use `CustomHeader` when you need a header without a dedicated builder. You can set the name and value directly, then update the value later if needed. ### Example Configuration ```python +from secure import CustomHeader + custom_header = CustomHeader("X-Custom-Header", "CustomValue") ``` ### Methods Available -- **`set(value)`**: Updates the value of the custom header. +- **`set(value)` / `value(value)`**: Updates the value of the custom header. - **`header_value`**: Property that retrieves the current value of the custom header. ## Example Usage @@ -29,6 +31,8 @@ custom_header = CustomHeader("X-Custom-Header", "CustomValue") To define a custom header and use it in a secure configuration: ```python +from secure import CustomHeader + custom_header = CustomHeader("X-Custom-Header", "CustomValue") print(custom_header.header_name) # Output: 'X-Custom-Header' print(custom_header.header_value) # Output: 'CustomValue' @@ -38,7 +42,7 @@ custom_header.set("NewValue") print(custom_header.header_value) # Output: 'NewValue' ``` -This can then be applied as part of your Secure headers configuration: +Then pass it into `Secure`: ```python from secure import Secure diff --git a/docs/headers/dns_prefetch_control.md b/docs/headers/dns_prefetch_control.md index 3451d14..e99a40d 100644 --- a/docs/headers/dns_prefetch_control.md +++ b/docs/headers/dns_prefetch_control.md @@ -17,12 +17,13 @@ If you create `XDnsPrefetchControl()` and do not set a directive, it returns the ```python from secure import Secure, XDnsPrefetchControl -secure = Secure( +secure_headers = Secure( xdfc=XDnsPrefetchControl().off() ) ``` If you don’t configure anything, the default value is emitted. +`Preset.BASIC` includes `X-DNS-Prefetch-Control: off`; `Preset.BALANCED` and `Preset.STRICT` leave it out unless you add it explicitly. ## Common recipes @@ -104,7 +105,7 @@ print(xdfc.header_value) # off - Output is always a **single token** (`on` or `off`) when using `.on()` / `.off()` (stable and deterministic). - Setting the value multiple times overwrites the previous value (last call wins). -- Header value sanitization (e.g., blocking CR/LF) is enforced by `Secure.validate_and_normalize_headers(...)`. +- `.set(...)`, `.value(...)`, and `.custom(...)` reject CR/LF; `Secure.validate_and_normalize_headers(...)` performs the broader normalization pass. ## Compatibility notes diff --git a/docs/headers/permissions_policy.md b/docs/headers/permissions_policy.md index d2359a3..b2af071 100644 --- a/docs/headers/permissions_policy.md +++ b/docs/headers/permissions_policy.md @@ -12,7 +12,7 @@ In this library, `PermissionsPolicy` is a fluent builder for producing a single - Enable selectively: allow features only where required, and only for trusted origins. - Validate in real browsers: support varies by feature and browser; test the behaviors you rely on. -## Configuration with `secure` +## Configuration with `Secure` ```python from secure import PermissionsPolicy, Secure @@ -25,6 +25,8 @@ secure_headers = Secure( ) ``` +`Preset.BALANCED` and `Preset.STRICT` include `geolocation=(), microphone=(), camera=()` by default; `Preset.BASIC` does not add `Permissions-Policy`. + ## Allowlist syntax `PermissionsPolicy` uses MDN-style allowlist syntax for each directive: @@ -58,8 +60,7 @@ Common methods you’ll use: ## Example usage ```python -from secure import Secure -from secure.headers import PermissionsPolicy +from secure import PermissionsPolicy, Secure permissions_policy = ( PermissionsPolicy() diff --git a/docs/headers/referrer_policy.md b/docs/headers/referrer_policy.md index 618abc3..e12ddd3 100644 --- a/docs/headers/referrer_policy.md +++ b/docs/headers/referrer_policy.md @@ -27,11 +27,13 @@ This matches modern browser defaults: if no policy is specified (or the provided ```python from secure import ReferrerPolicy, Secure -secure = Secure( +secure_headers = Secure( referrer=ReferrerPolicy() # uses the default: strict-origin-when-cross-origin ) ``` +`Preset.BALANCED` uses `strict-origin-when-cross-origin`; `Preset.BASIC` and `Preset.STRICT` use `no-referrer`. + ### Set a single explicit policy Use `value(...)` (or `custom(...)`) when you want to **replace** any configured policies and set exactly one value: @@ -39,7 +41,7 @@ Use `value(...)` (or `custom(...)`) when you want to **replace** any configured ```python from secure import ReferrerPolicy, Secure -secure = Secure( +secure_headers = Secure( referrer=ReferrerPolicy().value("no-referrer") ) ``` @@ -47,7 +49,7 @@ secure = Secure( You can also use the fluent directive helpers: ```python -secure = Secure( +secure_headers = Secure( referrer=ReferrerPolicy().no_referrer() ) ``` diff --git a/docs/headers/server.md b/docs/headers/server.md index 3e17361..a951df9 100644 --- a/docs/headers/server.md +++ b/docs/headers/server.md @@ -2,20 +2,23 @@ ## Purpose -The `Server` header provides information about the server software handling the request. By default, this header exposes the server's technology stack, which can increase the risk of targeted attacks. For enhanced security, it is recommended to obscure or remove this header to prevent unnecessary exposure of server details. +The `Server` header can reveal details about the software handling the request. In `secure`, the builder defaults to an empty string so your application can avoid adding identifying detail when the surrounding stack allows it. ## Best Practices -- **Set an empty value or custom string**: It's generally advisable to set the `Server` header to an empty value (`""`) or use a non-informative value to avoid revealing specific details about the server software. +- **Set an empty value or custom string**: Use an empty or generic value when you want `secure` to control the header. - **Avoid exposing server information**: Avoid leaving the default server response, which may expose sensitive version information. +- **Check upstream defaults**: Proxies, ASGI servers, and framework middleware may still add their own `Server` header unless you disable that behavior. -## Configuration with `secure` +## Configuration with `Secure` -The `Server` class in `secure` allows you to easily control the `Server` header value, with the default value set to an empty string to enhance security. +Use `Server` to control the `Server` header value. Its default value is an empty string. ### Example Configuration ```python +from secure import Secure, Server + secure_headers = Secure( server=Server().set("") ) @@ -31,14 +34,18 @@ secure_headers = Secure( To set up the `Server` header and hide the server information: ```python +from secure import Server + server_header = Server().set("") print(server_header.header_name) # Output: 'Server' print(server_header.header_value) # Output: '' ``` -This can then be applied as part of your Secure headers configuration: +Then pass it into `Secure`: ```python +from secure import Secure + secure_headers = Secure(server=server_header) ``` diff --git a/docs/headers/strict_transport_security.md b/docs/headers/strict_transport_security.md index e48081e..70c2d5f 100644 --- a/docs/headers/strict_transport_security.md +++ b/docs/headers/strict_transport_security.md @@ -22,13 +22,12 @@ If you do not configure any directives, this library emits the default header va - `max-age` must be **at least 31536000** - `includeSubDomains` must be present -## Configuration with `secure` +## Configuration with `Secure` -The `StrictTransportSecurity` header module supports fluent, chainable configuration: +Use `StrictTransportSecurity` for fluent, chainable configuration: ```python -from secure import Secure -from secure.headers import StrictTransportSecurity +from secure import Secure, StrictTransportSecurity secure_headers = Secure( hsts=StrictTransportSecurity() @@ -37,12 +36,14 @@ secure_headers = Secure( ) ``` +`Preset.BASIC` and `Preset.BALANCED` use one year with `includeSubDomains`; `Preset.STRICT` uses two years with `includeSubDomains`. + ### Preload configuration If you opt into preload, the library ensures preload requirements are satisfied: ```python -from secure.headers import StrictTransportSecurity +from secure import StrictTransportSecurity hsts = ( StrictTransportSecurity() @@ -67,7 +68,6 @@ If `preload()` is enabled with a `max-age` less than `31536000`, the header buil - **`preload()`** Add `preload`: indicates intent to meet HSTS preload requirements. This library: - - automatically enables `includeSubDomains` - enforces `max-age >= 31536000` @@ -82,7 +82,7 @@ If `preload()` is enabled with a `max-age` less than `31536000`, the header buil Minimal one-year HSTS: ```python -from secure.headers import StrictTransportSecurity +from secure import StrictTransportSecurity hsts = StrictTransportSecurity().max_age(31536000) print(hsts.header_value) # 'max-age=31536000' @@ -91,7 +91,7 @@ print(hsts.header_value) # 'max-age=31536000' One-year HSTS including subdomains: ```python -from secure.headers import StrictTransportSecurity +from secure import StrictTransportSecurity hsts = StrictTransportSecurity().max_age(31536000).include_subdomains() print(hsts.header_value) # 'max-age=31536000; includeSubDomains' diff --git a/docs/headers/x-permitted-cross-domain-policies.md b/docs/headers/x-permitted-cross-domain-policies.md index 1999c1c..bd7a445 100644 --- a/docs/headers/x-permitted-cross-domain-policies.md +++ b/docs/headers/x-permitted-cross-domain-policies.md @@ -21,12 +21,13 @@ This is the least permissive option and is the most common secure setting when y ```python from secure import Secure, XPermittedCrossDomainPolicies -secure = Secure( +secure_headers = Secure( xpcdp=XPermittedCrossDomainPolicies().none() ) ``` If you don’t configure anything, the default value is emitted. +`Preset.BASIC` includes `X-Permitted-Cross-Domain-Policies: none`; `Preset.BALANCED` and `Preset.STRICT` leave it out unless you add it explicitly. ## Common recipes diff --git a/docs/headers/x_content_type_options.md b/docs/headers/x_content_type_options.md index ef581c3..e4cfa96 100644 --- a/docs/headers/x_content_type_options.md +++ b/docs/headers/x_content_type_options.md @@ -16,11 +16,12 @@ This helps reduce the risk of content being interpreted as executable when it sh - **Set to `nosniff`** (recommended): This is the standard and widely supported directive. - **Use correct `Content-Type` values**: `nosniff` is most effective when your server sends accurate MIME types. -## Configuration in `secure` +## Configuration with `Secure` The `XContentTypeOptions` class configures `X-Content-Type-Options`. **Default header value:** `nosniff` +All built-in presets include it. ### Minimal configuration diff --git a/docs/headers/x_frame_options.md b/docs/headers/x_frame_options.md index 544117a..f26e00f 100644 --- a/docs/headers/x_frame_options.md +++ b/docs/headers/x_frame_options.md @@ -32,16 +32,15 @@ This directive is **obsolete**. Modern browsers that encounter `ALLOW-FROM` may ### Minimal usage ```python -from secure import Secure -from secure.headers import XFrameOptions +from secure import Secure, XFrameOptions -secure = Secure(xfo=XFrameOptions().sameorigin()) +secure_headers = Secure(xfo=XFrameOptions().sameorigin()) ``` ### Choose a directive ```python -from secure.headers import XFrameOptions +from secure import XFrameOptions xfo = XFrameOptions().deny() print(xfo.header_name) # 'X-Frame-Options' @@ -53,7 +52,7 @@ print(xfo.header_value) # 'DENY' If you already have a fully-formed value, set it directly: ```python -from secure.headers import XFrameOptions +from secure import XFrameOptions xfo = XFrameOptions().value("SAMEORIGIN") # Aliases (for compatibility / readability): @@ -71,7 +70,7 @@ print(xfo.header_value) # 'SAMEORIGIN' ### Obsolete directive (not recommended) ```python -from secure.headers import XFrameOptions +from secure import XFrameOptions # Warning: obsolete; prefer CSP frame-ancestors xfo = XFrameOptions().allow_from("https://example.com") diff --git a/docs/installation.md b/docs/installation.md index b30a2b4..2cba77f 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,87 +1,25 @@ -# Installation Guide +# Installation -## Overview +`secure` requires Python 3.10 or newer and has no external dependencies. -`secure` is a lightweight and powerful Python library designed to simplify the management of HTTP security headers. It helps you easily add secure headers to web applications, supporting a variety of popular frameworks. This guide will walk you through the installation process, system requirements, and framework compatibility. - ---- - -## Requirements - -- **Python Version**: `secure` supports Python 3.10 and above. -- **Supported Platforms**: The library is cross-platform, supporting Linux, macOS, and Windows. - -To ensure compatibility with `secure`, your system should have Python version 3.10 or higher. You can check your Python version by running the following command: - -```bash -python --version -``` - -Most modern systems should meet this requirement. - ---- - -## Installation via uv or pip - -To install `secure` from the Python Package Index (PyPI), use the following command: - -### Using `uv` +Install it with `uv` or `pip`: ```bash uv add secure ``` -### Using `pip` - ```bash pip install secure ``` -This will download and install the latest version of `secure`, along with any dependencies required for basic usage. - -### Optional Dependencies - -If you're using `secure` with specific web frameworks, you’ll need to install the respective framework alongside the library. Here are some common optional dependencies: +If you are following a framework example, install that framework separately. ```bash -pip install aiohttp # For aiohttp support -pip install flask # For Flask support -pip install fastapi # For FastAPI support +pip install fastapi ``` -Make sure to install the framework you're working with to ensure seamless integration with `secure`. - -For integration with other frameworks, refer to the [framework integration guide](./frameworks.md) for details on the supported frameworks and their setup. - ---- - -## Testing the Installation - -After installation, you can verify that `secure` is working properly by importing it in a Python shell or script: +After installation, import the public API from the package root: ```python ->>> from secure import Secure ->>> secure_headers = Secure.with_default_headers() ->>> print(secure_headers) +from secure import Secure ``` - -If the headers are printed without any errors, the installation was successful and you're ready to begin securing your web application. - ---- - -## Troubleshooting - -If you encounter any issues during installation, ensure the following: - -1. **Correct Python Version**: Verify that Python 3.10+ is installed. -2. **pip is Up-to-date**: Make sure you're using the latest version of `pip`. You can upgrade it with: - ```bash - pip install --upgrade pip - ``` -3. **Virtual Environments**: Consider using a virtual environment to isolate your dependencies. You can create one with: - ```bash - python -m venv env - source env/bin/activate # On Windows: env\Scripts\activate - ``` - -For further issues, consult the [GitHub repository](https://github.com/TypeError/secure) for troubleshooting tips or to open an issue. diff --git a/docs/migration.md b/docs/migration.md index 4c7ab51..5e55436 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1,36 +1,48 @@ -# v2.0.0 Migration Notes +# v2 Migration Notes -## Package and import changes +The first stable v2 release is `2.0.1`. Skip `2.0.0`. -- The package is now published as `secure` (not `secure.py`). Import the public API via `import secure` or `from secure import Secure, Preset, ContentSecurityPolicy`, and the builder classes are re-exported at the package level for convenience. -- `Secure.with_default_headers()` now equals `Secure.from_preset(Preset.BALANCED)`, so you can keep calling the same helpers while taking advantage of the new preset enum. Balanced is the recommended default and intentionally omits `Cache-Control`; add it explicitly when your deployment depends on caching directives. +If your application already uses `Secure` to set headers on responses, the upgrade should be straightforward. Most changes are about preset names, package-level imports, and clearer sync versus async integration. -```python -from secure import Secure, StrictTransportSecurity +## What stayed the same -secure_headers = Secure( - hsts=StrictTransportSecurity().max_age(63072000) -) -``` +- `Secure` is still the main entry point. +- Header builders such as `ContentSecurityPolicy` and `StrictTransportSecurity` are still the way to define custom policies. +- `set_headers(response)` is still the sync path for supported response objects. -## Presets and defaults +## What changed -- There are three built-in presets now: `Preset.BALANCED` (the recommended default that `with_default_headers()` uses), `Preset.BASIC` (Helmet compatibility parity), and `Preset.STRICT` (the hardened profile). `Preset.MODERN` has been removed in favor of this clearer contract between the default, compatibility, and strict profiles. -- The `BASIC` preset emits additional legacy/compatibility headers such as `X-Permitted-Cross-Domain-Policies`, `X-DNS-Prefetch-Control`, `Origin-Agent-Cluster`, `X-Download-Options`, and `X-XSS-Protection`. Use `Preset.BALANCED` when you want the same security posture without the extra response headers, and add those legacy headers manually only when you still depend on them. -- `Preset.STRICT` continues to enable COEP, CSP base/frame restrictions, and a strict permissions policy, but it no longer preloads HSTS by default; add `.preload()` yourself when you are ready to opt into the preload list. +- Import from the package root: `from secure import Secure, Preset, ContentSecurityPolicy`. +- `Secure.with_default_headers()` now means `Secure.from_preset(Preset.BALANCED)`. +- Presets are now `Preset.BALANCED`, `Preset.BASIC`, and `Preset.STRICT`. +- `set_headers_async(response)` is available for async integrations and async response setters. +- `secure.middleware` exposes `SecureWSGIMiddleware` and `SecureASGIMiddleware` for app-wide integration. -## Header pipeline helpers +## What might break -- Use `secure_headers.allowlist_headers(...).deduplicate_headers(...).validate_and_normalize_headers(...)` to enforce a clean, single-valued header mapping before calling `set_headers`/`set_headers_async`. This pipeline combines allowlists, duplicate resolution, and validation with sanitized output that you can inspect via `secure_headers.headers` or emit manually via `secure_headers.header_items()`. -- `Secure.header_items()` keeps the original ordering and multi-valued headers, so you can still emit headers like CSP multiple times when necessary. +- `Preset.MODERN` is gone. Replace it with `Preset.BALANCED` or `Preset.STRICT`, depending on what you wanted. +- The default profile is now `BALANCED`, which intentionally omits `Cache-Control` and the legacy compatibility headers from `BASIC`. +- `Preset.STRICT` no longer enables HSTS preload by default. Add `.preload()` yourself if you rely on that behavior. +- `set_headers()` is sync-only. If your response object only supports async setters, switch to `await set_headers_async(response)`. +- If you set the `Server` header, disable framework or server defaults such as Uvicorn's `Server: uvicorn` to avoid duplicates. -## Setters and async support +## Minimal upgrade path -- `set_headers` now raises clear errors if the response object only exposes async setters, while `set_headers_async` transparently awaits either sync or async `set_header`/`headers.__setitem__` calls. If you previously manipulated headers manually, switching to these helpers gives you timeouts, logging, and validation hooks. +If you previously relied on the default helpers, this is usually enough: -## Security gotchas +```python +from secure import Secure + +secure_headers = Secure.with_default_headers() +secure_headers.set_headers(response) +``` -- The `Server` header defaults to an empty string, so disable framework defaults (e.g., `uvicorn --no-server-header`) if you apply a custom value to avoid duplicate headers. -- `Preset.BASIC` includes legacy/compatibility defaults such as `X-Permitted-Cross-Domain-Policies: none` and `X-XSS-Protection: 0`. Use `Preset.BALANCED` (or roll your own `Secure` instance) when you want a leaner header set. +If you want the new preset API explicitly: + +```python +from secure import Preset, Secure + +secure_headers = Secure.from_preset(Preset.BALANCED) +``` -Refer back to the [README](../README.md) and the individual header docs for exact builder methods when adapting your existing configuration to v2.0.0. +If your old code expected stricter defaults, review `Preset.STRICT` before switching. The main thing to check is CSP behavior, caching, framing, and HSTS preload. diff --git a/docs/security_considerations.md b/docs/security_considerations.md index fc5d5cd..a2332d3 100644 --- a/docs/security_considerations.md +++ b/docs/security_considerations.md @@ -2,7 +2,9 @@ ## Overview -Security headers are a critical component of modern web application security. They help mitigate common attack vectors such as Cross-Site Scripting (XSS), clickjacking, and man-in-the-middle (MITM) attacks. This guide highlights the security implications of each header supported by `secure` and offers best practices based on OWASP recommendations. +Security headers are one part of a web application's security posture. They help browsers enforce transport, embedding, content loading, and privacy rules, but they do not replace application-layer controls such as output encoding, CSRF protection, authentication, or input validation. + +This guide keeps the advice tied to what `secure` actually emits and where the tradeoffs are operational rather than theoretical. ## Importance of Security Headers @@ -11,24 +13,24 @@ Security headers are a critical component of modern web application security. Th The `Strict-Transport-Security` header ensures that browsers only connect to your site over HTTPS, preventing MITM attacks by forcing a secure connection. It tells the browser to remember to always access the site via HTTPS, even if the user tries to access it over HTTP. - [MDN Docs - Strict-Transport-Security](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security) -- **Best Practice**: Set a long `max-age` (e.g., `max-age=63072000` for two years) and include subdomains. +- **Best Practice**: Use a long `max-age` and include subdomains only when every subdomain is HTTPS-ready. - **Pitfall**: Be cautious when setting the `preload` directive, as it’s difficult to remove once added to the HSTS preload list. --- ### **Content-Security-Policy (CSP)** -The `Content-Security-Policy` header helps prevent XSS and data injection attacks by specifying which content sources are allowed to be loaded by the browser. It is one of the most effective ways to mitigate XSS attacks. +The `Content-Security-Policy` header limits which sources the browser will trust for scripts, styles, images, frames, and other resource types. A well-tuned CSP reduces the impact of XSS and unsafe third-party content, but the policy still has to match how your frontend actually loads code and assets. - [MDN Docs - Content-Security-Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy) -- **Best Practice**: Start with a strict `default-src 'self'` policy and expand only as needed. Use nonce-based policies for inline scripts. +- **Best Practice**: Start with a restrictive baseline and expand only where the application requires it. Use nonces or hashes for inline scripts when possible. - **Pitfall**: Overly permissive CSP rules (e.g., using `unsafe-inline`, `unsafe-eval`, or `*`) can leave your application vulnerable to XSS attacks. --- ### **X-Frame-Options** -The `X-Frame-Options` header prevents clickjacking attacks by controlling whether your site can be embedded in an iframe. +The `X-Frame-Options` header prevents clickjacking by controlling whether a page can be framed. In modern deployments, treat it as a compatibility header alongside CSP `frame-ancestors`. - [MDN Docs - X-Frame-Options](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options) - **Best Practice**: Set to `DENY` to completely block framing, or `SAMEORIGIN` if you only want to allow framing from your own domain. @@ -38,11 +40,11 @@ The `X-Frame-Options` header prevents clickjacking attacks by controlling whethe ### **X-Content-Type-Options** -The `X-Content-Type-Options` header prevents MIME-sniffing by telling the browser to strictly follow the declared `Content-Type`. This helps prevent certain types of attacks, including drive-by downloads. +The `X-Content-Type-Options` header prevents MIME-sniffing by telling browsers to respect the declared `Content-Type`. In practice, it is most relevant for blocking incorrectly typed script and stylesheet responses. - [MDN Docs - X-Content-Type-Options](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options) - **Best Practice**: Always set this header to `nosniff`. -- **Pitfall**: None. This header is very low-risk but high-reward from a security perspective. +- **Pitfall**: This header can surface incorrect `Content-Type` handling in your app or asset pipeline. --- @@ -58,7 +60,7 @@ The `Referrer-Policy` header controls how much referrer information is included ### **Permissions-Policy** -The `Permissions-Policy` (formerly `Feature-Policy`) header allows you to enable or disable browser features such as geolocation, camera access, and more. +The `Permissions-Policy` header allows you to disable or scope browser features such as geolocation, camera access, and microphone access. - [MDN Docs - Permissions-Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Permissions-Policy) - **Best Practice**: Disable unnecessary features (e.g., `camera`, `microphone`, `geolocation`) to reduce attack surface. @@ -68,27 +70,27 @@ The `Permissions-Policy` (formerly `Feature-Policy`) header allows you to enable ### **Cross-Origin-Embedder-Policy (COEP)** -The `Cross-Origin-Embedder-Policy` header prevents a document from loading any cross-origin resources that don’t explicitly grant the document permission. +The `Cross-Origin-Embedder-Policy` header controls whether a document can load cross-origin resources that do not explicitly opt in via CORP or CORS. - [MDN Docs - Cross-Origin-Embedder-Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cross-Origin-Embedder-Policy) -- **Best Practice**: Use `require-corp` to ensure that all embedded resources are loaded securely. -- **Pitfall**: Misconfiguration can prevent legitimate cross-origin resource sharing. +- **Best Practice**: Use COEP when you need cross-origin isolation and can verify that your own and third-party resources are compatible. +- **Pitfall**: Misconfiguration often breaks legitimate cross-origin assets before it improves anything. --- ### **Cross-Origin-Opener-Policy (COOP)** -The `Cross-Origin-Opener-Policy` header helps isolate your browsing context by preventing access to your global object via cross-origin documents. +The `Cross-Origin-Opener-Policy` header isolates a document's browsing context group, which helps reduce XS-Leaks and is typically paired with COEP when you need cross-origin isolation. - [MDN Docs - Cross-Origin-Opener-Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cross-Origin-Opener-Policy) - **Best Practice**: Set this to `same-origin` to protect against XS-Leaks and ensure that only same-origin documents can access the browsing context. -- **Pitfall**: Incompatibility with certain cross-origin interactions, such as embedded third-party services. +- **Pitfall**: Popups, payment flows, or OAuth-style integrations may need a less strict value than `same-origin`. --- ### **Cache-Control** -The `Cache-Control` header controls how and for how long browsers cache responses. Setting it properly can prevent sensitive data from being stored in caches. +The `Cache-Control` header controls how responses are cached. For security-sensitive responses, it helps prevent browsers and intermediaries from storing content that should not persist. - [MDN Docs - Cache-Control](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control) - **Best Practice**: Use `no-store` for sensitive pages like login or payment forms to ensure that they are not cached. @@ -98,26 +100,24 @@ The `Cache-Control` header controls how and for how long browsers cache response ### **Server** -The `Server` header is typically used to reveal information about the server software being used. Hiding or customizing this header can obscure specific server details from attackers. +The `Server` header can disclose software details, but changing or clearing it should be treated as passive information reduction, not as a primary defense. - [MDN Docs - Server](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Server) -- **Best Practice**: Either remove or set this to a generic value to avoid exposing server details. -- **Pitfall**: Leaving this header exposed can give attackers valuable information about your server’s configuration, potentially making it easier to exploit. +- **Best Practice**: Set a generic or empty value when your stack allows it, and disable framework or proxy defaults that would re-add a value upstream. +- **Pitfall**: Do not assume hiding `Server` materially hardens a vulnerable application. --- ### **Custom Headers** -In addition to the predefined headers, you can define custom security headers based on your application's specific needs. - -- **Best Practice**: Use custom headers for non-standard security requirements or business-specific security mechanisms. +`CustomHeader` is an escape hatch for application-specific response headers. Use it when you need to emit a header that does not have a dedicated builder, but keep the semantics and deployment expectations documented elsewhere in your application. --- ## Common Pitfalls -- **Improper CSP Configurations**: Using `unsafe-inline` or `unsafe-eval` weakens CSP protections and should be avoided. -- **Weak HSTS Settings**: A short `max-age` value undermines the effectiveness of HSTS, as users will not remain protected if the connection is downgraded. +- **Improper CSP configurations**: Using `unsafe-inline`, `unsafe-eval`, or broad source allowlists weakens CSP quickly. +- **Weak HSTS rollout discipline**: Sending HSTS before all routes and subdomains are HTTPS-ready can break access just as easily as it improves transport security. ## OWASP Guidelines diff --git a/docs/usage.md b/docs/usage.md index 5e6fe4b..f7e5b54 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1,322 +1,120 @@ -# Usage Guide +# Usage -## Overview +`Secure` is the public entry point. Configure it once, then reuse it wherever your framework gives you access to the response. -The `secure` library is designed to simplify the configuration of HTTP security headers in Python web applications. This guide provides detailed examples of how to use the library, from setting basic security headers to leveraging advanced presets and custom configurations. +## Start with a preset -## Setting Basic Security Headers - -To start using `secure`, you can quickly set up a default configuration that applies common security headers. Here's a basic example: +`Secure.with_default_headers()` is the recommended starting point. It matches `Preset.BALANCED`. ```python from secure import Secure secure_headers = Secure.with_default_headers() - -def add_security_headers(response): - secure_headers.set_headers(response) - return response ``` -This will apply a standard set of HTTP security headers, such as `Content-Security-Policy`, `Strict-Transport-Security`, and `X-Frame-Options`, ensuring a baseline level of security. - ---- - -## Using Presets - -### Presets Overview - -`secure` offers three preset configurations: `BALANCED`, `BASIC`, and `STRICT`. These are pre-configured sets of security headers that can be quickly applied to your web application for different security needs. - ---- - -## **BALANCED Preset** - -The `BALANCED` preset is the recommended default and corresponds to `Secure.with_default_headers()`. It keeps the response headers focused while still enforcing CSP, HSTS, COOP/Corp, and other modern defaults. - -### Example Code: +You can also choose a preset explicitly: ```python -from flask import Flask, Response - from secure import Preset, Secure -app = Flask(__name__) -secure_headers = Secure.from_preset(Preset.BALANCED) - -@app.after_request -def add_security_headers(response: Response): - secure_headers.set_headers(response) - return response - -@app.route("/") -def home(): - return "Hello, world" - -if __name__ == "__main__": - app.run() -``` - -### Example Headers: - -```http -Cross-Origin-Opener-Policy: same-origin -Cross-Origin-Resource-Policy: same-origin -Content-Security-Policy: default-src 'self'; base-uri 'self'; font-src 'self' https: data:; form-action 'self'; frame-ancestors 'self'; img-src 'self' data:; object-src 'none'; script-src 'self'; script-src-attr 'none'; style-src 'self' https: 'unsafe-inline'; upgrade-insecure-requests -Strict-Transport-Security: max-age=31536000; includeSubDomains -Permissions-Policy: geolocation=(), microphone=(), camera=() -Referrer-Policy: strict-origin-when-cross-origin -Server: -X-Content-Type-Options: nosniff -X-Frame-Options: SAMEORIGIN +balanced = Secure.from_preset(Preset.BALANCED) +basic = Secure.from_preset(Preset.BASIC) +strict = Secure.from_preset(Preset.STRICT) ``` -Balanced omits `Cache-Control` and the legacy/resource headers included by `Preset.BASIC`, so add them manually when your deployment still relies on them. +- `Preset.BALANCED`: recommended default for most applications. +- `Preset.BASIC`: adds legacy and interoperability headers. +- `Preset.STRICT`: tighter CSP, disabled caching, and stricter framing rules. ---- +## Apply headers to a response -## **BASIC Preset** - -The `BASIC` preset mirrors Helmet.js defaults. It extends the Balanced set with extra compatibility headers such as `X-Permitted-Cross-Domain-Policies` and `X-XSS-Protection`. - -### Example Code: +Use `set_headers()` for synchronous response objects: ```python -from flask import Flask, Response +from secure import Secure -from secure import Preset, Secure +secure_headers = Secure.with_default_headers() -app = Flask(__name__) -secure_headers = Secure.from_preset(Preset.BASIC) -@app.after_request -def add_security_headers(response: Response): +def add_security_headers(response): secure_headers.set_headers(response) return response - -@app.route("/") -def home(): - return "Hello, world" - -if __name__ == "__main__": - app.run() -``` - -### Example Headers: - -```http -Cross-Origin-Opener-Policy: same-origin -Cross-Origin-Resource-Policy: same-origin -Content-Security-Policy: default-src 'self'; base-uri 'self'; font-src 'self' https: data:; form-action 'self'; frame-ancestors 'self'; img-src 'self' data:; object-src 'none'; script-src 'self'; script-src-attr 'none'; style-src 'self' https: 'unsafe-inline'; upgrade-insecure-requests -Strict-Transport-Security: max-age=31536000; includeSubDomains -Referrer-Policy: no-referrer -X-Content-Type-Options: nosniff -X-Frame-Options: SAMEORIGIN -X-Permitted-Cross-Domain-Policies: none -X-DNS-Prefetch-Control: off -Origin-Agent-Cluster: ?1 -X-Download-Options: noopen -X-XSS-Protection: 0 ``` -Use this preset when you want to match the Helmet.js defaults exactly. - ---- - -## **STRICT Preset** - -The `STRICT` preset applies the most walls for security-focused deployments that tolerate tighter restrictions. It enables COEP, CSP base/frame restrictions, and aggressive HSTS (without preload by default). - -### Example Code: +Use `set_headers_async()` in async code or when the response object may expose async setters: ```python -from flask import Flask, Response +from secure import Secure -from secure import Preset, Secure +secure_headers = Secure.with_default_headers() -app = Flask(__name__) -secure_headers = Secure.from_preset(Preset.STRICT) -@app.after_request -def add_security_headers(response: Response): - secure_headers.set_headers(response) +async def add_security_headers(response): + await secure_headers.set_headers_async(response) return response - -@app.route("/") -def home(): - return "Hello, world" - -if __name__ == "__main__": - app.run() ``` -### Example Headers: - -```http -Cache-Control: no-store, max-age=0 -Cross-Origin-Embedder-Policy: require-corp -Cross-Origin-Opener-Policy: same-origin -Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none' -Strict-Transport-Security: max-age=63072000; includeSubDomains -Permissions-Policy: geolocation=(), microphone=(), camera=() -Referrer-Policy: no-referrer -Server: -X-Content-Type-Options: nosniff -X-Frame-Options: DENY -``` - -Start with `BALANCED` and move to `STRICT` once you have validated that your application works correctly with the stricter Content Security Policy, caching, and frame restrictions. - ---- - -You can easily adjust between these presets based on your application's needs by importing `Preset.BASIC`, `Preset.BALANCED`, or `Preset.STRICT` and applying it to your response handlers. +`Secure` works with response objects that provide either: ---- +- `response.set_header(name, value)` +- `response.headers[name] = value` -## Customizing Individual Headers +If your framework uses a different contract, emit headers manually with `header_items()`. -In addition to using presets, you can tailor individual headers to fit your application’s specific security requirements. +## Build an explicit configuration -### Example: Customizing `Content-Security-Policy` +Presets are the shortest path. When you need more control, pass builder objects into `Secure`. ```python -from secure import ContentSecurityPolicy, Secure +from secure import ( + ContentSecurityPolicy, + PermissionsPolicy, + Secure, + StrictTransportSecurity, +) secure_headers = Secure( - csp=ContentSecurityPolicy() - .default_src("'self'") - .img_src("https://trusted-images.com") + csp=( + ContentSecurityPolicy() + .default_src("'self'") + .img_src("'self'", "https://images.example.com") + .script_src("'self'", "https://cdn.example.com") + ), + hsts=StrictTransportSecurity().max_age(63072000).include_subdomains(), + permissions=PermissionsPolicy().geolocation().microphone().camera(), ) - -def add_security_headers(response): - secure_headers.set_headers(response) - return response ``` -In this example, the `Content-Security-Policy` (CSP) header is customized to allow images from a trusted domain while enforcing `'self'` as the default source for all other content. - ---- +This keeps the configuration readable while avoiding hand-built header strings. -## Asynchronous Usage +## Optional validation pipeline -For asynchronous frameworks (such as `aiohttp`, `FastAPI`, or `Quart`), you can use the `set_headers_async()` method to apply security headers without blocking the event loop: - -```python -async def add_security_headers(response): - await secure_headers.set_headers_async(response) - return response -``` - -This approach ensures that your security headers are applied efficiently in non-blocking environments. - ---- - -## Middleware - -Secure exposes `SecureWSGIMiddleware` and `SecureASGIMiddleware` through `secure.middleware`. Each middleware accepts a `Secure` instance (defaulting to `Secure.with_default_headers()`), overwrites headers by default, and only appends duplicates when the normalized header name is listed in `multi_ok` (which defaults to `secure.MULTI_OK`, including `Content-Security-Policy`). - -### WSGI (Flask) - -Wrap a Flask app by replacing its `wsgi_app`, ensuring every response passes through the middleware: - -```python -from flask import Flask -from secure import Secure -from secure.middleware import SecureWSGIMiddleware - -secure_headers = Secure.with_default_headers() -app = Flask(__name__) -app.wsgi_app = SecureWSGIMiddleware(app.wsgi_app, secure=secure_headers) -``` - -### WSGI (Django) - -Django middleware wraps requests and responses rather than the raw WSGI callable, so apply secure headers with a lightweight middleware class: +Most applications do not need this. It is useful when headers are being merged, extended, or generated dynamically and you want validation before emission. ```python from secure import Secure -class SecureHeadersMiddleware: - def __init__(self, get_response): - self.get_response = get_response - self.secure = Secure.with_default_headers() - - def __call__(self, request): - response = self.get_response(request) - self.secure.set_headers(response) - return response +secure_headers = ( + Secure.with_default_headers() + .allowlist_headers() + .deduplicate_headers() + .validate_and_normalize_headers() +) ``` -Add `SecureHeadersMiddleware` to the `MIDDLEWARE` setting to run secure headers on every Django response. - -### ASGI (FastAPI) - -`SecureASGIMiddleware` touches only HTTP scopes and leaves WebSocket traffic unchanged. Mount it manually or via FastAPI’s middleware helper: - -```python -from fastapi import FastAPI -from secure import Secure -from secure.middleware import SecureASGIMiddleware - -secure_headers = Secure.with_default_headers() -app = FastAPI() -app.add_middleware(SecureASGIMiddleware, secure=secure_headers) -``` +After `validate_and_normalize_headers()`, the normalized mapping is available via `secure_headers.headers`. -### ASGI (Shiny for Python) +## Manual emission -Wrap a Shiny `App` directly with the middleware to secure HTTP responses: +Use `header_items()` when a framework does not expose a supported response interface or when you need ordered header pairs. ```python -from shiny import App from secure import Secure -from secure.middleware import SecureASGIMiddleware secure_headers = Secure.with_default_headers() -app = SecureASGIMiddleware(App(), secure=secure_headers) -``` - -### Customizing `multi_ok` - -Pass an explicit `multi_ok` iterable to either middleware to append headers whose names must appear multiple times (for example, when downstream code already emits `Content-Security-Policy`). - ---- - -## Full Example with Customization - -The following is a complete example demonstrating how to combine default headers with custom configurations: - -```python -from secure import Secure, StrictTransportSecurity, XFrameOptions - -secure_headers = Secure( - hsts=StrictTransportSecurity() - .max_age(63072000) - .include_subdomains(), - xfo=XFrameOptions().deny() -) -def add_security_headers(response): - # Apply security headers to the response - secure_headers.set_headers(response) - return response +for name, value in secure_headers.header_items(): + response.headers[name] = value ``` -In this example, a custom `Strict-Transport-Security` (HSTS) header is configured to enforce HTTPS for two years across all subdomains, and the `X-Frame-Options` header is set to `DENY` to prevent clickjacking. - ---- - -## Summary - -The `secure` library offers flexibility and ease of use when configuring HTTP security headers for Python web applications. You can use pre-configured presets for quick setups or customize headers individually to meet your specific security needs. By leveraging both synchronous and asynchronous methods, `secure` fits seamlessly into any Python-based web framework. - -For more details on the individual headers and advanced usage, refer to the [Security Headers](./headers) documentation. - ---- - -## **Attribution** - -This library implements security recommendations from trusted sources: - -- [MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers) (licensed under [CC-BY-SA 2.5](https://creativecommons.org/licenses/by-sa/2.5/)) -- [OWASP Secure Headers Project](https://owasp.org/www-project-secure-headers/) (licensed under [CC-BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/)) +For framework-specific examples, see [Framework Integration](./frameworks.md). diff --git a/pyproject.toml b/pyproject.toml index 481b4e0..18bfadc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "secure" -version = "2.0.0rc1" +version = "2.0.1" description = "A lightweight package that adds security headers for Python web frameworks." readme = { file = "README.md", content-type = "text/markdown" } license = "MIT" @@ -13,7 +13,7 @@ authors = [{ name = "Caleb Kinney", email = "caleb@typeerror.com" }] requires-python = ">=3.10" keywords = ["security", "headers", "web", "framework", "HTTP"] classifiers = [ - "Development Status :: 4 - Beta", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Operating System :: OS Independent", "Programming Language :: Python :: 3", @@ -42,6 +42,10 @@ exclude = ["tests*", "docs*"] [tool.setuptools.package-data] secure = ["py.typed"] +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["tests"] + # --- Ruff (formatter + linter) --- [tool.ruff] target-version = "py310" diff --git a/secure/_internal/__init__.py b/secure/_internal/__init__.py new file mode 100644 index 0000000..7376815 --- /dev/null +++ b/secure/_internal/__init__.py @@ -0,0 +1 @@ +"""Private implementation details for the secure package.""" diff --git a/secure/_internal/configured_headers.py b/secure/_internal/configured_headers.py new file mode 100644 index 0000000..d8b0c4c --- /dev/null +++ b/secure/_internal/configured_headers.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from types import MappingProxyType +from typing import TYPE_CHECKING, Any, TypeAlias, overload + +from ..headers.base_header import BaseHeader +from ..headers.custom_header import CustomHeader +from .types import HeaderItems, HeaderPair + +if TYPE_CHECKING: + from collections.abc import Callable, Iterable + +HeaderInput: TypeAlias = BaseHeader | HeaderPair | list[str] +HEADER_PAIR_SIZE = 2 + + +class ConfiguredHeaders(list[BaseHeader]): + """Mutable header builder collection with centralized mutation bookkeeping.""" + + def __init__( + self, + headers: Iterable[HeaderInput] = (), + *, + on_change: Callable[[], None], + ) -> None: + self._on_change = on_change + super().__init__(_coerce_header_object(header, operation="ConfiguredHeaders") for header in headers) + + def replace_all(self, headers: Iterable[HeaderInput]) -> None: + replacement = [_coerce_header_object(header, operation="headers_list") for header in headers] + super().clear() + super().extend(replacement) + self._on_change() + + def append(self, header: HeaderInput) -> None: + super().append(_coerce_header_object(header, operation="headers_list.append")) + self._on_change() + + def extend(self, headers: Iterable[HeaderInput]) -> None: + super().extend(_coerce_header_object(header, operation="headers_list.extend") for header in headers) + self._on_change() + + def insert(self, index: int, header: HeaderInput) -> None: + super().insert(index, _coerce_header_object(header, operation="headers_list.insert")) + self._on_change() + + @overload + def __setitem__(self, index: int, header: HeaderInput) -> None: ... + + @overload + def __setitem__(self, index: slice, header: Iterable[HeaderInput]) -> None: ... + + def __setitem__(self, index: int | slice, header: HeaderInput | Iterable[HeaderInput]) -> None: + if isinstance(index, slice): + super().__setitem__( + index, + [_coerce_header_object(item, operation="headers_list assignment") for item in header], + ) + else: + super().__setitem__(index, _coerce_header_object(header, operation="headers_list assignment")) + self._on_change() + + def __delitem__(self, index: int | slice) -> None: + super().__delitem__(index) + self._on_change() + + def clear(self) -> None: + super().clear() + self._on_change() + + def pop(self, index: int = -1) -> BaseHeader: + header = super().pop(index) + self._on_change() + return header + + def remove(self, header: BaseHeader) -> None: + super().remove(header) + self._on_change() + + def reverse(self) -> None: + super().reverse() + self._on_change() + + def sort(self, *, key: Callable[[BaseHeader], Any] | None = None, reverse: bool = False) -> None: + super().sort(key=key, reverse=reverse) + self._on_change() + + def __iadd__(self, headers: Iterable[HeaderInput]) -> ConfiguredHeaders: + self.extend(headers) + return self + + +def header_items_from_objects(headers: Iterable[BaseHeader]) -> HeaderItems: + return tuple((header.header_name, header.header_value) for header in headers) + + +def header_mapping_from_items(items: HeaderItems) -> MappingProxyType[str, str]: + headers: dict[str, str] = {} + seen_names: set[str] = set() + + for name, value in items: + lowered_name = name.lower() + if lowered_name in seen_names: + raise ValueError(f"Multiple '{name}' headers present; use `header_items()` when emitting multiples.") + seen_names.add(lowered_name) + headers[name] = value + + return MappingProxyType(headers) + + +def _coerce_header_object(header: HeaderInput, *, operation: str) -> BaseHeader: + if isinstance(header, BaseHeader): + return header + + if (isinstance(header, tuple) and len(header) == HEADER_PAIR_SIZE) or ( + isinstance(header, list) and len(header) == HEADER_PAIR_SIZE + ): + name, value = header + if isinstance(name, str) and isinstance(value, str): + return CustomHeader(header=name, value=value) + + raise TypeError(f"{operation} expects BaseHeader objects or (name, value) pairs") diff --git a/secure/_internal/constants.py b/secure/_internal/constants.py new file mode 100644 index 0000000..8bdb8ed --- /dev/null +++ b/secure/_internal/constants.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import re + +# Headers that may appear multiple times as separate fields. +MULTI_OK: frozenset[str] = frozenset( + { + "content-security-policy", + } +) + +# Headers where RFC7230-style comma merging is safe/expected. +COMMA_JOIN_OK: frozenset[str] = frozenset({"cache-control"}) + +# A default allowlist of secure headers. +DEFAULT_ALLOWED_HEADERS: frozenset[str] = frozenset( + { + "cache-control", + "content-security-policy", + "content-security-policy-report-only", + "cross-origin-embedder-policy", + "cross-origin-opener-policy", + "cross-origin-resource-policy", + "origin-agent-cluster", + "permissions-policy", + "referrer-policy", + "server", + "strict-transport-security", + "x-content-type-options", + "x-dns-prefetch-control", + "x-download-options", + "x-frame-options", + "x-permitted-cross-domain-policies", + "x-xss-protection", + } +) + +# RFC 7230 token (visible ASCII except separators). +HEADER_NAME_RE = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$") diff --git a/secure/_internal/emit.py b/secure/_internal/emit.py new file mode 100644 index 0000000..6369a95 --- /dev/null +++ b/secure/_internal/emit.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import inspect +from typing import TYPE_CHECKING, cast + +if TYPE_CHECKING: + from collections.abc import Callable, MutableMapping + + from .types import HeaderItems, ResponseProtocol + + +class HeaderSetError(RuntimeError): + """Raised when applying a header to a response fails.""" + + +def set_headers_sync(response: ResponseProtocol, items: HeaderItems) -> None: + """ + Apply header items to a sync response object. + """ + if hasattr(response, "set_header"): + _apply_sync_setter( + response.set_header, + items, + coroutine_function_error=( + "Async 'set_header' detected in sync context. Use 'await set_headers_async(response)'." + ), + awaitable_error=( + "Async 'set_header' returned awaitable in sync context. Use 'await set_headers_async(response)'." + ), + ) + return + + _set_headers_container_sync(_get_headers_container(response), items) + + +async def set_headers_async(response: ResponseProtocol, items: HeaderItems) -> None: + """ + Apply header items to a sync or async response object from async code. + """ + if hasattr(response, "set_header"): + await _apply_async_setter(response.set_header, items) + return + + await _set_headers_container_async(_get_headers_container(response), items) + + +def _get_headers_container(response: ResponseProtocol) -> object: + if hasattr(response, "headers"): + return cast("object", response.headers) + raise AttributeError("Response object does not support setting headers.") + + +def _set_headers_container_sync(headers: object, items: HeaderItems) -> None: + set_fn = getattr(headers, "set", None) + if callable(set_fn): + _apply_sync_setter( + set_fn, + items, + coroutine_function_error=( + "Async headers setter detected in sync context. Use 'await set_headers_async(response)'." + ), + awaitable_error=( + "Async headers setter returned awaitable in sync context. Use 'await set_headers_async(response)'." + ), + ) + return + + _set_headers_mapping_sync(headers, items) + + +async def _set_headers_container_async(headers: object, items: HeaderItems) -> None: + set_fn = getattr(headers, "set", None) + if callable(set_fn): + await _apply_async_setter(set_fn, items) + return + + await _set_headers_mapping_async(headers, items) + + +def _apply_sync_setter( + setter: object, + items: HeaderItems, + *, + coroutine_function_error: str, + awaitable_error: str, +) -> None: + if inspect.iscoroutinefunction(setter): + raise RuntimeError(coroutine_function_error) + + typed_setter = cast("Callable[[str, str], object]", setter) + + try: + for name, value in items: + result = typed_setter(name, value) + if inspect.isawaitable(result): + raise RuntimeError(awaitable_error) + except (TypeError, ValueError, AttributeError) as error: + raise HeaderSetError(f"Failed to set headers: {error}") from error + + +async def _apply_async_setter(setter: object, items: HeaderItems) -> None: + typed_setter = cast("Callable[[str, str], object]", setter) + + try: + for name, value in items: + result = typed_setter(name, value) + if inspect.isawaitable(result): + await result + except (TypeError, ValueError, AttributeError) as error: + raise HeaderSetError(f"Failed to set headers: {error}") from error + + +def _set_headers_mapping_sync(headers: object, items: HeaderItems) -> None: + setitem = getattr(headers, "__setitem__", None) + if not callable(setitem): + raise AttributeError( # noqa: TRY004 + "Response object has .headers but it does not support setting header values." + ) + + if inspect.iscoroutinefunction(setitem): + raise RuntimeError("Async headers mapping detected in sync context. Use 'await set_headers_async(response)'.") + + try: + headers_map = cast("MutableMapping[str, str]", headers) + for name, value in items: + headers_map[name] = value + except (TypeError, ValueError, AttributeError) as error: + raise HeaderSetError(f"Failed to set headers: {error}") from error + + +async def _set_headers_mapping_async(headers: object, items: HeaderItems) -> None: + setitem = getattr(headers, "__setitem__", None) + if not callable(setitem): + raise AttributeError( # noqa: TRY004 + "Response object has .headers but it does not support setting header values." + ) + + await _apply_async_setter(setitem, items) diff --git a/secure/_internal/normalize.py b/secure/_internal/normalize.py new file mode 100644 index 0000000..ee239c8 --- /dev/null +++ b/secure/_internal/normalize.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +from dataclasses import dataclass +import logging +from types import MappingProxyType +from typing import TYPE_CHECKING + +from .constants import HEADER_NAME_RE + +if TYPE_CHECKING: + from collections.abc import Iterable, Mapping + + from .types import OnInvalidPolicy + +SPACE_CODEPOINT = 0x20 +VISIBLE_CHAR_MIN = 0x21 +VISIBLE_CHAR_MAX = 0x7E +OBS_TEXT_MIN = 0x80 +OBS_TEXT_MAX = 0xFF + + +@dataclass(frozen=True) +class _NormalizationOptions: + on_invalid: OnInvalidPolicy + strict: bool + allow_obs_text: bool + logger: logging.Logger + + +def normalize_header_items( + items: Iterable[tuple[str, str]], + *, + on_invalid: OnInvalidPolicy = "drop", + strict: bool = False, + allow_obs_text: bool = False, + logger: logging.Logger | None = None, +) -> Mapping[str, str]: + """ + Validate and normalize header items into a single-valued immutable mapping. + """ + options = _NormalizationOptions( + on_invalid=on_invalid, + strict=strict, + allow_obs_text=allow_obs_text, + logger=logger or logging.getLogger(__name__), + ) + cleaned: dict[str, str] = {} + seen_lc: set[str] = set() + + for name, value in items: + pair = _validate_header_pair( + name, + value, + options=options, + ) + if pair is None: + continue + + normalized_name, normalized_value = pair + lowered_name = normalized_name.lower() + + if lowered_name in seen_lc: + raise ValueError( + f"Duplicate header {normalized_name!r} encountered during normalization. " + "Run deduplicate_headers() first or use header_items() for multi-valued headers." + ) + + seen_lc.add(lowered_name) + cleaned[normalized_name] = normalized_value + + return MappingProxyType(cleaned) + + +def _validate_header_pair( + name: str, + value: str, + *, + options: _NormalizationOptions, +) -> tuple[str, str] | None: + normalized_name = name.strip() + + if not HEADER_NAME_RE.match(normalized_name): + _handle_invalid( + f"Invalid header name {normalized_name!r} (RFC 7230 token required)", + options=options, + ) + return None + + if value.startswith((" ", "\t")): + _handle_invalid( + f"Header {normalized_name!r} starts with forbidden whitespace", + options=options, + ) + return None + + normalized_value = value + if ("\r" in normalized_value) or ("\n" in normalized_value): + if options.strict: + raise ValueError(f"Header {normalized_name!r} contained CR/LF") + normalized_value = " ".join(normalized_value.splitlines()) + + normalized_value = normalized_value.strip() + if not normalized_value: + _handle_invalid( + f"Dropping header {normalized_name!r}: empty value", + options=options, + ) + return None + + if _value_is_allowed(normalized_value, allow_obs_text=options.allow_obs_text): + return normalized_name, normalized_value + + sanitized_value = _sanitize_value( + normalized_name, + normalized_value, + strict=options.strict, + allow_obs_text=options.allow_obs_text, + ) + if not sanitized_value: + _handle_invalid( + f"Dropping header {normalized_name!r}: empty after sanitization", + options=options, + ) + return None + + return normalized_name, sanitized_value + + +def _handle_invalid(message: str, *, options: _NormalizationOptions) -> None: + if options.on_invalid == "warn": + options.logger.warning(message) + elif options.on_invalid == "raise": + raise ValueError(message) + + +def _value_is_allowed(value: str, *, allow_obs_text: bool) -> bool: + return all(_is_allowed_value_char(char, allow_obs_text=allow_obs_text) for char in value) + + +def _is_allowed_value_char(char: str, *, allow_obs_text: bool) -> bool: + codepoint = ord(char) + return ( + char == "\t" + or codepoint == SPACE_CODEPOINT + or VISIBLE_CHAR_MIN <= codepoint <= VISIBLE_CHAR_MAX + or (allow_obs_text and OBS_TEXT_MIN <= codepoint <= OBS_TEXT_MAX) + ) + + +def _sanitize_value(name: str, value: str, *, strict: bool, allow_obs_text: bool) -> str: + sanitized_characters: list[str] = [] + + for char in value: + codepoint = ord(char) + if _is_allowed_value_char(char, allow_obs_text=allow_obs_text): + sanitized_characters.append(char) + continue + + if strict: + raise ValueError(f"Header {name!r} contains disallowed char U+{codepoint:04X}") + + sanitized_characters.append(" ") + + return "".join(sanitized_characters).strip() diff --git a/secure/_internal/policy.py b/secure/_internal/policy.py new file mode 100644 index 0000000..dbabd5a --- /dev/null +++ b/secure/_internal/policy.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +from collections import defaultdict +import logging +from typing import TYPE_CHECKING + +from ..headers.base_header import BaseHeader +from ..headers.custom_header import CustomHeader + +if TYPE_CHECKING: + from collections.abc import Iterable + + from .types import DeduplicateAction, OnUnexpectedPolicy + + +def deduplicate_header_objects( + headers: Iterable[BaseHeader], + *, + action: DeduplicateAction = "raise", + comma_join_ok: frozenset[str], + multi_ok: frozenset[str], + logger: logging.Logger | None = None, +) -> list[BaseHeader]: + """ + Deduplicate header objects while preserving stable header ordering. + """ + log = logger or logging.getLogger(__name__) + groups: dict[str, list[tuple[int, BaseHeader]]] = defaultdict(list) + + for index, header in enumerate(headers): + validated_header = _require_header_object(header, operation="deduplicate_headers") + groups[validated_header.header_name.lower()].append((index, validated_header)) + + ordered_keys = sorted(groups.keys(), key=lambda key: groups[key][0][0]) + deduplicated_headers: list[BaseHeader] = [] + duplicate_errors: list[str] = [] + + for lowered_name in ordered_keys: + entries = groups[lowered_name] + + if len(entries) == 1: + _, header = entries[0] + deduplicated_headers.append(_clone_as_custom_header(header.header_name, header.header_value)) + continue + + if lowered_name in multi_ok: + for _, header in entries: + deduplicated_headers.append(_clone_as_custom_header(header.header_name, header.header_value)) + continue + + resolved_headers, error_name = _resolve_duplicate_headers( + lowered_name, + entries, + action=action, + comma_join_ok=comma_join_ok, + logger=log, + ) + deduplicated_headers.extend(resolved_headers) + + if error_name is not None: + duplicate_errors.append(error_name) + + if duplicate_errors: + names = ", ".join(sorted(set(duplicate_errors))) + raise ValueError(f"Duplicate header(s) not allowed: {names}. Define each at most once.") + + return deduplicated_headers + + +def allowlist_header_objects( # noqa: PLR0913 + headers: Iterable[BaseHeader], + *, + allowed: Iterable[str], + allow_extra: Iterable[str] | None = None, + on_unexpected: OnUnexpectedPolicy = "raise", + allow_x_prefixed: bool = False, + logger: logging.Logger | None = None, +) -> list[BaseHeader]: + """ + Filter header objects against a case-insensitive allowlist. + """ + log = logger or logging.getLogger(__name__) + allowed_lc = {header.lower() for header in allowed} + if allow_extra: + allowed_lc.update(header.lower() for header in allow_extra) + + kept: list[BaseHeader] = [] + unexpected_names: list[str] = [] + + for header in headers: + validated_header = _require_header_object(header, operation="allowlist_headers") + header_name = validated_header.header_name + lowered_name = header_name.lower() + + if _is_allowed_header_name( + lowered_name, + allowed=allowed_lc, + allow_x_prefixed=allow_x_prefixed, + ): + kept.append(validated_header) + continue + + if on_unexpected == "warn": + log.warning("Unexpected header %r kept (not in allowlist)", header_name) + kept.append(validated_header) + elif on_unexpected == "drop": + log.warning("Unexpected header %r dropped (not in allowlist)", header_name) + else: + unexpected_names.append(header_name) + + if unexpected_names: + names = ", ".join(sorted(set(unexpected_names))) + raise ValueError( + f"Unexpected header(s) not in allowlist: {names}. Enable allow_extra or set on_unexpected to 'drop'/'warn'." + ) + + return kept + + +def _require_header_object(header: object, *, operation: str) -> BaseHeader: + if not isinstance(header, BaseHeader): + raise TypeError(f"{operation}() requires BaseHeader objects only") + return header + + +def _clone_as_custom_header(name: str, value: str) -> BaseHeader: + return CustomHeader(header=name, value=value) + + +def _resolve_duplicate_headers( + lowered_name: str, + entries: list[tuple[int, BaseHeader]], + *, + action: DeduplicateAction, + comma_join_ok: frozenset[str], + logger: logging.Logger, +) -> tuple[list[BaseHeader], str | None]: + if action == "first": + _, header = entries[0] + if len(entries) > 1: + logger.warning("Dropping duplicate header(s) for %r (keeping first)", header.header_name) + return [_clone_as_custom_header(header.header_name, header.header_value)], None + + if action == "last": + _, header = entries[-1] + if len(entries) > 1: + logger.warning("Dropping duplicate header(s) for %r (keeping last)", header.header_name) + return [_clone_as_custom_header(header.header_name, header.header_value)], None + + if action == "concat": + if lowered_name in comma_join_ok: + name = entries[0][1].header_name + joined_value = ", ".join(header.header_value for _, header in entries) + return [_clone_as_custom_header(name, joined_value)], None + + return [], entries[0][1].header_name + + return [], entries[0][1].header_name + + +def _is_allowed_header_name( + lowered_name: str, + *, + allowed: set[str], + allow_x_prefixed: bool, +) -> bool: + return lowered_name in allowed or (allow_x_prefixed and lowered_name.startswith("x-")) diff --git a/secure/_internal/presets.py b/secure/_internal/presets.py new file mode 100644 index 0000000..80ab566 --- /dev/null +++ b/secure/_internal/presets.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +from enum import Enum + +from ..headers import ( + CacheControl, + ContentSecurityPolicy, + CrossOriginEmbedderPolicy, + CrossOriginOpenerPolicy, + CrossOriginResourcePolicy, + CustomHeader, + PermissionsPolicy, + ReferrerPolicy, + Server, + StrictTransportSecurity, + XContentTypeOptions, + XDnsPrefetchControl, + XFrameOptions, + XPermittedCrossDomainPolicies, +) + + +class Preset(Enum): + """Predefined security header presets for :class:`Secure`.""" + + BASIC = "basic" + BALANCED = "balanced" + STRICT = "strict" + + +def _baseline_content_security_policy() -> ContentSecurityPolicy: + """Shared CSP builder used by the BASIC and BALANCED presets.""" + return ( + ContentSecurityPolicy() + .default_src("'self'") + .base_uri("'self'") + .font_src("'self'", "https:", "data:") + .form_action("'self'") + .frame_ancestors("'self'") + .img_src("'self'", "data:") + .object_src("'none'") + .script_src("'self'") + .script_src_attr("'none'") + .style_src("'self'", "https:", "'unsafe-inline'") + .upgrade_insecure_requests() + ) + + +def preset_kwargs(preset: Preset) -> dict[str, object]: + """Return constructor kwargs for a predefined :class:`Secure` preset.""" + match preset: + case Preset.BASIC: + return { + "coop": CrossOriginOpenerPolicy().same_origin(), + "csp": _baseline_content_security_policy(), + "corp": CrossOriginResourcePolicy().same_origin(), + "hsts": StrictTransportSecurity().max_age(31536000).include_subdomains(), + "referrer": ReferrerPolicy().no_referrer(), + "xcto": XContentTypeOptions().nosniff(), + "xfo": XFrameOptions().sameorigin(), + "xdfc": XDnsPrefetchControl().disable(), + "xpcdp": XPermittedCrossDomainPolicies().none(), + "custom": [ + CustomHeader( + header="Origin-Agent-Cluster", + value="?1", + ), + CustomHeader( + header="X-Download-Options", + value="noopen", + ), + CustomHeader( + header="X-XSS-Protection", + value="0", + ), + ], + } + case Preset.BALANCED: + return { + "coop": CrossOriginOpenerPolicy().same_origin(), + "corp": CrossOriginResourcePolicy().same_origin(), + "csp": _baseline_content_security_policy(), + "hsts": StrictTransportSecurity().max_age(31536000).include_subdomains(), + "permissions": PermissionsPolicy().geolocation().microphone().camera(), + "referrer": ReferrerPolicy().strict_origin_when_cross_origin(), + "server": Server().set(""), + "xcto": XContentTypeOptions().nosniff(), + "xfo": XFrameOptions().sameorigin(), + } + case Preset.STRICT: + return { + "cache": CacheControl().no_store().max_age(0), + "coep": CrossOriginEmbedderPolicy().require_corp(), + "coop": CrossOriginOpenerPolicy().same_origin(), + "csp": ( + ContentSecurityPolicy() + .default_src("'self'") + .script_src("'self'") + .style_src("'self'") + .object_src("'none'") + .base_uri("'none'") + .frame_ancestors("'none'") + ), + "hsts": StrictTransportSecurity().max_age(63072000).include_subdomains(), + "permissions": PermissionsPolicy().geolocation().microphone().camera(), + "referrer": ReferrerPolicy().no_referrer(), + "server": Server().set(""), + "xcto": XContentTypeOptions().nosniff(), + "xfo": XFrameOptions().deny(), + } + case _: + raise ValueError(f"Unknown preset: {preset}") diff --git a/secure/_internal/types.py b/secure/_internal/types.py new file mode 100644 index 0000000..330301a --- /dev/null +++ b/secure/_internal/types.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from typing import Literal, Protocol, TypeAlias + +OnInvalidPolicy = Literal["drop", "warn", "raise"] +DeduplicateAction = Literal["raise", "first", "last", "concat"] +OnUnexpectedPolicy = Literal["raise", "drop", "warn"] + + +class HeadersProtocol(Protocol): + @property + def headers(self) -> object: ... + + +class SetHeaderProtocol(Protocol): + def set_header(self, key: str, value: str) -> object | None: ... + + +ResponseProtocol: TypeAlias = HeadersProtocol | SetHeaderProtocol +HeaderPair: TypeAlias = tuple[str, str] +HeaderItems: TypeAlias = tuple[HeaderPair, ...] diff --git a/secure/headers/server.py b/secure/headers/server.py index c9da31c..9c8c87a 100644 --- a/secure/headers/server.py +++ b/secure/headers/server.py @@ -58,10 +58,10 @@ def value(self, value: str) -> Server: def clear(self) -> Server: """ - Reset the `Server` header value to its default (`NULL`). + Reset the `Server` header value to its default (an empty string). - This method clears any custom value that has been set for the `Server` header - and reverts it to the default, which is a more secure value that hides server details. + This method clears any custom value that has been set for the `Server` + header and reverts it to the default, which hides server details. Returns: Server: The current instance, allowing for method chaining. diff --git a/secure/middleware/asgi.py b/secure/middleware/asgi.py index 3bc38b4..b228eaa 100644 --- a/secure/middleware/asgi.py +++ b/secure/middleware/asgi.py @@ -81,7 +81,7 @@ class SecureASGIMiddleware: multi_ok: Header names allowed to appear multiple times in a response. For these, Secure's value is appended instead of overwriting. Defaults to - :data:`secure.secure.MULTI_OK`. + :data:`secure.MULTI_OK`. Behavior -------- diff --git a/secure/middleware/wsgi.py b/secure/middleware/wsgi.py index cf99d36..6e11e30 100644 --- a/secure/middleware/wsgi.py +++ b/secure/middleware/wsgi.py @@ -64,7 +64,7 @@ class SecureWSGIMiddleware: multi_ok: Header names allowed to appear multiple times in a response. For these, Secure's value is appended instead of overwriting. Defaults to - :data:`secure.secure.MULTI_OK`. + :data:`secure.MULTI_OK`. Behavior -------- diff --git a/secure/secure.py b/secure/secure.py index f14a871..cd1b86c 100644 --- a/secure/secure.py +++ b/secure/secure.py @@ -1,131 +1,38 @@ from __future__ import annotations -from collections import defaultdict -from enum import Enum -from functools import cached_property -import inspect import logging -import re -from types import MappingProxyType -from typing import TYPE_CHECKING, Literal, Protocol, TypeAlias, cast +from typing import TYPE_CHECKING if TYPE_CHECKING: - from collections.abc import Callable, Iterable, Mapping, MutableMapping - -from .headers import ( - BaseHeader, - CacheControl, - ContentSecurityPolicy, - CrossOriginEmbedderPolicy, - CrossOriginOpenerPolicy, - CrossOriginResourcePolicy, - CustomHeader, - PermissionsPolicy, - ReferrerPolicy, - Server, - StrictTransportSecurity, - XContentTypeOptions, - XDnsPrefetchControl, - XFrameOptions, - XPermittedCrossDomainPolicies, -) - -# --------------------------------------------------------------------------- -# Configuration / constants -# --------------------------------------------------------------------------- - -# Headers that may appear multiple times as separate fields. -MULTI_OK: frozenset[str] = frozenset( - { - "content-security-policy", - } -) - -# Headers where RFC7230-style comma merging is safe/expected. -COMMA_JOIN_OK: frozenset[str] = frozenset({"cache-control"}) - -# A default allowlist of secure headers. -DEFAULT_ALLOWED_HEADERS: frozenset[str] = frozenset( - { - "cache-control", - "content-security-policy", - "content-security-policy-report-only", - "cross-origin-embedder-policy", - "cross-origin-opener-policy", - "cross-origin-resource-policy", - "origin-agent-cluster", - "permissions-policy", - "referrer-policy", - "strict-transport-security", - "x-content-type-options", - "x-dns-prefetch-control", - "x-download-options", - "x-frame-options", - "x-permitted-cross-domain-policies", - "x-xss-protection", - } -) - -# RFC 7230 token (visible ASCII except separators). -HEADER_NAME_RE = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$") - -OnInvalidPolicy = Literal["drop", "warn", "raise"] -DeduplicateAction = Literal["raise", "first", "last", "concat"] -OnUnexpectedPolicy = Literal["raise", "drop", "warn"] - - -# --------------------------------------------------------------------------- -# Protocols / errors -# --------------------------------------------------------------------------- - - -class HeaderSetError(RuntimeError): - """Raised when applying a header to a response fails.""" - - -class HeadersProtocol(Protocol): - @property - def headers(self) -> object: ... - - -class SetHeaderProtocol(Protocol): - def set_header(self, key: str, value: str) -> object | None: ... - - -# Union type for supported response objects. -ResponseProtocol: TypeAlias = HeadersProtocol | SetHeaderProtocol - - -# --------------------------------------------------------------------------- -# Presets -# --------------------------------------------------------------------------- - - -class Preset(Enum): - """Predefined security header presets for :class:`Secure`.""" - - BASIC = "basic" - BALANCED = "balanced" - STRICT = "strict" - - -def _baseline_content_security_policy() -> ContentSecurityPolicy: - """Shared CSP builder used by the BASIC and BALANCED presets.""" - return ( - ContentSecurityPolicy() - .default_src("'self'") - .base_uri("'self'") - .font_src("'self'", "https:", "data:") - .form_action("'self'") - .frame_ancestors("'self'") - .img_src("'self'", "data:") - .object_src("'none'") - .script_src("'self'") - .script_src_attr("'none'") - .style_src("'self'", "https:", "'unsafe-inline'") - .upgrade_insecure_requests() + from collections.abc import Iterable, Mapping + + from ._internal.types import DeduplicateAction, HeaderItems, OnInvalidPolicy, OnUnexpectedPolicy, ResponseProtocol + from .headers import ( + BaseHeader, + CacheControl, + ContentSecurityPolicy, + CrossOriginEmbedderPolicy, + CrossOriginOpenerPolicy, + CrossOriginResourcePolicy, + CustomHeader, + PermissionsPolicy, + ReferrerPolicy, + Server, + StrictTransportSecurity, + XContentTypeOptions, + XDnsPrefetchControl, + XFrameOptions, + XPermittedCrossDomainPolicies, ) +from ._internal import emit as _emit +from ._internal.configured_headers import ConfiguredHeaders, header_items_from_objects, header_mapping_from_items +from ._internal.constants import COMMA_JOIN_OK, DEFAULT_ALLOWED_HEADERS, MULTI_OK +from ._internal.normalize import normalize_header_items +from ._internal.policy import allowlist_header_objects, deduplicate_header_objects +from ._internal.presets import Preset, preset_kwargs + +HeaderSetError = _emit.HeaderSetError # --------------------------------------------------------------------------- # Core API @@ -136,9 +43,9 @@ class Secure: """ Configure and apply HTTP security headers for web applications. - A :class:`Secure` instance encapsulates a set of header objects that can be - applied to response objects from common Python web frameworks (FastAPI, - Starlette, Flask, Django, etc.). + A :class:`Secure` instance is the library's public facade. It encapsulates a + set of typed header builders and applies them to response objects from common + Python web frameworks (FastAPI, Starlette, Flask, Django, etc.). Typical pipeline: @@ -212,9 +119,7 @@ def __init__( # noqa: PLR0913 xfo : X-Frame-Options header configuration. """ - self.headers_list: list[BaseHeader] = [] - - params: list[BaseHeader | None] = [ + params: tuple[BaseHeader | None, ...] = ( cache, coep, coop, @@ -228,16 +133,41 @@ def __init__( # noqa: PLR0913 server, xcto, xfo, - ] - - for header in params: - if header is not None: - self.headers_list.append(header) + ) + configured_headers = [header for header in params if header is not None] if custom: - self.headers_list.extend(custom) + configured_headers.extend(custom) + + self._headers = ConfiguredHeaders(configured_headers, on_change=self._discard_normalized_headers) + self._normalized_headers: Mapping[str, str] | None = None + self._normalized_source_items: HeaderItems | None = None + + @property + def headers_list(self) -> list[BaseHeader]: + """Mutable ordered list of configured header builders.""" + return self._headers + + @headers_list.setter + def headers_list(self, headers: Iterable[BaseHeader]) -> None: + self._headers.replace_all(headers) + + def _discard_normalized_headers(self) -> None: + self._normalized_headers = None + self._normalized_source_items = None + + def _normalized_headers_for( + self, + header_items: HeaderItems, + ) -> Mapping[str, str] | None: + if self._normalized_headers is None: + return None + + if self._normalized_source_items != header_items: + self._discard_normalized_headers() + return None - self._headers_override: Mapping[str, str] | None = None + return self._normalized_headers @classmethod def with_default_headers(cls) -> Secure: @@ -265,8 +195,8 @@ def from_preset(cls, preset: Preset) -> Secure: ---------- preset : The security preset to use, for example :data:`Preset.BALANCED` for the - recommended default profile, :data:`Preset.BASIC` for Helmet-parity - behavior, or :data:`Preset.STRICT` for a hardened configuration with + recommended default profile, :data:`Preset.BASIC` for compatibility- + oriented behavior, or :data:`Preset.STRICT` for a hardened configuration with stronger guarantees. Returns @@ -279,70 +209,7 @@ def from_preset(cls, preset: Preset) -> Secure: ValueError If an unknown preset is provided. """ - match preset: - case Preset.BASIC: - return cls( - coop=CrossOriginOpenerPolicy().same_origin(), - csp=_baseline_content_security_policy(), - corp=CrossOriginResourcePolicy().same_origin(), - hsts=StrictTransportSecurity().max_age(31536000).include_subdomains(), - referrer=ReferrerPolicy().no_referrer(), - xcto=XContentTypeOptions().nosniff(), - xfo=XFrameOptions().sameorigin(), - xdfc=XDnsPrefetchControl().disable(), - xpcdp=XPermittedCrossDomainPolicies().none(), - custom=[ - CustomHeader( - header="Origin-Agent-Cluster", - value="?1", - ), - CustomHeader( - header="X-Download-Options", - value="noopen", - ), - CustomHeader( - header="X-XSS-Protection", - value="0", - ), - ], - ) - case Preset.BALANCED: - return cls( - coop=CrossOriginOpenerPolicy().same_origin(), - corp=CrossOriginResourcePolicy().same_origin(), - csp=_baseline_content_security_policy(), - hsts=StrictTransportSecurity().max_age(31536000).include_subdomains(), - permissions=PermissionsPolicy().geolocation().microphone().camera(), - referrer=ReferrerPolicy().strict_origin_when_cross_origin(), - server=Server().set(""), - xcto=XContentTypeOptions().nosniff(), - xfo=XFrameOptions().sameorigin(), - ) - - case Preset.STRICT: - return cls( - cache=CacheControl().no_store().max_age(0), - coep=CrossOriginEmbedderPolicy().require_corp(), - coop=CrossOriginOpenerPolicy().same_origin(), - csp=( - ContentSecurityPolicy() - .default_src("'self'") - .script_src("'self'") - .style_src("'self'") - .object_src("'none'") - .base_uri("'none'") - .frame_ancestors("'none'") - ), - hsts=StrictTransportSecurity().max_age(63072000).include_subdomains(), - permissions=PermissionsPolicy().geolocation().microphone().camera(), - referrer=ReferrerPolicy().no_referrer(), - server=Server().set(""), - xcto=XContentTypeOptions().nosniff(), - xfo=XFrameOptions().deny(), - ) - - case _: - raise ValueError(f"Unknown preset: {preset}") + return cls(**preset_kwargs(preset)) def __str__(self) -> str: """Return a human-readable listing of headers and their effective values.""" @@ -356,7 +223,7 @@ def __repr__(self) -> str: # Header normalization / safety helpers # ------------------------------------------------------------------ - def validate_and_normalize_headers( # noqa: PLR0915 + def validate_and_normalize_headers( self, *, on_invalid: OnInvalidPolicy = "drop", @@ -370,8 +237,8 @@ def validate_and_normalize_headers( # noqa: PLR0915 This operates on :meth:`header_items` (not ``headers_list`` directly) to preserve ordering, multi-valued behavior, and any prior deduplication. - The resulting mapping is stored as an internal override that is returned - by :attr:`headers`. + The resulting mapping is stored as a normalized snapshot that is returned + by :attr:`headers` until the configured headers change. Parameters ---------- @@ -401,112 +268,15 @@ def validate_and_normalize_headers( # noqa: PLR0915 if duplicates are found when building the single-valued mapping, or if ``strict=True`` and CR/LF or disallowed characters are present. """ - log = logger or logging.getLogger(__name__) - - # Visible ASCII per RFCs. - sp = 0x20 - vchar_min, vchar_max = 0x21, 0x7E - obs_min, obs_max = 0x80, 0xFF - - def _handle_invalid(msg: str) -> None: - if on_invalid == "warn": - log.warning(msg) - elif on_invalid == "raise": - raise ValueError(msg) - # on_invalid == "drop": silently drop - - def _validate_pair(name: str, value: str) -> tuple[str, str] | None: # noqa: PLR0912 - strict_flag = bool(strict) - name = name.strip() - - if not HEADER_NAME_RE.match(name): - _handle_invalid(f"Invalid header name {name!r} (RFC 7230 token required)") - return None - - # Prevent folded-header smuggling. - if value.startswith((" ", "\t")): - _handle_invalid(f"Header {name!r} starts with forbidden whitespace") - return None - - # CR/LF must never appear in values. - if ("\r" in value) or ("\n" in value): - if strict_flag: - raise ValueError(f"Header {name!r} contained CR/LF") - value = " ".join(value.splitlines()) - - value = value.strip() - if not value: - _handle_invalid(f"Dropping header {name!r}: empty value") - return None - - _allow_obs = allow_obs_text - - needs_sanitize = False - for ch in value: - code = ord(ch) - if not ( - ch == "\t" - or code == sp - or (vchar_min <= code <= vchar_max) - or (_allow_obs and (obs_min <= code <= obs_max)) - ): - needs_sanitize = True - break - - if not needs_sanitize: - return name, value - - sanitized: list[str] = [] - append = sanitized.append - - for ch in value: - code = ord(ch) - if ( - ch == "\t" - or code == sp - or (vchar_min <= code <= vchar_max) - or (_allow_obs and (obs_min <= code <= obs_max)) - ): - append(ch) - else: - if strict_flag: - raise ValueError(f"Header {name!r} contains disallowed char U+{code:04X}") - append(" ") - - norm_value = "".join(sanitized).strip() - if not norm_value: - _handle_invalid(f"Dropping header {name!r}: empty after sanitization") - return None - - return name, norm_value - - items = self.header_items() - - cleaned: dict[str, str] = {} - seen_lc: set[str] = set() - - for name, value in items: - pair = _validate_pair(name, value) - if pair is None: - continue - - norm_name, norm_value = pair - lname = norm_name.lower() - - if lname in seen_lc: - raise ValueError( - f"Duplicate header {norm_name!r} encountered during normalization. " - "Run deduplicate_headers() first or use header_items() for multi-valued headers." - ) - - seen_lc.add(lname) - cleaned[norm_name] = norm_value - - self._headers_override = MappingProxyType(cleaned) - - # Reset cached_property. - self.__dict__.pop("headers", None) - + header_items = self.header_items() + self._normalized_headers = normalize_header_items( + header_items, + on_invalid=on_invalid, + strict=strict, + allow_obs_text=allow_obs_text, + logger=logger or logging.getLogger(__name__), + ) + self._normalized_source_items = header_items return self def deduplicate_headers( @@ -548,82 +318,13 @@ def deduplicate_headers( If duplicates are found for headers that are not in ``multi_ok`` and the action is ``"raise"`` or ``"concat"`` for unsafe headers. """ - log = logger or logging.getLogger(__name__) - - # Group by lowercase name; store (first_index, BaseHeader). - groups: dict[str, list[tuple[int, BaseHeader]]] = defaultdict(list) - - for idx, h in enumerate(self.headers_list): - if not hasattr(h, "header_name") or not hasattr(h, "header_value"): - raise TypeError("deduplicate_headers() requires BaseHeader objects only") - groups[h.header_name.lower()].append((idx, h)) - - # Stable processing order by first appearance. - ordered_keys = sorted(groups.keys(), key=lambda k: groups[k][0][0]) - - def _clone(name: str, value: str) -> BaseHeader: - # Preserve type stability using CustomHeader as neutral carrier. - return CustomHeader(header=name, value=value) - - def _handle_disallowed_dupes( - lname: str, - entries: list[tuple[int, BaseHeader]], - ) -> tuple[list[BaseHeader], str | None]: - """Return (new_items, dup_error_name_if_any).""" - if action == "first": - _, h = entries[0] - if len(entries) > 1: - log.warning("Dropping duplicate header(s) for %r (keeping first)", h.header_name) - return [_clone(h.header_name, h.header_value)], None - - if action == "last": - _, h = entries[-1] - if len(entries) > 1: - log.warning("Dropping duplicate header(s) for %r (keeping last)", h.header_name) - return [_clone(h.header_name, h.header_value)], None - - if action == "concat": - if lname in comma_join_ok: - nm = entries[0][1].header_name - joined = ", ".join(h.header_value for _, h in entries) - return [_clone(nm, joined)], None - - # Not safe to join. - return [], entries[0][1].header_name - - # Default "raise". - return [], entries[0][1].header_name - - new_list: list[BaseHeader] = [] - dup_errors: list[str] = [] - - for lname in ordered_keys: - entries = groups[lname] - - if len(entries) == 1: - _, h = entries[0] - new_list.append(_clone(h.header_name, h.header_value)) - continue - - if lname in multi_ok: - for _, h in entries: - new_list.append(_clone(h.header_name, h.header_value)) - continue - - produced, err = _handle_disallowed_dupes(lname, entries) - new_list.extend(produced) - - if err is not None: - dup_errors.append(err) - - if dup_errors: - names = ", ".join(sorted(set(dup_errors))) - raise ValueError(f"Duplicate header(s) not allowed: {names}. Define each at most once.") - - self.headers_list = new_list - self._headers_override = None - self.__dict__.pop("headers", None) - + self.headers_list = deduplicate_header_objects( + self._headers, + action=action, + comma_join_ok=comma_join_ok, + multi_ok=multi_ok, + logger=logger or logging.getLogger(__name__), + ) return self def allowlist_headers( @@ -665,66 +366,24 @@ def allowlist_headers( ValueError If ``on_unexpected="raise"`` and any header is not in the allowlist. """ - log = logger or logging.getLogger(__name__) - - # Build the lowercase allowlist. - allowed_lc = {h.lower() for h in allowed} - if allow_extra: - allowed_lc.update(h.lower() for h in allow_extra) - - def _keep(name_lc: str) -> bool: - return (name_lc in allowed_lc) or (allow_x_prefixed and name_lc.startswith("x-")) - - kept: list[BaseHeader] = [] - unexpected_names: list[str] = [] - - for h in self.headers_list: - if not hasattr(h, "header_name") or not hasattr(h, "header_value"): - raise TypeError("allowlist_headers() requires BaseHeader objects only") - - name = h.header_name - lname = name.lower() - - if _keep(lname): - kept.append(h) - continue - - if on_unexpected == "warn": - log.warning("Unexpected header %r kept (not in allowlist)", name) - kept.append(h) - elif on_unexpected == "drop": - log.warning("Unexpected header %r dropped (not in allowlist)", name) - else: # "raise" (default) - unexpected_names.append(name) - - if unexpected_names: - names = ", ".join(sorted(set(unexpected_names))) - raise ValueError( - f"Unexpected header(s) not in allowlist: {names}. " - "Enable allow_extra or set on_unexpected to 'drop'/'warn'." - ) - - self.headers_list = kept - - # Invalidate any cached mapping / overrides derived from headers_list. - self._headers_override = None - self.__dict__.pop("headers", None) - + self.headers_list = allowlist_header_objects( + self._headers, + allowed=allowed, + allow_extra=allow_extra, + on_unexpected=on_unexpected, + allow_x_prefixed=allow_x_prefixed, + logger=logger or logging.getLogger(__name__), + ) return self # ------------------------------------------------------------------ # Serialization / access # ------------------------------------------------------------------ - def header_items(self) -> tuple[tuple[str, str], ...]: + def header_items(self) -> HeaderItems: """ Serialize the current headers into ``(name, value)`` pairs. - This method supports two forms in :attr:`headers_list`: - - * Header objects with ``.header_name`` and ``.header_value`` attributes. - * Tuple-like items with at least two elements (name, value). - It does not enforce uniqueness. Use :meth:`deduplicate_headers` or :meth:`validate_and_normalize_headers` when you need a single-valued mapping. @@ -734,36 +393,26 @@ def header_items(self) -> tuple[tuple[str, str], ...]: tuple[tuple[str, str], ...] Immutable sequence of ``(name, value)`` pairs. """ - header_tuple_size = 2 - items: list[tuple[str, str]] = [] - append = items.append - - for h in self.headers_list: - if hasattr(h, "header_name") and hasattr(h, "header_value"): - append((h.header_name, h.header_value)) - elif isinstance(h, (tuple, list)) and len(h) >= header_tuple_size: - append((h[0], h[1])) - else: - raise TypeError("header_items() expected elements with .header_name/.header_value or 2-tuples") - - return tuple(items) + return header_items_from_objects(self._headers) - def _resolved_header_items(self) -> tuple[tuple[str, str], ...]: + def _resolved_header_items(self) -> HeaderItems: """ Return the list of header items honoring any normalized override. """ - if self._headers_override is not None: - return tuple(self._headers_override.items()) - return self.header_items() + header_items = self.header_items() + normalized_headers = self._normalized_headers_for(header_items) + if normalized_headers is not None: + return tuple(normalized_headers.items()) + return header_items - @cached_property + @property def headers(self) -> Mapping[str, str]: """ Single-valued, immutable mapping of headers. By default, this is derived from :meth:`header_items`. If :meth:`validate_and_normalize_headers` has been called, the mapping - returned here is the normalized override produced by that method. + returned here is the normalized snapshot produced by that method. Returns ------- @@ -778,26 +427,18 @@ def headers(self) -> Mapping[str, str]: in :data:`MULTI_OK`. Use :meth:`header_items` to emit multi-valued headers or call :meth:`deduplicate_headers` first. """ - if self._headers_override is not None: - return self._headers_override - - data: dict[str, str] = {} - seen: set[str] = set() + header_items = self.header_items() + normalized_headers = self._normalized_headers_for(header_items) + if normalized_headers is not None: + return normalized_headers - for name, value in self.header_items(): - k = name.lower() - if k in seen: - raise ValueError(f"Multiple '{name}' headers present; use `header_items()` when emitting multiples.") - seen.add(k) - data[name] = value - - return MappingProxyType(data) + return header_mapping_from_items(header_items) # ------------------------------------------------------------------ # Application to framework responses # ------------------------------------------------------------------ - def set_headers(self, response: ResponseProtocol) -> None: # noqa: PLR0912 + def set_headers(self, response: ResponseProtocol) -> None: """ Apply configured headers synchronously to ``response``. @@ -827,80 +468,9 @@ def set_headers(self, response: ResponseProtocol) -> None: # noqa: PLR0912 HeaderSetError If setting an individual header fails. """ - items = self._resolved_header_items() - - # Path 1: response.set_header(...) - if hasattr(response, "set_header"): - set_header = response.set_header - - if inspect.iscoroutinefunction(set_header): - raise RuntimeError( - "Async 'set_header' detected in sync context. Use 'await set_headers_async(response)'." - ) - - try: - for name, value in items: - result = set_header(name, value) - if inspect.isawaitable(result): - raise RuntimeError( - "Async 'set_header' returned awaitable in sync context. " - "Use 'await set_headers_async(response)'." - ) - except (TypeError, ValueError, AttributeError) as e: - raise HeaderSetError(f"Failed to set headers: {e}") from e - - return - - # Path 2: response.headers... - if hasattr(response, "headers"): - hdrs = cast("object", response.headers) - - set_fn = getattr(hdrs, "set", None) - if callable(set_fn): - set_fn_typed = cast("Callable[[str, str], object]", set_fn) - - if inspect.iscoroutinefunction(set_fn_typed): - raise RuntimeError( - "Async headers setter detected in sync context. Use 'await set_headers_async(response)'." - ) - - try: - for name, value in items: - result = set_fn_typed(name, value) - if inspect.isawaitable(result): - raise RuntimeError( - "Async headers setter returned awaitable in sync context. " - "Use 'await set_headers_async(response)'." - ) - except (TypeError, ValueError, AttributeError) as e: - raise HeaderSetError(f"Failed to set headers: {e}") from e - - return - - setitem = getattr(hdrs, "__setitem__", None) - if callable(setitem): - setitem_typed = cast("Callable[[str, str], object]", setitem) - - if inspect.iscoroutinefunction(setitem_typed): - raise RuntimeError( - "Async headers mapping detected in sync context. Use 'await set_headers_async(response)'." - ) - - try: - # Use mapping assignment for the common case. - hdrs_map = cast("MutableMapping[str, str]", hdrs) - for name, value in items: - hdrs_map[name] = value - except (TypeError, ValueError, AttributeError) as e: - raise HeaderSetError(f"Failed to set headers: {e}") from e - - return - - raise AttributeError("Response object has .headers but it does not support setting header values.") - - raise AttributeError("Response object does not support setting headers.") - - async def set_headers_async(self, response: ResponseProtocol) -> None: # noqa: PLR0912 + _emit.set_headers_sync(response, self._resolved_header_items()) + + async def set_headers_async(self, response: ResponseProtocol) -> None: """ Apply configured headers asynchronously to ``response``. @@ -930,54 +500,4 @@ async def set_headers_async(self, response: ResponseProtocol) -> None: # noqa: HeaderSetError If setting an individual header fails. """ - items = self._resolved_header_items() - - # Path 1: response.set_header(...) - if hasattr(response, "set_header"): - set_header = response.set_header - - try: - for name, value in items: - result = set_header(name, value) - if inspect.isawaitable(result): - await result - except (TypeError, ValueError, AttributeError) as e: - raise HeaderSetError(f"Failed to set headers: {e}") from e - - return - - # Path 2: response.headers... - if hasattr(response, "headers"): - hdrs = cast("object", response.headers) - - set_fn = getattr(hdrs, "set", None) - if callable(set_fn): - set_fn_typed = cast("Callable[[str, str], object]", set_fn) - - try: - for name, value in items: - result = set_fn_typed(name, value) - if inspect.isawaitable(result): - await result - except (TypeError, ValueError, AttributeError) as e: - raise HeaderSetError(f"Failed to set headers: {e}") from e - - return - - setitem = getattr(hdrs, "__setitem__", None) - if callable(setitem): - setitem_typed = cast("Callable[[str, str], object]", setitem) - - try: - for name, value in items: - result = setitem_typed(name, value) - if inspect.isawaitable(result): - await result - except (TypeError, ValueError, AttributeError) as e: - raise HeaderSetError(f"Failed to set headers: {e}") from e - - return - - raise AttributeError("Response object has .headers but it does not support setting header values.") - - raise AttributeError("Response object does not support setting headers.") + await _emit.set_headers_async(response, self._resolved_header_items()) diff --git a/tests/headers/test_custom_header.py b/tests/headers/test_custom_header.py index 3989bed..0c7eb59 100644 --- a/tests/headers/test_custom_header.py +++ b/tests/headers/test_custom_header.py @@ -18,9 +18,7 @@ def test_set_custom_header_value(self): def test_method_chaining(self): """Test method chaining while setting a new value for the custom header.""" - custom_header = CustomHeader("X-Custom-Header", "initial-value").set( - "new-value" - ) + custom_header = CustomHeader("X-Custom-Header", "initial-value").set("new-value") self.assertEqual(custom_header.header_value, "new-value") diff --git a/tests/headers/test_referrer_policy.py b/tests/headers/test_referrer_policy.py index 42cb57f..a625ab9 100644 --- a/tests/headers/test_referrer_policy.py +++ b/tests/headers/test_referrer_policy.py @@ -7,9 +7,7 @@ class TestReferrerPolicy(unittest.TestCase): def test_default_referrer_policy(self): """Test default Referrer-Policy header.""" referrer_policy = ReferrerPolicy() - self.assertEqual( - referrer_policy.header_value, "strict-origin-when-cross-origin" - ) + self.assertEqual(referrer_policy.header_value, "strict-origin-when-cross-origin") def test_set_custom_policy(self): """Test setting a custom referrer policy.""" @@ -49,9 +47,7 @@ def test_strict_origin(self): def test_strict_origin_when_cross_origin(self): """Test setting the Referrer-Policy to 'strict-origin-when-cross-origin'.""" referrer_policy = ReferrerPolicy().strict_origin_when_cross_origin() - self.assertEqual( - referrer_policy.header_value, "strict-origin-when-cross-origin" - ) + self.assertEqual(referrer_policy.header_value, "strict-origin-when-cross-origin") def test_unsafe_url(self): """Test setting the Referrer-Policy to 'unsafe-url'.""" @@ -61,9 +57,7 @@ def test_unsafe_url(self): def test_clear_policy(self): """Test clearing the referrer policy directives and resetting to default.""" referrer_policy = ReferrerPolicy().set("custom-policy").clear() - self.assertEqual( - referrer_policy.header_value, "strict-origin-when-cross-origin" - ) + self.assertEqual(referrer_policy.header_value, "strict-origin-when-cross-origin") if __name__ == "__main__": diff --git a/tests/secure_tests/test_internal_helpers.py b/tests/secure_tests/test_internal_helpers.py new file mode 100644 index 0000000..a3bf6d2 --- /dev/null +++ b/tests/secure_tests/test_internal_helpers.py @@ -0,0 +1,86 @@ +import asyncio +import unittest +from unittest import mock + +from secure import DEFAULT_ALLOWED_HEADERS, MULTI_OK +from secure._internal.emit import set_headers_async +from secure._internal.normalize import normalize_header_items +from secure._internal.policy import allowlist_header_objects, deduplicate_header_objects +from secure.headers import CustomHeader + + +class _AsyncHeadersMapping: + def __init__(self) -> None: + self.storage: dict[str, str] = {} + + async def __setitem__(self, key: str, value: str) -> None: + self.storage[key] = value + + +class _AsyncHeadersResponse: + def __init__(self) -> None: + self.headers = _AsyncHeadersMapping() + + +class TestInternalHelpers(unittest.TestCase): + def test_normalize_header_items_warns_and_drops_invalid_names(self) -> None: + logger = mock.Mock() + + items = normalize_header_items( + (("Bad Header", "value"),), + on_invalid="warn", + logger=logger, + ) + + self.assertEqual(dict(items), {}) + logger.warning.assert_called_once_with("Invalid header name 'Bad Header' (RFC 7230 token required)") + + def test_normalize_header_items_preserves_obs_text_when_allowed(self) -> None: + items = normalize_header_items( + (("X-Obs-Text", "caf\xe9"),), + allow_obs_text=True, + ) + + self.assertEqual(items["X-Obs-Text"], "caf\xe9") + + def test_allowlist_header_objects_warn_keeps_unexpected_headers(self) -> None: + logger = mock.Mock() + headers = [CustomHeader("X-App-Header", "value")] + + kept = allowlist_header_objects( + headers, + allowed=DEFAULT_ALLOWED_HEADERS, + on_unexpected="warn", + logger=logger, + ) + + self.assertEqual([header.header_name for header in kept], ["X-App-Header"]) + logger.warning.assert_called_once_with("Unexpected header %r kept (not in allowlist)", "X-App-Header") + + def test_deduplicate_header_objects_preserves_multi_ok_order(self) -> None: + headers = [ + CustomHeader("Content-Security-Policy", "default-src 'self'"), + CustomHeader("Content-Security-Policy", "report-uri /csp"), + ] + + deduplicated = deduplicate_header_objects( + headers, + action="raise", + comma_join_ok=frozenset(), + multi_ok=MULTI_OK, + ) + + self.assertEqual( + [(header.header_name, header.header_value) for header in deduplicated], + [ + ("Content-Security-Policy", "default-src 'self'"), + ("Content-Security-Policy", "report-uri /csp"), + ], + ) + + def test_set_headers_async_helper_awaits_async_headers_mapping(self) -> None: + response = _AsyncHeadersResponse() + + asyncio.run(set_headers_async(response, (("X-Test", "value"),))) + + self.assertEqual(response.headers.storage, {"X-Test": "value"}) diff --git a/tests/secure_tests/test_secure.py b/tests/secure_tests/test_secure.py index 1449c29..58ac70a 100644 --- a/tests/secure_tests/test_secure.py +++ b/tests/secure_tests/test_secure.py @@ -119,8 +119,6 @@ def setUp(self) -> None: CustomHeader("X-Test-Header-2", "Value2"), ] ) - # Precompute headers dictionary - self.secure.headers = {header.header_name: header.header_value for header in self.secure.headers_list} def test_with_default_headers(self) -> None: """Test that the Balanced defaults are correctly applied.""" @@ -627,6 +625,17 @@ def test_headers_property_with_no_headers(self) -> None: secure_headers = Secure() self.assertEqual(secure_headers.headers, {}) + def test_headers_property_tracks_builder_mutation_after_access(self) -> None: + """Header mapping should reflect later builder updates instead of staying cached.""" + server = Server().set("Initial") + secure_headers = Secure(server=server) + + self.assertEqual(secure_headers.headers["Server"], "Initial") + + server.set("Updated") + + self.assertEqual(secure_headers.headers["Server"], "Updated") + def test_allowlist_headers_drop_unexpected(self) -> None: """Headers not on the allowlist are removed when using drop policy.""" secure_headers = Secure(custom=[CustomHeader("X-Not-Allowed", "value")]) @@ -642,6 +651,13 @@ def test_allowlist_headers_raises_on_unexpected(self) -> None: with self.assertRaises(ValueError): secure_headers.allowlist_headers() # default on_unexpected is "raise" + def test_allowlist_accepts_default_balanced_headers(self) -> None: + """The default allowlist should accept the default preset without extra configuration.""" + secure_headers = Secure.with_default_headers().allowlist_headers() + + header_names = [h.header_name for h in secure_headers.headers_list] + self.assertIn("Server", header_names) + def test_allowlist_respects_allow_x_prefixed(self) -> None: """Allowlist can be relaxed to accept any `X-` header when requested.""" secure_headers = Secure(custom=[CustomHeader("X-Extra-Header", "ok")]) @@ -687,6 +703,31 @@ def test_validate_and_normalize_headers_strict_rejects_crlf(self) -> None: with self.assertRaises(ValueError): secure_headers.validate_and_normalize_headers(strict=True) + def test_validate_and_normalize_headers_is_cleared_by_headers_list_mutation(self) -> None: + """Mutating `headers_list` should discard any normalized snapshot.""" + secure_headers = Secure(custom=[CustomHeader("X-Test", "value\nwith\r\nspaces")]) + secure_headers.validate_and_normalize_headers() + + secure_headers.headers_list.append(CustomHeader("X-New", "fresh")) + + self.assertEqual( + secure_headers.header_items(), + ( + ("X-Test", "value\nwith\r\nspaces"), + ("X-New", "fresh"), + ), + ) + + def test_validate_and_normalize_headers_is_cleared_by_builder_mutation(self) -> None: + """Mutating an existing builder should invalidate stale normalized output.""" + server = Server().set("Initial") + secure_headers = Secure(server=server) + secure_headers.validate_and_normalize_headers() + + server.set("Updated") + + self.assertEqual(secure_headers.headers["Server"], "Updated") + def test_headers_property_raises_on_duplicates(self) -> None: """Accessing `headers` should fail when duplicates are configured.""" secure_headers = Secure( diff --git a/uv.lock b/uv.lock index 18f2fd8..c54b64f 100644 --- a/uv.lock +++ b/uv.lock @@ -4,5 +4,5 @@ requires-python = ">=3.10" [[package]] name = "secure" -version = "2.0.0" +version = "2.0.1" source = { editable = "." }