From 53915c4e000f78519aeacdc4425ce0613652b492 Mon Sep 17 00:00:00 2001 From: cak Date: Sun, 28 Sep 2025 16:09:18 -0400 Subject: [PATCH 001/117] chore(build): clean up pyproject.toml for 2.0.0 Use SPDX license metadata, move license-files to [project], remove deprecated fields, and bump version to 2.0.0. --- MANIFEST.in | 2 -- pyproject.toml | 10 +++++----- 2 files changed, 5 insertions(+), 7 deletions(-) delete mode 100644 MANIFEST.in diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 7518d90..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,2 +0,0 @@ -include LICENSE -exclude tests/* \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 92cd0d7..172137c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,19 +1,19 @@ [build-system] -requires = ["setuptools>=42", "wheel"] +requires = ["setuptools>=77"] build-backend = "setuptools.build_meta" [project] name = "secure" -version = "1.0.1" +version = "2.0.0" description = "A lightweight package that adds security headers for Python web frameworks." -readme = { file = "README.md", "content-type" = "text/markdown" } -license = { text = "MIT" } +readme = { file = "README.md", content-type = "text/markdown" } +license = "MIT" +license-files = ["LICENSE"] authors = [{ name = "Caleb Kinney", email = "caleb@typeerror.com" }] requires-python = ">=3.10" keywords = ["security", "headers", "web", "framework", "HTTP"] classifiers = [ "Development Status :: 5 - Production/Stable", - "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", From bc8971439d5472ec1d10ac446ffd19bdc4b18998 Mon Sep 17 00:00:00 2001 From: cak Date: Sun, 28 Sep 2025 16:09:58 -0400 Subject: [PATCH 002/117] fix: specify type for params in Secure class constructor --- secure/secure.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/secure/secure.py b/secure/secure.py index 2288bde..3edadbd 100644 --- a/secure/secure.py +++ b/secure/secure.py @@ -96,7 +96,7 @@ def __init__( # Store headers in the order defined by the parameters self.headers_list: list[BaseHeader] = [] # List of header parameters in the desired order - params = [ + params: list[BaseHeader | None] = [ cache, coep, coop, From c33dd0fab31d9d8c1564428ab6e666a0a22b2d24 Mon Sep 17 00:00:00 2001 From: cak Date: Sat, 25 Oct 2025 19:06:17 -0400 Subject: [PATCH 003/117] fix(headers): expose cached headers as immutable Mapping Fixes issue #38. Return a read-only Mapping for `headers` by wrapping the built dict in `types.MappingProxyType` and updating the signature to `Mapping[str, str]`. --- secure/secure.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/secure/secure.py b/secure/secure.py index 3edadbd..150fc66 100644 --- a/secure/secure.py +++ b/secure/secure.py @@ -4,7 +4,8 @@ from collections.abc import MutableMapping from enum import Enum from functools import cached_property -from typing import Any, Protocol, runtime_checkable +from types import MappingProxyType +from typing import Any, Mapping, Protocol, runtime_checkable from .headers import ( BaseHeader, @@ -213,14 +214,17 @@ def __repr__(self) -> str: return f"{self.__class__.__name__}(headers_list={self.headers_list!r})" @cached_property - def headers(self) -> dict[str, str]: + def headers(self) -> Mapping[str, str]: """ - Collect all configured headers as a dictionary. + Collect all configured headers as an immutable mapping. - Returns: - dict[str, str]: A dictionary mapping header names to their values. + Note: + This value is computed lazily and cached. Construction may occur more + than once under concurrent first access, but the result is identical. + The returned mapping is read-only. """ - return {header.header_name: header.header_value for header in self.headers_list} + data = {header.header_name: header.header_value for header in self.headers_list} + return MappingProxyType(data) def set_headers(self, response: ResponseProtocol) -> None: """ From 6f737bb736fa355ea7e270a0d02e72249870d2ed Mon Sep 17 00:00:00 2001 From: cak Date: Sat, 25 Oct 2025 19:41:58 -0400 Subject: [PATCH 004/117] fix(headers): clarify sync errors and add safe fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes issue #37. - set_headers: if response.set_header is async and no event loop is running, run to completion via asyncio.run(); if a loop is running, raise a clear, prescriptive RuntimeError instructing to use `await set_headers_async(response)` - set_headers: handle “sync-looking but returns awaitable” setters the same way - set_headers: use headers.update(...) fast-path when available - set_headers_async: defensively await results from sync-looking setters; support mapping-style headers with callable update() and async __setitem__ fallback --- secure/secure.py | 90 ++++++++++++++++++++++++++++++++++++------------ 1 file changed, 68 insertions(+), 22 deletions(-) diff --git a/secure/secure.py b/secure/secure.py index 150fc66..91fb461 100644 --- a/secure/secure.py +++ b/secure/secure.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import inspect from collections.abc import MutableMapping from enum import Enum @@ -189,7 +190,7 @@ def from_preset(cls, preset: Preset) -> Secure: xcto=XContentTypeOptions().nosniff(), xfo=XFrameOptions().deny(), ) - case _: # type: ignore + case _: raise ValueError(f"Unknown preset: {preset}") def __str__(self) -> str: @@ -237,26 +238,57 @@ def set_headers(self, response: ResponseProtocol) -> None: response (ResponseProtocol): The response object to modify. Raises: - RuntimeError: If an asynchronous 'set_header' method is used in a synchronous context. + RuntimeError: If an asynchronous header operation is required while an event loop is running. AttributeError: If the response object does not support setting headers. """ if isinstance(response, SetHeaderProtocol): - # Use the set_header method if available set_header = response.set_header if inspect.iscoroutinefunction(set_header): + try: + asyncio.get_running_loop() + except RuntimeError: + + async def _apply_all() -> None: + for header_name, header_value in self.headers.items(): + await set_header(header_name, header_value) + + asyncio.run(_apply_all()) + return raise RuntimeError( - "Encountered asynchronous 'set_header' in synchronous context." + "Asynchronous 'set_header' detected while an event loop is running. " + "Use 'await set_headers_async(response)'." ) for header_name, header_value in self.headers.items(): - set_header(header_name, header_value) - elif isinstance(response, HeadersProtocol): # type: ignore - # Use the headers dictionary if available + res = set_header(header_name, header_value) + if inspect.isawaitable(res): + try: + asyncio.get_running_loop() + except RuntimeError: + + async def _apply_one(a: Any) -> None: + await a + + asyncio.run(_apply_one(a=res)) + else: + raise RuntimeError( + "Asynchronous header operation detected while an event loop is running. " + "Use 'await set_headers_async(response)'." + ) + return + + if hasattr(response, "headers"): + hdrs = response.headers + update = getattr(hdrs, "update", None) + if callable(update) and not inspect.iscoroutinefunction(update): + update(self.headers) + return for header_name, header_value in self.headers.items(): - response.headers[header_name] = header_value - else: - raise AttributeError( - f"Response object of type '{type(response).__name__}' does not support setting headers." - ) + hdrs[header_name] = header_value + return + + raise AttributeError( + f"Response object of type '{type(response).__name__}' does not support setting headers." + ) async def set_headers_async(self, response: ResponseProtocol) -> None: """ @@ -272,19 +304,33 @@ async def set_headers_async(self, response: ResponseProtocol) -> None: AttributeError: If the response object does not support setting headers. """ if isinstance(response, SetHeaderProtocol): - # Use the set_header method if available set_header = response.set_header if inspect.iscoroutinefunction(set_header): for header_name, header_value in self.headers.items(): await set_header(header_name, header_value) else: for header_name, header_value in self.headers.items(): - set_header(header_name, header_value) - elif isinstance(response, HeadersProtocol): # type: ignore - # Use the headers dictionary if available - for header_name, header_value in self.headers.items(): - response.headers[header_name] = header_value - else: - raise AttributeError( - f"Response object of type '{type(response).__name__}' does not support setting headers." - ) + res = set_header(header_name, header_value) + if inspect.isawaitable(res): + await res + return + + if hasattr(response, "headers"): + hdrs = response.headers + update = getattr(hdrs, "update", None) + if callable(update): + if inspect.iscoroutinefunction(update): + await update(self.headers) + else: + update(self.headers) + return + setitem = getattr(hdrs, "__setitem__", None) + if inspect.iscoroutinefunction(setitem): + for header_name, header_value in self.headers.items(): + await setitem(header_name, header_value) + else: + for header_name, header_value in self.headers.items(): + hdrs[header_name] = header_value + return + + raise AttributeError("Response object does not support setting headers.") From acafd8c4264cffb8b700594628c16d4b555a022e Mon Sep 17 00:00:00 2001 From: cak Date: Sat, 25 Oct 2025 20:11:35 -0400 Subject: [PATCH 005/117] chore(build): clean up pyproject.toml for Ruff and setuptools --- pyproject.toml | 90 ++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 73 insertions(+), 17 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 172137c..e7e913e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=77"] +requires = ["setuptools>=77", "wheel"] build-backend = "setuptools.build_meta" [project] @@ -7,22 +7,24 @@ name = "secure" version = "2.0.0" description = "A lightweight package that adds security headers for Python web frameworks." readme = { file = "README.md", content-type = "text/markdown" } -license = "MIT" -license-files = ["LICENSE"] +license = { file = "LICENSE" } authors = [{ name = "Caleb Kinney", email = "caleb@typeerror.com" }] requires-python = ">=3.10" keywords = ["security", "headers", "web", "framework", "HTTP"] classifiers = [ - "Development Status :: 5 - Production/Stable", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", - "Programming Language :: Python :: 3.14", - "Typing :: Typed", - "Operating System :: OS Independent", - "Intended Audience :: Developers", - "Topic :: Software Development :: Libraries", + "Development Status :: 5 - Production/Stable", + "License :: OSI Approved :: MIT License", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + # "Programming Language :: Python :: 3.14", + "Typing :: Typed", + "Topic :: Software Development :: Libraries", ] dependencies = [] @@ -30,10 +32,64 @@ dependencies = [] Homepage = "https://github.com/TypeError/secure" Documentation = "https://github.com/TypeError/secure/tree/main/docs" Repository = "https://github.com/TypeError/secure" -Issue-Tracker = "https://github.com/TypeError/secure/issues" +"Issue Tracker" = "https://github.com/TypeError/secure/issues" -[tool.setuptools.packages] -find = { exclude = ["tests*", "docs*"] } +# --- Setuptools package discovery --- +[tool.setuptools.packages.find] +include = ["secure*"] +exclude = ["tests*", "docs*"] [tool.setuptools.package-data] -"secure" = ["py.typed"] +secure = ["py.typed"] + +# --- Ruff (formatter + linter) --- +[tool.ruff] +target-version = "py310" +line-length = 120 +src = ["secure"] +exclude = ["build", "dist", ".venv", "**/__pycache__"] +fix = true + +[tool.ruff.format] +quote-style = "preserve" +indent-style = "space" +docstring-code-format = true + +[tool.ruff.lint] +select = [ + # Core + "E","F", + # Import sorting (isort) + "I", + # Upgrades & quality + "UP","B","C4","SIM", + # Typing / type-checking hygiene + "ANN","TCH", + # Misc rule families used below + "DTZ","PTH","T20","ERA","ISC","TRY","S","PERF","N","Q","PL","RUF" +] +ignore = ["TRY003", "EM101"] + + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["S101","S311","T20","PLR2004"] +"examples/**" = ["T20"] + +[tool.ruff.lint.flake8-annotations] +mypy-init-return = true +suppress-dummy-args = true +suppress-none-returning = false +allow-star-arg-any = false +ignore-fully-untyped = false + +[tool.ruff.lint.flake8-type-checking] +exempt-modules = ["typing", "typing_extensions"] +runtime-evaluated-base-classes = ["pydantic.BaseModel"] + +[tool.ruff.lint.isort] +known-first-party = ["secure"] +combine-as-imports = true +force-sort-within-sections = true + +[tool.ruff.lint.pydocstyle] +convention = "google" From 119b3195c784e0e120807e9ad9e3bebfb2914b05 Mon Sep 17 00:00:00 2001 From: cak Date: Sat, 25 Oct 2025 20:12:07 -0400 Subject: [PATCH 006/117] style(secure): apply Ruff-driven refactors and import sorting --- secure/secure.py | 32 +++++++++++++------------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/secure/secure.py b/secure/secure.py index 91fb461..d62353c 100644 --- a/secure/secure.py +++ b/secure/secure.py @@ -1,12 +1,14 @@ from __future__ import annotations import asyncio -import inspect -from collections.abc import MutableMapping from enum import Enum from functools import cached_property +import inspect from types import MappingProxyType -from typing import Any, Mapping, Protocol, runtime_checkable +from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable + +if TYPE_CHECKING: + from collections.abc import Awaitable, Mapping, MutableMapping from .headers import ( BaseHeader, @@ -35,7 +37,7 @@ class HeadersProtocol(Protocol): class SetHeaderProtocol(Protocol): """Protocol for response objects that have a 'set_header' method.""" - def set_header(self, key: str, value: str) -> Any: ... + def set_header(self, key: str, value: str) -> None: ... ResponseProtocol = HeadersProtocol | SetHeaderProtocol @@ -64,7 +66,7 @@ class Secure: headers_list (list[BaseHeader]): List of header objects representing the configured headers. """ - def __init__( + def __init__( # noqa: PLR0913 self, *, cache: CacheControl | None = None, @@ -180,10 +182,7 @@ def from_preset(cls, preset: Preset) -> Secure: .object_src("'none'") .base_uri("'none'") .frame_ancestors("'none'"), - hsts=StrictTransportSecurity() - .max_age(63072000) - .include_subdomains() - .preload(), + hsts=StrictTransportSecurity().max_age(63072000).include_subdomains().preload(), permissions=PermissionsPolicy().geolocation().microphone().camera(), referrer=ReferrerPolicy().no_referrer(), server=Server().set(""), @@ -200,10 +199,7 @@ def __str__(self) -> str: Returns: str: A string listing the headers and their values. """ - return "\n".join( - f"{header.header_name}: {header.header_value}" - for header in self.headers_list - ) + return "\n".join(f"{header.header_name}: {header.header_value}" for header in self.headers_list) def __repr__(self) -> str: """ @@ -265,7 +261,7 @@ async def _apply_all() -> None: asyncio.get_running_loop() except RuntimeError: - async def _apply_one(a: Any) -> None: + async def _apply_one(a: Awaitable[Any]) -> None: await a asyncio.run(_apply_one(a=res)) @@ -286,11 +282,9 @@ async def _apply_one(a: Any) -> None: hdrs[header_name] = header_value return - raise AttributeError( - f"Response object of type '{type(response).__name__}' does not support setting headers." - ) + raise AttributeError(f"Response object of type '{type(response).__name__}' does not support setting headers.") - async def set_headers_async(self, response: ResponseProtocol) -> None: + async def set_headers_async(self, response: ResponseProtocol) -> None: # noqa: PLR0912 """ Set security headers on the response object asynchronously. @@ -312,7 +306,7 @@ async def set_headers_async(self, response: ResponseProtocol) -> None: for header_name, header_value in self.headers.items(): res = set_header(header_name, header_value) if inspect.isawaitable(res): - await res + await res # type: ignore[misc] return if hasattr(response, "headers"): From 1424067ff8fa38c71653aee6d32d2444bcf5d4a8 Mon Sep 17 00:00:00 2001 From: cak Date: Sat, 25 Oct 2025 20:42:00 -0400 Subject: [PATCH 007/117] Preserve multi-valued headers in setters Fixes issue #36 - Iterate header_items() in sync/async setters to keep CSP & Set-Cookie. - Avoid bulk dict.update() and lossy mappings. - headers() remains strict/immutable to prevent duplicate loss. --- secure/secure.py | 142 +++++++++++++++++++++++++++++++---------------- 1 file changed, 93 insertions(+), 49 deletions(-) diff --git a/secure/secure.py b/secure/secure.py index d62353c..de2b2aa 100644 --- a/secure/secure.py +++ b/secure/secure.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +from collections import defaultdict from enum import Enum from functools import cached_property import inspect @@ -10,6 +11,7 @@ if TYPE_CHECKING: from collections.abc import Awaitable, Mapping, MutableMapping + from .headers import ( BaseHeader, CacheControl, @@ -25,6 +27,8 @@ XFrameOptions, ) +MULTI_OK: set[str] = {"content-security-policy", "set-cookie"} + @runtime_checkable class HeadersProtocol(Protocol): @@ -210,17 +214,55 @@ def __repr__(self) -> str: """ return f"{self.__class__.__name__}(headers_list={self.headers_list!r})" + def header_items(self) -> tuple[tuple[str, str], ...]: + """ + Return all headers as (name, value) pairs, preserving allowed multi-valued + headers (e.g., Content-Security-Policy, Set-Cookie) and rejecting unsafe + duplicates for other headers. + """ + groups: defaultdict[str, list[tuple[str, str]]] = defaultdict(list) + for h in self.headers_list: + groups[h.header_name.lower()].append((h.header_name, h.header_value)) + + items: list[tuple[str, str]] = [] + dup_errors: list[str] = [] + + for lname, pairs in groups.items(): + if len(pairs) == 1: + items.append(pairs[0]) + continue + if lname in MULTI_OK: + # Preserve multiple fields as separate items. + items.extend(pairs) + else: + # Keep original casing from the first occurrence for the error. + dup_errors.append(pairs[0][0]) + + if dup_errors: + raise ValueError( + "Duplicate header(s) not allowed: " + ", ".join(sorted(set(dup_errors))) + ". Define each at most once." + ) + + return tuple(items) + @cached_property def headers(self) -> Mapping[str, str]: """ - Collect all configured headers as an immutable mapping. + Single-valued, immutable mapping of headers. - Note: - This value is computed lazily and cached. Construction may occur more - than once under concurrent first access, but the result is identical. - The returned mapping is read-only. + Raises: + ValueError: if any header name appears more than once (case-insensitive), + including headers in MULTI_OK. Use `header_items()` or the setters to + emit multi-valued headers like Content-Security-Policy or Set-Cookie. """ - data = {header.header_name: header.header_value for header in self.headers_list} + data: dict[str, str] = {} + seen: set[str] = set() + 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) def set_headers(self, response: ResponseProtocol) -> None: @@ -237,54 +279,60 @@ def set_headers(self, response: ResponseProtocol) -> None: RuntimeError: If an asynchronous header operation is required while an event loop is running. AttributeError: If the response object does not support setting headers. """ + items = self.header_items() + if isinstance(response, SetHeaderProtocol): set_header = response.set_header if inspect.iscoroutinefunction(set_header): - try: - asyncio.get_running_loop() - except RuntimeError: - - async def _apply_all() -> None: - for header_name, header_value in self.headers.items(): - await set_header(header_name, header_value) - - asyncio.run(_apply_all()) - return - raise RuntimeError( - "Asynchronous 'set_header' detected while an event loop is running. " - "Use 'await set_headers_async(response)'." - ) - for header_name, header_value in self.headers.items(): - res = set_header(header_name, header_value) - if inspect.isawaitable(res): + # If there is a running loop, instruct the caller to use the async API. + def _check_and_raise_for_running_loop() -> None: try: asyncio.get_running_loop() except RuntimeError: + return + raise RuntimeError( + "Asynchronous 'set_header' detected while an event loop is running. " + "Use 'await set_headers_async(response)'." + ) - async def _apply_one(a: Awaitable[Any]) -> None: - await a + _check_and_raise_for_running_loop() - asyncio.run(_apply_one(a=res)) - else: - raise RuntimeError( - "Asynchronous header operation detected while an event loop is running. " - "Use 'await set_headers_async(response)'." - ) + # No running loop — run the async setter to completion. + async def _apply_all() -> None: + for header_name, header_value in items: + await set_header(header_name, header_value) + + asyncio.run(_apply_all()) + else: + for header_name, header_value in items: + res = set_header(header_name, header_value) + if inspect.isawaitable(res): + # No running loop — run the awaitable to completion. + try: + asyncio.get_running_loop() + except RuntimeError: + + async def _apply_one(a: Awaitable[Any]) -> None: + await a + + asyncio.run(_apply_one(res)) + else: + raise RuntimeError( + "Asynchronous header operation detected while an event loop is running. " + "Use 'await set_headers_async(response)'." + ) return if hasattr(response, "headers"): hdrs = response.headers - update = getattr(hdrs, "update", None) - if callable(update) and not inspect.iscoroutinefunction(update): - update(self.headers) - return - for header_name, header_value in self.headers.items(): + # Avoid bulk update(): it would drop duplicates. Set one-by-one. + for header_name, header_value in items: hdrs[header_name] = header_value return - raise AttributeError(f"Response object of type '{type(response).__name__}' does not support setting headers.") + raise AttributeError("Response object does not support setting headers.") - async def set_headers_async(self, response: ResponseProtocol) -> None: # noqa: PLR0912 + async def set_headers_async(self, response: ResponseProtocol) -> None: """ Set security headers on the response object asynchronously. @@ -297,13 +345,15 @@ async def set_headers_async(self, response: ResponseProtocol) -> None: # noqa: Raises: AttributeError: If the response object does not support setting headers. """ + items = self.header_items() + if isinstance(response, SetHeaderProtocol): set_header = response.set_header if inspect.iscoroutinefunction(set_header): - for header_name, header_value in self.headers.items(): + for header_name, header_value in items: await set_header(header_name, header_value) else: - for header_name, header_value in self.headers.items(): + for header_name, header_value in items: res = set_header(header_name, header_value) if inspect.isawaitable(res): await res # type: ignore[misc] @@ -311,19 +361,13 @@ async def set_headers_async(self, response: ResponseProtocol) -> None: # noqa: if hasattr(response, "headers"): hdrs = response.headers - update = getattr(hdrs, "update", None) - if callable(update): - if inspect.iscoroutinefunction(update): - await update(self.headers) - else: - update(self.headers) - return + # Avoid bulk update(): preserve multiples by setting one-by-one. setitem = getattr(hdrs, "__setitem__", None) if inspect.iscoroutinefunction(setitem): - for header_name, header_value in self.headers.items(): + for header_name, header_value in items: await setitem(header_name, header_value) else: - for header_name, header_value in self.headers.items(): + for header_name, header_value in items: hdrs[header_name] = header_value return From b90f1b947d8464f822d5a0f8abe381999290ecc2 Mon Sep 17 00:00:00 2001 From: cak Date: Sun, 26 Oct 2025 13:35:14 -0400 Subject: [PATCH 008/117] fix(headers): robust error handling in setting headers Fixes issue #34 - Wrap per-item header operations in try/except; re-raise as HeaderSetError (TypeError, ValueError, AttributeError) - Support async setters safely (await/asyncio.run); guide callers to async API when an event loop is running - Move try/except out of loops to satisfy Ruff PERF203; add Awaitable annotation to address ANN001 --- secure/secure.py | 187 +++++++++++++++++++++++++++++++++-------------- 1 file changed, 134 insertions(+), 53 deletions(-) diff --git a/secure/secure.py b/secure/secure.py index de2b2aa..a7e2d53 100644 --- a/secure/secure.py +++ b/secure/secure.py @@ -6,7 +6,7 @@ from functools import cached_property import inspect from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable +from typing import TYPE_CHECKING, Protocol, runtime_checkable if TYPE_CHECKING: from collections.abc import Awaitable, Mapping, MutableMapping @@ -30,6 +30,10 @@ MULTI_OK: set[str] = {"content-security-policy", "set-cookie"} +class HeaderSetError(RuntimeError): + """Raised when applying a header to a response fails.""" + + @runtime_checkable class HeadersProtocol(Protocol): """Protocol for response objects that have a 'headers' attribute.""" @@ -265,110 +269,187 @@ def headers(self) -> Mapping[str, str]: data[name] = value return MappingProxyType(data) - def set_headers(self, response: ResponseProtocol) -> None: + def set_headers(self, response: ResponseProtocol) -> None: # noqa: PLR0915 """ - Set security headers on the response object synchronously. - - The method checks for the presence of a 'set_header' method or 'headers' attribute - on the response object to set the headers appropriately. - - Args: - response (ResponseProtocol): The response object to modify. - - Raises: - RuntimeError: If an asynchronous header operation is required while an event loop is running. - AttributeError: If the response object does not support setting headers. + Apply configured headers **synchronously** to `response`. + + Supports: + - `set_header(key, value)`: async (driven with `asyncio.run` if no running loop) or sync + (awaits returned awaitable if present, else sets directly). + - `.headers` mapping: async `__setitem__` (driven with `asyncio.run`) or sync. + + Raises + ------ + RuntimeError + If an async setter is detected while a loop is already running. + AttributeError + If the response lacks both `.set_header` and `.headers`. + HeaderSetError + If setting an individual header fails. """ + items = self.header_items() if isinstance(response, SetHeaderProtocol): set_header = response.set_header + if inspect.iscoroutinefunction(set_header): - # If there is a running loop, instruct the caller to use the async API. - def _check_and_raise_for_running_loop() -> None: - try: - asyncio.get_running_loop() - except RuntimeError: - return + try: + asyncio.get_running_loop() + except RuntimeError: + pass + else: raise RuntimeError( "Asynchronous 'set_header' detected while an event loop is running. " "Use 'await set_headers_async(response)'." ) - _check_and_raise_for_running_loop() + async def _apply_one(name: str, value: str) -> None: + try: + await set_header(name, value) + except (TypeError, ValueError, AttributeError) as e: + raise HeaderSetError(f"Failed to set header {name!r}: {e}") from e - # No running loop — run the async setter to completion. async def _apply_all() -> None: - for header_name, header_value in items: - await set_header(header_name, header_value) + for k, v in items: + await _apply_one(k, v) asyncio.run(_apply_all()) - else: - for header_name, header_value in items: - res = set_header(header_name, header_value) + return + + def _set_header_one(name: str, value: str) -> None: + try: + res = set_header(name, value) if inspect.isawaitable(res): - # No running loop — run the awaitable to completion. try: asyncio.get_running_loop() except RuntimeError: - async def _apply_one(a: Awaitable[Any]) -> None: + async def _await_one(a: Awaitable[object]) -> None: await a - asyncio.run(_apply_one(res)) + asyncio.run(_await_one(res)) # type: ignore[arg-type] else: raise RuntimeError( "Asynchronous header operation detected while an event loop is running. " "Use 'await set_headers_async(response)'." ) + except (TypeError, ValueError, AttributeError) as e: + raise HeaderSetError(f"Failed to set header {name!r}: {e}") from e + + for k, v in items: + _set_header_one(k, v) return if hasattr(response, "headers"): hdrs = response.headers - # Avoid bulk update(): it would drop duplicates. Set one-by-one. - for header_name, header_value in items: - hdrs[header_name] = header_value + setitem = getattr(hdrs, "__setitem__", None) + + if inspect.iscoroutinefunction(setitem): + try: + asyncio.get_running_loop() + except RuntimeError: + pass + else: + raise RuntimeError( + "Asynchronous header operation detected while an event loop is running. " + "Use 'await set_headers_async(response)'." + ) + + async def _apply_hdr_one(name: str, value: str) -> None: + try: + await setitem(name, value) # type: ignore[misc] + except (TypeError, ValueError, AttributeError) as e: + raise HeaderSetError(f"Failed to set header {name!r}: {e}") from e + + async def _apply_all_hdrs() -> None: + for k, v in items: + await _apply_hdr_one(k, v) + + asyncio.run(_apply_all_hdrs()) + return + + def _hdrs_set_one(name: str, value: str) -> None: + try: + hdrs[name] = value + except (TypeError, ValueError, AttributeError) as e: + raise HeaderSetError(f"Failed to set header {name!r}: {e}") from e + + for k, v in items: + _hdrs_set_one(k, v) return raise AttributeError("Response object does not support setting headers.") async def set_headers_async(self, response: ResponseProtocol) -> None: """ - Set security headers on the response object asynchronously. - - This method handles both synchronous and asynchronous 'set_header' methods, - as well as response objects with a 'headers' attribute. - - Args: - response (ResponseProtocol): The response object to modify. - - Raises: - AttributeError: If the response object does not support setting headers. + Apply configured headers **asynchronously** to `response`. + + Supports: + - `set_header(key, value)`: async (awaited) or sync (awaits returned awaitable if present). + - `.headers` mapping: async `__setitem__` (awaited) or sync setitem. + + Raises + ------ + AttributeError + If the response lacks both `.set_header` and `.headers`. + HeaderSetError + If setting an individual header fails. """ + items = self.header_items() if isinstance(response, SetHeaderProtocol): set_header = response.set_header + if inspect.iscoroutinefunction(set_header): - for header_name, header_value in items: - await set_header(header_name, header_value) - else: - for header_name, header_value in items: - res = set_header(header_name, header_value) + + async def _apply_one(name: str, value: str) -> None: + try: + await set_header(name, value) + except (TypeError, ValueError, AttributeError) as e: + raise HeaderSetError(f"Failed to set header {name!r}: {e}") from e + + for k, v in items: + await _apply_one(k, v) + return + + async def _apply_one_syncish(name: str, value: str) -> None: + try: + res = set_header(name, value) if inspect.isawaitable(res): await res # type: ignore[misc] + except (TypeError, ValueError, AttributeError) as e: + raise HeaderSetError(f"Failed to set header {name!r}: {e}") from e + + for k, v in items: + await _apply_one_syncish(k, v) return if hasattr(response, "headers"): hdrs = response.headers - # Avoid bulk update(): preserve multiples by setting one-by-one. setitem = getattr(hdrs, "__setitem__", None) + if inspect.iscoroutinefunction(setitem): - for header_name, header_value in items: - await setitem(header_name, header_value) - else: - for header_name, header_value in items: - hdrs[header_name] = header_value + + async def _apply_hdr_one(name: str, value: str) -> None: + try: + await setitem(name, value) # type: ignore[misc] + except (TypeError, ValueError, AttributeError) as e: + raise HeaderSetError(f"Failed to set header {name!r}: {e}") from e + + for k, v in items: + await _apply_hdr_one(k, v) + return + + def _hdrs_set_one(name: str, value: str) -> None: + try: + hdrs[name] = value + except (TypeError, ValueError, AttributeError) as e: + raise HeaderSetError(f"Failed to set header {name!r}: {e}") from e + + for k, v in items: + _hdrs_set_one(k, v) return raise AttributeError("Response object does not support setting headers.") From fab44bff0bb59afeab2e8bb4802a8b42c1f20f56 Mon Sep 17 00:00:00 2001 From: cak Date: Sun, 26 Oct 2025 14:11:57 -0400 Subject: [PATCH 009/117] secure: validate header names/values before write Fixes issue #33 - Call self._validate_and_normalize_header(...) before every header write - Reject CR/LF and disallowed control chars; enforce RFC7230 token names - Works in both set_header(...) and mapping (__setitem__) branches - Respects optional _strict/_allow_obs_text/_on_invalid if present - No breaking changes to public API; fix ships in v2.0 --- secure/secure.py | 109 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 103 insertions(+), 6 deletions(-) diff --git a/secure/secure.py b/secure/secure.py index a7e2d53..4e38721 100644 --- a/secure/secure.py +++ b/secure/secure.py @@ -5,6 +5,8 @@ from enum import Enum from functools import cached_property import inspect +import logging +import re from types import MappingProxyType from typing import TYPE_CHECKING, Protocol, runtime_checkable @@ -62,6 +64,19 @@ class Preset(Enum): STRICT = "strict" +HTAB: int = 0x09 +SP: int = 0x20 +VCHAR_MIN: int = 0x21 +VCHAR_MAX: int = 0x7E +OBS_MIN: int = 0x80 +OBS_MAX: int = 0xFF + +_LOG = logging.getLogger("secure") + +# RFC 7230 token for header field-name (field-name = token) +_HEADER_NAME_RE = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$") + + class Secure: """ A class to configure and apply security headers for web applications. @@ -88,6 +103,9 @@ def __init__( # noqa: PLR0913 server: Server | None = None, xcto: XContentTypeOptions | None = None, xfo: XFrameOptions | None = None, + strict: bool = False, + allow_obs_text: bool = True, + on_invalid: str = "drop", # "drop" | "raise" | "warn" ) -> None: """ Initialize the Secure instance with the specified security headers. @@ -104,6 +122,12 @@ def __init__( # noqa: PLR0913 server (Server | None): The Server header configuration. xcto (XContentTypeOptions | None): The X-Content-Type-Options header configuration. xfo (XFrameOptions | None): The X-Frame-Options header configuration. + strict: fail-fast on invalid headers (raises ValueError) + allow_obs_text: allow 0x80-0xFF in values (RFC7230 obs-text) + on_invalid (lenient mode only): + - "drop" (default): skip invalid headers + - "warn": drop and log a warning + - "raise": escalate even in lenient mode """ # Store headers in the order defined by the parameters self.headers_list: list[BaseHeader] = [] @@ -130,6 +154,10 @@ def __init__( # noqa: PLR0913 if custom: self.headers_list.extend(custom) + self._strict = strict + self._allow_obs_text = allow_obs_text + self._on_invalid = on_invalid + @classmethod def with_default_headers(cls) -> Secure: """ @@ -218,6 +246,65 @@ def __repr__(self) -> str: """ return f"{self.__class__.__name__}(headers_list={self.headers_list!r})" + def _validate_and_normalize_header(self, name: str | None, value: str | None) -> tuple[str, str] | None: + """ + Validate a header field-name and field-value. + + Returns: + (name, value) if acceptable/sanitized, + None in lenient 'drop'/'warn' modes when unsanitizable, + raises ValueError in 'strict' or 'raise' modes on invalid input. + """ + # Read class-level switches if present; default to non-breaking behavior. + + log = getattr(self, "_log", _LOG) + + def _handle_invalid(msg: str) -> tuple[str, str] | None: + if self._strict or self._on_invalid == "raise": + raise ValueError(msg) + if self._on_invalid == "warn": + log.warning(msg) + # "drop" (default) falls through + return None + + if name is None or value is None: + return _handle_invalid("Header name/value must not be None") + + name = name.strip() + if not _HEADER_NAME_RE.match(name): + return _handle_invalid(f"Invalid header name {name!r} (RFC 7230 token required)") + + # Block response-splitting + if ("\r" in value) or ("\n" in value): + if self._strict: + raise ValueError(f"Header {name!r} contained CR/LF") + # lenient: collapse newlines into a single space + value = " ".join(value.splitlines()) + + value = value.strip() + + sanitized: list[str] = [] + for ch in value: + code = ord(ch) + if ( + ch == "\t" + or code == SP + or VCHAR_MIN <= code <= VCHAR_MAX + or (self._allow_obs_text and (OBS_MIN <= code <= OBS_MAX)) + ): # HTAB or SP + sanitized.append(ch) + else: + if self._strict: + raise ValueError(f"Header {name!r} contains disallowed char U+{code:04X}") + sanitized.append(" ") + + norm_value = "".join(sanitized).strip() + if not norm_value: + # Empty after sanitization: treat as invalid per on_invalid/strict + return _handle_invalid(f"Dropping header {name!r} due to empty/invalid value after sanitization") + + return name, norm_value + def header_items(self) -> tuple[tuple[str, str], ...]: """ Return all headers as (name, value) pairs, preserving allowed multi-valued @@ -306,7 +393,9 @@ def set_headers(self, response: ResponseProtocol) -> None: # noqa: PLR0915 async def _apply_one(name: str, value: str) -> None: try: - await set_header(name, value) + nv = self._validate_and_normalize_header(name, value) + if nv: + await set_header(*nv) except (TypeError, ValueError, AttributeError) as e: raise HeaderSetError(f"Failed to set header {name!r}: {e}") from e @@ -358,7 +447,9 @@ async def _await_one(a: Awaitable[object]) -> None: async def _apply_hdr_one(name: str, value: str) -> None: try: - await setitem(name, value) # type: ignore[misc] + nv = self._validate_and_normalize_header(name, value) + if nv: + await setitem(*nv) except (TypeError, ValueError, AttributeError) as e: raise HeaderSetError(f"Failed to set header {name!r}: {e}") from e @@ -371,7 +462,9 @@ async def _apply_all_hdrs() -> None: def _hdrs_set_one(name: str, value: str) -> None: try: - hdrs[name] = value + nv = self._validate_and_normalize_header(name, value) + if nv: + hdrs.__setitem__(*nv) except (TypeError, ValueError, AttributeError) as e: raise HeaderSetError(f"Failed to set header {name!r}: {e}") from e @@ -406,7 +499,9 @@ async def set_headers_async(self, response: ResponseProtocol) -> None: async def _apply_one(name: str, value: str) -> None: try: - await set_header(name, value) + nv = self._validate_and_normalize_header(name, value) + if nv: + await set_header(*nv) except (TypeError, ValueError, AttributeError) as e: raise HeaderSetError(f"Failed to set header {name!r}: {e}") from e @@ -416,7 +511,7 @@ async def _apply_one(name: str, value: str) -> None: async def _apply_one_syncish(name: str, value: str) -> None: try: - res = set_header(name, value) + res = None if (nv := self._validate_and_normalize_header(name, value)) is None else set_header(*nv) if inspect.isawaitable(res): await res # type: ignore[misc] except (TypeError, ValueError, AttributeError) as e: @@ -434,7 +529,9 @@ async def _apply_one_syncish(name: str, value: str) -> None: async def _apply_hdr_one(name: str, value: str) -> None: try: - await setitem(name, value) # type: ignore[misc] + nv = self._validate_and_normalize_header(name, value) + if nv: + await setitem(*nv) except (TypeError, ValueError, AttributeError) as e: raise HeaderSetError(f"Failed to set header {name!r}: {e}") from e From d7d73d8253412fe85fd8cc130e8663f2f414ed62 Mon Sep 17 00:00:00 2001 From: cak Date: Wed, 29 Oct 2025 11:38:34 -0400 Subject: [PATCH 010/117] refactor(headers): streamline validation and normalization logic Updates issue #33, still resolved --- secure/secure.py | 204 ++++++++++++++++++++++++++--------------------- 1 file changed, 113 insertions(+), 91 deletions(-) diff --git a/secure/secure.py b/secure/secure.py index 4e38721..b16f935 100644 --- a/secure/secure.py +++ b/secure/secure.py @@ -64,19 +64,6 @@ class Preset(Enum): STRICT = "strict" -HTAB: int = 0x09 -SP: int = 0x20 -VCHAR_MIN: int = 0x21 -VCHAR_MAX: int = 0x7E -OBS_MIN: int = 0x80 -OBS_MAX: int = 0xFF - -_LOG = logging.getLogger("secure") - -# RFC 7230 token for header field-name (field-name = token) -_HEADER_NAME_RE = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$") - - class Secure: """ A class to configure and apply security headers for web applications. @@ -103,9 +90,6 @@ def __init__( # noqa: PLR0913 server: Server | None = None, xcto: XContentTypeOptions | None = None, xfo: XFrameOptions | None = None, - strict: bool = False, - allow_obs_text: bool = True, - on_invalid: str = "drop", # "drop" | "raise" | "warn" ) -> None: """ Initialize the Secure instance with the specified security headers. @@ -122,12 +106,6 @@ def __init__( # noqa: PLR0913 server (Server | None): The Server header configuration. xcto (XContentTypeOptions | None): The X-Content-Type-Options header configuration. xfo (XFrameOptions | None): The X-Frame-Options header configuration. - strict: fail-fast on invalid headers (raises ValueError) - allow_obs_text: allow 0x80-0xFF in values (RFC7230 obs-text) - on_invalid (lenient mode only): - - "drop" (default): skip invalid headers - - "warn": drop and log a warning - - "raise": escalate even in lenient mode """ # Store headers in the order defined by the parameters self.headers_list: list[BaseHeader] = [] @@ -154,10 +132,6 @@ def __init__( # noqa: PLR0913 if custom: self.headers_list.extend(custom) - self._strict = strict - self._allow_obs_text = allow_obs_text - self._on_invalid = on_invalid - @classmethod def with_default_headers(cls) -> Secure: """ @@ -246,64 +220,122 @@ def __repr__(self) -> str: """ return f"{self.__class__.__name__}(headers_list={self.headers_list!r})" - def _validate_and_normalize_header(self, name: str | None, value: str | None) -> tuple[str, str] | None: + def validate_and_normalize_headers( # noqa: PLR0915 + self, + *, + on_invalid: str = "drop", # "drop" | "warn" | "raise" + strict: bool = False, # hard-fail on CR/LF + illegal chars + allow_obs_text: bool = False, + logger: logging.Logger | None = None, + ) -> Secure: """ - Validate a header field-name and field-value. + Validate/normalize the *current* headers and replace the cached mapping in-place. + No persistent class state is added; behavior is controlled only by call arguments. Returns: - (name, value) if acceptable/sanitized, - None in lenient 'drop'/'warn' modes when unsanitizable, - raises ValueError in 'strict' or 'raise' modes on invalid input. + self (chainable) """ - # Read class-level switches if present; default to non-breaking behavior. - log = getattr(self, "_log", _LOG) + log = logger or logging.getLogger("secure") - def _handle_invalid(msg: str) -> tuple[str, str] | None: - if self._strict or self._on_invalid == "raise": - raise ValueError(msg) - if self._on_invalid == "warn": + # Token per RFC 7230 tchar (visible ASCII except separators). + header_name_re = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$") + + # Visible ASCII per RFCs + sp = 0x20 + vchar_min, vchar_max = 0x21, 0x7E + # obs-text (RFC 7230 §3.2.4) — rarely needed + obs_min, obs_max = 0x80, 0xFF + + def _handle_invalid(msg: str) -> None: + if on_invalid == "warn": log.warning(msg) - # "drop" (default) falls through - return None - - if name is None or value is None: - return _handle_invalid("Header name/value must not be None") - - name = name.strip() - if not _HEADER_NAME_RE.match(name): - return _handle_invalid(f"Invalid header name {name!r} (RFC 7230 token required)") - - # Block response-splitting - if ("\r" in value) or ("\n" in value): - if self._strict: - raise ValueError(f"Header {name!r} contained CR/LF") - # lenient: collapse newlines into a single space - value = " ".join(value.splitlines()) - - value = value.strip() - - sanitized: list[str] = [] - for ch in value: - code = ord(ch) - if ( - ch == "\t" - or code == SP - or VCHAR_MIN <= code <= VCHAR_MAX - or (self._allow_obs_text and (OBS_MIN <= code <= OBS_MAX)) - ): # HTAB or SP - sanitized.append(ch) - else: - if self._strict: - raise ValueError(f"Header {name!r} contains disallowed char U+{code:04X}") - sanitized.append(" ") + elif on_invalid == "raise" or strict: + raise ValueError(msg) + # "drop" does nothing; caller will skip the pair + + def _validate_pair(name: str, value: str) -> tuple[str, str] | None: + # Normalize header name casing to canonical case-insensitive form. + + name = name.strip() + if not header_name_re.match(name): + _handle_invalid(f"Invalid header name {name!r} (RFC 7230 token required)") + return None # already raised if strict/raise + + # CR/LF must never appear in header values. If not strict, coalesce lines. + if ("\r" in value) or ("\n" in value): + if strict: + 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 + + # Fast path: if all chars are HTAB/SP/VCHAR (and optional obs-text), keep as-is. + # Bind lookups locally for speed in tight loops. + _sp = sp + _vmin, _vmax = vchar_min, vchar_max + _allow_obs = allow_obs_text + _omin, _omax = obs_min, obs_max + + # Check if sanitization is needed; avoid building a new string if not. + needs_sanitize = False + for ch in value: + code = ord(ch) + if not ( + ch == "\t" or code == _sp or (_vmin <= code <= _vmax) or (_allow_obs and (_omin <= code <= _omax)) + ): + needs_sanitize = True + break + + if not needs_sanitize: + return name, value + + # Sanitize disallowed characters: strict -> error, else replace with SP. + sanitized_chars: list[str] = [] + append = sanitized_chars.append + for ch in value: + code = ord(ch) + if ch == "\t" or code == _sp or (_vmin <= code <= _vmax) or (_allow_obs and (_omin <= code <= _omax)): + append(ch) + else: + if strict: + raise ValueError(f"Header {name!r} contains disallowed char U+{code:04X}") + append(" ") + + norm_value = "".join(sanitized_chars).strip() + if not norm_value: + _handle_invalid(f"Dropping header {name!r}: empty after sanitization") + return None + + return name, norm_value + + # Pull the current cached mapping (a MappingProxyType) and rebuild it. + try: + current = dict(self.headers) + except Exception as e: # pragma: no cover + raise RuntimeError( + "Secure.validate_and_normalize_headers() expected self.headers to be mapping-like" + ) from e + + cleaned: dict[str, str] = {} + for k, v in current.items(): + pair = _validate_pair(k, v) + if pair is None: + continue + name, value = pair + cleaned[name] = value - norm_value = "".join(sanitized).strip() - if not norm_value: - # Empty after sanitization: treat as invalid per on_invalid/strict - return _handle_invalid(f"Dropping header {name!r} due to empty/invalid value after sanitization") + # Ensure `headers` is a @cached_property so we can swap its cached value. + hdr_descr = getattr(type(self), "headers", None) + if not isinstance(hdr_descr, cached_property): + raise TypeError("`headers` must be a @cached_property to swap it in-place.") - return name, norm_value + # Overwrite the cached property value with a read-only mapping. + self.__dict__["headers"] = MappingProxyType(cleaned) + return self def header_items(self) -> tuple[tuple[str, str], ...]: """ @@ -393,9 +425,7 @@ def set_headers(self, response: ResponseProtocol) -> None: # noqa: PLR0915 async def _apply_one(name: str, value: str) -> None: try: - nv = self._validate_and_normalize_header(name, value) - if nv: - await set_header(*nv) + await set_header(name, value) except (TypeError, ValueError, AttributeError) as e: raise HeaderSetError(f"Failed to set header {name!r}: {e}") from e @@ -447,9 +477,7 @@ async def _await_one(a: Awaitable[object]) -> None: async def _apply_hdr_one(name: str, value: str) -> None: try: - nv = self._validate_and_normalize_header(name, value) - if nv: - await setitem(*nv) + await setitem(name, value) # type: ignore[misc] except (TypeError, ValueError, AttributeError) as e: raise HeaderSetError(f"Failed to set header {name!r}: {e}") from e @@ -462,9 +490,7 @@ async def _apply_all_hdrs() -> None: def _hdrs_set_one(name: str, value: str) -> None: try: - nv = self._validate_and_normalize_header(name, value) - if nv: - hdrs.__setitem__(*nv) + hdrs[name] = value except (TypeError, ValueError, AttributeError) as e: raise HeaderSetError(f"Failed to set header {name!r}: {e}") from e @@ -499,9 +525,7 @@ async def set_headers_async(self, response: ResponseProtocol) -> None: async def _apply_one(name: str, value: str) -> None: try: - nv = self._validate_and_normalize_header(name, value) - if nv: - await set_header(*nv) + await set_header(name, value) except (TypeError, ValueError, AttributeError) as e: raise HeaderSetError(f"Failed to set header {name!r}: {e}") from e @@ -511,7 +535,7 @@ async def _apply_one(name: str, value: str) -> None: async def _apply_one_syncish(name: str, value: str) -> None: try: - res = None if (nv := self._validate_and_normalize_header(name, value)) is None else set_header(*nv) + res = set_header(name, value) if inspect.isawaitable(res): await res # type: ignore[misc] except (TypeError, ValueError, AttributeError) as e: @@ -529,9 +553,7 @@ async def _apply_one_syncish(name: str, value: str) -> None: async def _apply_hdr_one(name: str, value: str) -> None: try: - nv = self._validate_and_normalize_header(name, value) - if nv: - await setitem(*nv) + await setitem(name, value) # type: ignore[misc] except (TypeError, ValueError, AttributeError) as e: raise HeaderSetError(f"Failed to set header {name!r}: {e}") from e From edd5c197001e8b43f5413640292241a80c65d950 Mon Sep 17 00:00:00 2001 From: cak Date: Sat, 8 Nov 2025 09:28:22 -0500 Subject: [PATCH 011/117] headers: add deduplicate_headers() and simplify header_items() Update issue #36 (Lack of Duplicate Header Detection in Secure Clas) - Move duplicate handling into chainable deduplicate_headers - Respect MULTI_OK and COMMA_JOIN_OK; preserve stable order - header_items now only serializes (name, value) pairs - Drop in-function dedupe and error logic - Invalidate cached headers mapping after mutation --- secure/secure.py | 147 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 124 insertions(+), 23 deletions(-) diff --git a/secure/secure.py b/secure/secure.py index b16f935..d23e31f 100644 --- a/secure/secure.py +++ b/secure/secure.py @@ -29,7 +29,15 @@ XFrameOptions, ) -MULTI_OK: set[str] = {"content-security-policy", "set-cookie"} +# 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"}) class HeaderSetError(RuntimeError): @@ -256,6 +264,7 @@ def _handle_invalid(msg: str) -> None: def _validate_pair(name: str, value: str) -> tuple[str, str] | None: # Normalize header name casing to canonical case-insensitive form. + nonlocal strict name = name.strip() if not header_name_re.match(name): @@ -337,34 +346,122 @@ def _validate_pair(name: str, value: str) -> tuple[str, str] | None: self.__dict__["headers"] = MappingProxyType(cleaned) return self - def header_items(self) -> tuple[tuple[str, str], ...]: + def deduplicate_headers( + self, + *, + action: str = "raise", # "raise" | "first" | "last" | "concat" + comma_join_ok: frozenset[str] = COMMA_JOIN_OK, + multi_ok: frozenset[str] = MULTI_OK, + logger: logging.Logger | None = None, + ) -> Secure: """ - Return all headers as (name, value) pairs, preserving allowed multi-valued - headers (e.g., Content-Security-Policy, Set-Cookie) and rejecting unsafe - duplicates for other headers. + Deduplicate current headers in-place according to the chosen policy, + while respecting headers explicitly allowed to be multi-valued. + + Returns: + self (chainable) """ - groups: defaultdict[str, list[tuple[str, str]]] = defaultdict(list) - for h in self.headers_list: - groups[h.header_name.lower()].append((h.header_name, h.header_value)) + log = logger or logging.getLogger("secure") - items: list[tuple[str, str]] = [] + # Group by lowercase name; store (first_index, OriginalName, value) + groups: dict[str, list[tuple[int, str, str]]] = defaultdict(list) + + # Read items robustly (object with attrs or 2-tuple) + for idx, h in enumerate(self.headers_list): + try: + nm = h.header_name + val = h.header_value + except AttributeError: + nm, val = h # type: ignore[misc] + groups[nm.lower()].append((idx, nm, val)) + + # Stable processing order by first appearance + ordered_keys = sorted(groups.keys(), key=lambda k: groups[k][0][0]) + + def _make_pair(name: str, value: str) -> tuple[str, str]: + return (name, value) + + def _handle_disallowed_dupes( + lname: str, entries: list[tuple[int, str, str]] + ) -> tuple[list[tuple[str, str]], str | None]: + """Return (new_items, dup_error_name_if_any).""" + if action == "first": + _, nm, val = entries[0] + if len(entries) > 1: + log.warning("Dropping duplicate header(s) for %r (keeping first)", nm) + return [_make_pair(nm, val)], None + + if action == "last": + _, nm, val = entries[-1] + if len(entries) > 1: + log.warning("Dropping duplicate header(s) for %r (keeping last)", nm) + return [_make_pair(nm, val)], None + + if action == "concat": + if lname in comma_join_ok: + _, nm0, _ = entries[0] + joined = ", ".join(v for _, _, v in entries) + return [_make_pair(nm0, joined)], None + # not safe to join → error + return [], entries[0][1] + + # default "raise" + return [], entries[0][1] + + new_list: list[tuple[str, str]] = [] dup_errors: list[str] = [] - for lname, pairs in groups.items(): - if len(pairs) == 1: - items.append(pairs[0]) + for lname in ordered_keys: + entries = groups[lname] + if len(entries) == 1: + _, nm, val = entries[0] + new_list.append(_make_pair(nm, val)) + continue + + if lname in multi_ok: + # keep all, preserve order + for _, nm, val in entries: + new_list.append(_make_pair(nm, val)) continue - if lname in MULTI_OK: - # Preserve multiple fields as separate items. - items.extend(pairs) - else: - # Keep original casing from the first occurrence for the error. - dup_errors.append(pairs[0][0]) + + produced, err = _handle_disallowed_dupes(lname, entries) + new_list.extend(produced) + if err is not None: + dup_errors.append(err) if dup_errors: - raise ValueError( - "Duplicate header(s) not allowed: " + ", ".join(sorted(set(dup_errors))) + ". Define each at most once." - ) + names = ", ".join(sorted(set(dup_errors))) + raise ValueError(f"Duplicate header(s) not allowed: {names}. Define each at most once.") + + # Swap in the rebuilt list as simple (name, value) pairs. + self.headers_list = new_list # type: ignore[assignment] + + # Invalidate any cached mapping derived from headers_list (if present). + if "headers" in self.__dict__: + self.__dict__.pop("headers", None) + + return self + + def header_items(self) -> tuple[tuple[str, str], ...]: + """ + Serialize the current headers into (name, value) pairs. + + Assumes duplicate handling (if any) has already been performed elsewhere, + e.g., via `self.deduplicate_headers(...)`. + """ + + header_tuple_size = 2 + items: list[tuple[str, str]] = [] + append = items.append + + for h in self.headers_list: + # Support both object form (header_name/header_value) and 2-tuples. + if hasattr(h, "header_name") and hasattr(h, "header_value"): + append((h.header_name, h.header_value)) # BaseHeader-style + elif isinstance(h, (tuple, list)) and len(h) >= header_tuple_size: + append((h[0], h[1])) # tuple-like + else: + raise TypeError("header_items() expected elements with .header_name/.header_value or 2-tuples") return tuple(items) @@ -425,7 +522,9 @@ def set_headers(self, response: ResponseProtocol) -> None: # noqa: PLR0915 async def _apply_one(name: str, value: str) -> None: try: - await set_header(name, value) + result = set_header(name, value) + if inspect.isawaitable(result): + await result except (TypeError, ValueError, AttributeError) as e: raise HeaderSetError(f"Failed to set header {name!r}: {e}") from e @@ -525,7 +624,9 @@ async def set_headers_async(self, response: ResponseProtocol) -> None: async def _apply_one(name: str, value: str) -> None: try: - await set_header(name, value) + result = set_header(name, value) + if inspect.isawaitable(result): + await result except (TypeError, ValueError, AttributeError) as e: raise HeaderSetError(f"Failed to set header {name!r}: {e}") from e From 0b3348469641a79fe1ea2aa7a48011cd3d8ddc5f Mon Sep 17 00:00:00 2001 From: cak Date: Sat, 8 Nov 2025 09:37:49 -0500 Subject: [PATCH 012/117] headers: add allowlist_headers( New allowlist_headers(on_unexpected=raise|drop|warn, allow_extra, allow_x_prefixed) enforces a case-insensitive header allowlist (fixes #35). --- secure/secure.py | 98 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 97 insertions(+), 1 deletion(-) diff --git a/secure/secure.py b/secure/secure.py index d23e31f..412ee4f 100644 --- a/secure/secure.py +++ b/secure/secure.py @@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Protocol, runtime_checkable if TYPE_CHECKING: - from collections.abc import Awaitable, Mapping, MutableMapping + from collections.abc import Awaitable, Iterable, Mapping, MutableMapping from .headers import ( @@ -39,6 +39,23 @@ # 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", + "permissions-policy", + "referrer-policy", + "strict-transport-security", + "x-content-type-options", + "x-frame-options", + } +) + class HeaderSetError(RuntimeError): """Raised when applying a header to a response fails.""" @@ -442,6 +459,85 @@ def _handle_disallowed_dupes( return self + def allowlist_headers( # chainable, like deduplicate_headers() + self, + *, + allowed: Iterable[str] = DEFAULT_ALLOWED_HEADERS, + allow_extra: Iterable[str] | None = None, + on_unexpected: str = "raise", # "raise" | "drop" | "warn" + allow_x_prefixed: bool = False, # opt-in for private X-* headers + logger: logging.Logger | None = None, + ) -> Secure: + """ + Enforce a case-insensitive allowlist for header names in `self.headers_list`. + + Args: + allowed: Base allowlist of header names (case-insensitive). + allow_extra: Additional names to allow (e.g., app-specific). + on_unexpected: + - "raise": error on any name not in the allowlist (default). + - "drop": remove unexpected headers silently (or with warn if logger set). + - "warn": keep unexpected headers but log a warning. + allow_x_prefixed: If True, allows any header starting with "x-". + logger: Optional logger; used for "warn" or "drop" notifications. + + Returns: + self (chainable). + """ + log = logger or logging.getLogger("secure") + + # 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 _pair(h: BaseHeader | tuple[str, str]) -> tuple[str, str]: + # Support object with attributes or plain 2-tuple. + try: + return (h.header_name, h.header_value) # type: ignore[attr-defined] + except AttributeError: + return (h[0], h[1]) # type: ignore[index] + + def _keep(name_lc: str) -> bool: + return (name_lc in allowed_lc) or (allow_x_prefixed and name_lc.startswith("x-")) + + kept: list[tuple[str, str]] = [] + unexpected_names: list[str] = [] + + for h in self.headers_list: + name, value = _pair(h) + lname = name.lower() + + if _keep(lname): + kept.append((name, value)) + continue + + # Unexpected header handling + if on_unexpected == "warn": + log.warning("Unexpected header %r kept (not in allowlist)", name) + kept.append((name, value)) + elif on_unexpected == "drop": + log.warning("Unexpected header %r dropped (not in allowlist)", name) + # do not append + 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'." + ) + + # Replace with normalized (name, value) pairs (like deduplicate_headers). + self.headers_list = kept # type: ignore[assignment] + + # Invalidate any cached mapping derived from headers_list (if present). + if "headers" in self.__dict__: + self.__dict__.pop("headers", None) + + return self + def header_items(self) -> tuple[tuple[str, str], ...]: """ Serialize the current headers into (name, value) pairs. From af6d0aae93c5bf15eaa6c2dab5db8e4c77cbfa30 Mon Sep 17 00:00:00 2001 From: cak Date: Tue, 9 Dec 2025 05:39:08 -0500 Subject: [PATCH 013/117] Fix internal header invariants and normalization pipeline --- secure/secure.py | 195 +++++++++++++++++++++++------------------------ 1 file changed, 97 insertions(+), 98 deletions(-) diff --git a/secure/secure.py b/secure/secure.py index 412ee4f..0032941 100644 --- a/secure/secure.py +++ b/secure/secure.py @@ -157,6 +157,8 @@ def __init__( # noqa: PLR0913 if custom: self.headers_list.extend(custom) + self._headers_override: Mapping[str, str] | None = None + @classmethod def with_default_headers(cls) -> Secure: """ @@ -254,22 +256,26 @@ def validate_and_normalize_headers( # noqa: PLR0915 logger: logging.Logger | None = None, ) -> Secure: """ - Validate/normalize the *current* headers and replace the cached mapping in-place. - No persistent class state is added; behavior is controlled only by call arguments. + Validate and normalize the *current* header items and replace the + immutable headers mapping override in-place. + + This operates on `header_items()` (not `headers`) to preserve: + - ordering + - multi-valued headers + - deduplicated state Returns: self (chainable) """ - log = logger or logging.getLogger("secure") + log = logger or logging.getLogger(__name__) - # Token per RFC 7230 tchar (visible ASCII except separators). + # RFC 7230 token (visible ASCII except separators) header_name_re = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$") # Visible ASCII per RFCs sp = 0x20 vchar_min, vchar_max = 0x21, 0x7E - # obs-text (RFC 7230 §3.2.4) — rarely needed obs_min, obs_max = 0x80, 0xFF def _handle_invalid(msg: str) -> None: @@ -277,20 +283,22 @@ def _handle_invalid(msg: str) -> None: log.warning(msg) elif on_invalid == "raise" or strict: raise ValueError(msg) - # "drop" does nothing; caller will skip the pair - - def _validate_pair(name: str, value: str) -> tuple[str, str] | None: - # Normalize header name casing to canonical case-insensitive form. - nonlocal strict + def _validate_pair(name: str, value: str, strict_mode: bool = strict) -> tuple[str, str] | None: # noqa: PLR0912 name = name.strip() + if not header_name_re.match(name): _handle_invalid(f"Invalid header name {name!r} (RFC 7230 token required)") - return None # already raised if strict/raise + return None - # CR/LF must never appear in header values. If not strict, coalesce lines. + # 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: + if strict_mode: raise ValueError(f"Header {name!r} contained CR/LF") value = " ".join(value.splitlines()) @@ -299,19 +307,16 @@ def _validate_pair(name: str, value: str) -> tuple[str, str] | None: _handle_invalid(f"Dropping header {name!r}: empty value") return None - # Fast path: if all chars are HTAB/SP/VCHAR (and optional obs-text), keep as-is. - # Bind lookups locally for speed in tight loops. - _sp = sp - _vmin, _vmax = vchar_min, vchar_max _allow_obs = allow_obs_text - _omin, _omax = obs_min, obs_max - # Check if sanitization is needed; avoid building a new string if not. needs_sanitize = False for ch in value: code = ord(ch) if not ( - ch == "\t" or code == _sp or (_vmin <= code <= _vmax) or (_allow_obs and (_omin <= code <= _omax)) + ch == "\t" + or code == sp + or (vchar_min <= code <= vchar_max) + or (_allow_obs and (obs_min <= code <= obs_max)) ): needs_sanitize = True break @@ -319,48 +324,44 @@ def _validate_pair(name: str, value: str) -> tuple[str, str] | None: if not needs_sanitize: return name, value - # Sanitize disallowed characters: strict -> error, else replace with SP. - sanitized_chars: list[str] = [] - append = sanitized_chars.append + sanitized: list[str] = [] + append = sanitized.append + for ch in value: code = ord(ch) - if ch == "\t" or code == _sp or (_vmin <= code <= _vmax) or (_allow_obs and (_omin <= code <= _omax)): + 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: + if strict_mode: raise ValueError(f"Header {name!r} contains disallowed char U+{code:04X}") append(" ") - norm_value = "".join(sanitized_chars).strip() + 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 - # Pull the current cached mapping (a MappingProxyType) and rebuild it. - try: - current = dict(self.headers) - except Exception as e: # pragma: no cover - raise RuntimeError( - "Secure.validate_and_normalize_headers() expected self.headers to be mapping-like" - ) from e + items = self.header_items() cleaned: dict[str, str] = {} - for k, v in current.items(): - pair = _validate_pair(k, v) + for name, value in items: + pair = _validate_pair(name, value) if pair is None: continue - name, value = pair - cleaned[name] = value + k, v = pair + cleaned[k] = v - # Ensure `headers` is a @cached_property so we can swap its cached value. - hdr_descr = getattr(type(self), "headers", None) - if not isinstance(hdr_descr, cached_property): - raise TypeError("`headers` must be a @cached_property to swap it in-place.") + self._headers_override = MappingProxyType(cleaned) + + self.__dict__.pop("headers", None) - # Overwrite the cached property value with a read-only mapping. - self.__dict__["headers"] = MappingProxyType(cleaned) return self def deduplicate_headers( @@ -378,71 +379,72 @@ def deduplicate_headers( Returns: self (chainable) """ - log = logger or logging.getLogger("secure") + log = logger or logging.getLogger(__name__) - # Group by lowercase name; store (first_index, OriginalName, value) - groups: dict[str, list[tuple[int, str, str]]] = defaultdict(list) + # Group by lowercase name; store (first_index, BaseHeader) + groups: dict[str, list[tuple[int, BaseHeader]]] = defaultdict(list) - # Read items robustly (object with attrs or 2-tuple) for idx, h in enumerate(self.headers_list): - try: - nm = h.header_name - val = h.header_value - except AttributeError: - nm, val = h # type: ignore[misc] - groups[nm.lower()].append((idx, nm, val)) + 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 _make_pair(name: str, value: str) -> tuple[str, str]: - return (name, value) + 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, str, str]] - ) -> tuple[list[tuple[str, str]], str | None]: + lname: str, + entries: list[tuple[int, BaseHeader]], + ) -> tuple[list[BaseHeader], str | None]: """Return (new_items, dup_error_name_if_any).""" + if action == "first": - _, nm, val = entries[0] + _, h = entries[0] if len(entries) > 1: - log.warning("Dropping duplicate header(s) for %r (keeping first)", nm) - return [_make_pair(nm, val)], None + 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": - _, nm, val = entries[-1] + _, h = entries[-1] if len(entries) > 1: - log.warning("Dropping duplicate header(s) for %r (keeping last)", nm) - return [_make_pair(nm, val)], None + 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: - _, nm0, _ = entries[0] - joined = ", ".join(v for _, _, v in entries) - return [_make_pair(nm0, joined)], None - # not safe to join → error - return [], entries[0][1] + 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] + return [], entries[0][1].header_name - new_list: list[tuple[str, str]] = [] + new_list: list[BaseHeader] = [] dup_errors: list[str] = [] for lname in ordered_keys: entries = groups[lname] + if len(entries) == 1: - _, nm, val = entries[0] - new_list.append(_make_pair(nm, val)) + _, h = entries[0] + new_list.append(_clone(h.header_name, h.header_value)) continue if lname in multi_ok: - # keep all, preserve order - for _, nm, val in entries: - new_list.append(_make_pair(nm, val)) + 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) @@ -450,12 +452,9 @@ def _handle_disallowed_dupes( names = ", ".join(sorted(set(dup_errors))) raise ValueError(f"Duplicate header(s) not allowed: {names}. Define each at most once.") - # Swap in the rebuilt list as simple (name, value) pairs. - self.headers_list = new_list # type: ignore[assignment] + self.headers_list = new_list - # Invalidate any cached mapping derived from headers_list (if present). - if "headers" in self.__dict__: - self.__dict__.pop("headers", None) + self.__dict__.pop("headers", None) return self @@ -476,7 +475,7 @@ def allowlist_headers( # chainable, like deduplicate_headers() allow_extra: Additional names to allow (e.g., app-specific). on_unexpected: - "raise": error on any name not in the allowlist (default). - - "drop": remove unexpected headers silently (or with warn if logger set). + - "drop": remove unexpected headers (logs if logger is set). - "warn": keep unexpected headers but log a warning. allow_x_prefixed: If True, allows any header starting with "x-". logger: Optional logger; used for "warn" or "drop" notifications. @@ -484,38 +483,34 @@ def allowlist_headers( # chainable, like deduplicate_headers() Returns: self (chainable). """ - log = logger or logging.getLogger("secure") + 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 _pair(h: BaseHeader | tuple[str, str]) -> tuple[str, str]: - # Support object with attributes or plain 2-tuple. - try: - return (h.header_name, h.header_value) # type: ignore[attr-defined] - except AttributeError: - return (h[0], h[1]) # type: ignore[index] - def _keep(name_lc: str) -> bool: return (name_lc in allowed_lc) or (allow_x_prefixed and name_lc.startswith("x-")) - kept: list[tuple[str, str]] = [] + kept: list[BaseHeader] = [] unexpected_names: list[str] = [] for h in self.headers_list: - name, value = _pair(h) + 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((name, value)) + kept.append(h) continue # Unexpected header handling if on_unexpected == "warn": log.warning("Unexpected header %r kept (not in allowlist)", name) - kept.append((name, value)) + kept.append(h) elif on_unexpected == "drop": log.warning("Unexpected header %r dropped (not in allowlist)", name) # do not append @@ -529,12 +524,11 @@ def _keep(name_lc: str) -> bool: "Enable allow_extra or set on_unexpected to 'drop'/'warn'." ) - # Replace with normalized (name, value) pairs (like deduplicate_headers). - self.headers_list = kept # type: ignore[assignment] + self.headers_list = kept - # Invalidate any cached mapping derived from headers_list (if present). - if "headers" in self.__dict__: - self.__dict__.pop("headers", None) + # Invalidate any cached mapping / overrides derived from headers_list. + self._headers_override = None + self.__dict__.pop("headers", None) return self @@ -571,14 +565,19 @@ def headers(self) -> Mapping[str, str]: including headers in MULTI_OK. Use `header_items()` or the setters to emit multi-valued headers like Content-Security-Policy or Set-Cookie. """ + if self._headers_override is not None: + return self._headers_override + data: dict[str, str] = {} seen: set[str] = set() + 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) def set_headers(self, response: ResponseProtocol) -> None: # noqa: PLR0915 From eb7b9169a859bc0e95cc7d7829eb6cbc4c3d775e Mon Sep 17 00:00:00 2001 From: cak Date: Tue, 9 Dec 2025 05:42:44 -0500 Subject: [PATCH 014/117] refactor: enforce synchronous header setting in set_headers() --- secure/secure.py | 114 +++++++++++++---------------------------------- 1 file changed, 32 insertions(+), 82 deletions(-) diff --git a/secure/secure.py b/secure/secure.py index 0032941..980fdd2 100644 --- a/secure/secure.py +++ b/secure/secure.py @@ -1,6 +1,5 @@ from __future__ import annotations -import asyncio from collections import defaultdict from enum import Enum from functools import cached_property @@ -11,7 +10,7 @@ from typing import TYPE_CHECKING, Protocol, runtime_checkable if TYPE_CHECKING: - from collections.abc import Awaitable, Iterable, Mapping, MutableMapping + from collections.abc import Iterable, Mapping, MutableMapping from .headers import ( @@ -580,19 +579,21 @@ def headers(self) -> Mapping[str, str]: return MappingProxyType(data) - def set_headers(self, response: ResponseProtocol) -> None: # noqa: PLR0915 + def set_headers(self, response: ResponseProtocol) -> None: """ Apply configured headers **synchronously** to `response`. - Supports: - - `set_header(key, value)`: async (driven with `asyncio.run` if no running loop) or sync - (awaits returned awaitable if present, else sets directly). - - `.headers` mapping: async `__setitem__` (driven with `asyncio.run`) or sync. + This method is STRICTLY sync-only. + If an async setter is detected, a RuntimeError is raised. + + Supported patterns: + - response.set_header(name, value) → sync only + - response.headers[name] = value → sync mapping Raises ------ RuntimeError - If an async setter is detected while a loop is already running. + If any async setter is detected. AttributeError If the response lacks both `.set_header` and `.headers`. HeaderSetError @@ -601,95 +602,44 @@ def set_headers(self, response: ResponseProtocol) -> None: # noqa: PLR0915 items = self.header_items() - if isinstance(response, SetHeaderProtocol): + # --- Path 1: response.set_header(...) --- + if hasattr(response, "set_header"): set_header = response.set_header if inspect.iscoroutinefunction(set_header): - try: - asyncio.get_running_loop() - except RuntimeError: - pass - else: - raise RuntimeError( - "Asynchronous 'set_header' detected while an event loop is running. " - "Use 'await set_headers_async(response)'." - ) - - async def _apply_one(name: str, value: str) -> None: - try: - result = set_header(name, value) - if inspect.isawaitable(result): - await result - except (TypeError, ValueError, AttributeError) as e: - raise HeaderSetError(f"Failed to set header {name!r}: {e}") from e - - async def _apply_all() -> None: - for k, v in items: - await _apply_one(k, v) + raise RuntimeError( + "Async 'set_header' detected in sync context. Use 'await set_headers_async(response)'." + ) - asyncio.run(_apply_all()) - return + 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 - def _set_header_one(name: str, value: str) -> None: - try: - res = set_header(name, value) - if inspect.isawaitable(res): - try: - asyncio.get_running_loop() - except RuntimeError: - - async def _await_one(a: Awaitable[object]) -> None: - await a - - asyncio.run(_await_one(res)) # type: ignore[arg-type] - else: - raise RuntimeError( - "Asynchronous header operation detected while an event loop is running. " - "Use 'await set_headers_async(response)'." - ) - except (TypeError, ValueError, AttributeError) as e: - raise HeaderSetError(f"Failed to set header {name!r}: {e}") from e - - for k, v in items: - _set_header_one(k, v) return + # --- Path 2: response.headers[...] mapping --- if hasattr(response, "headers"): hdrs = response.headers setitem = getattr(hdrs, "__setitem__", None) if inspect.iscoroutinefunction(setitem): - try: - asyncio.get_running_loop() - except RuntimeError: - pass - else: - raise RuntimeError( - "Asynchronous header operation detected while an event loop is running. " - "Use 'await set_headers_async(response)'." - ) - - async def _apply_hdr_one(name: str, value: str) -> None: - try: - await setitem(name, value) # type: ignore[misc] - except (TypeError, ValueError, AttributeError) as e: - raise HeaderSetError(f"Failed to set header {name!r}: {e}") from e - - async def _apply_all_hdrs() -> None: - for k, v in items: - await _apply_hdr_one(k, v) - - asyncio.run(_apply_all_hdrs()) - return + raise RuntimeError( + "Async headers mapping detected in sync context. Use 'await set_headers_async(response)'." + ) - def _hdrs_set_one(name: str, value: str) -> None: - try: + try: + for name, value in items: hdrs[name] = value - except (TypeError, ValueError, AttributeError) as e: - raise HeaderSetError(f"Failed to set header {name!r}: {e}") from e + except (TypeError, ValueError, AttributeError) as e: + raise HeaderSetError(f"Failed to set headers: {e}") from e - for k, v in items: - _hdrs_set_one(k, v) return raise AttributeError("Response object does not support setting headers.") From a17dfd8323bb657563791ca05e69016f12f45a2e Mon Sep 17 00:00:00 2001 From: cak Date: Tue, 9 Dec 2025 05:44:31 -0500 Subject: [PATCH 015/117] refactor: enforce async-only header setting in apply_headers() --- secure/secure.py | 73 +++++++++++++++++------------------------------- 1 file changed, 26 insertions(+), 47 deletions(-) diff --git a/secure/secure.py b/secure/secure.py index 980fdd2..11e8b70 100644 --- a/secure/secure.py +++ b/secure/secure.py @@ -648,9 +648,14 @@ async def set_headers_async(self, response: ResponseProtocol) -> None: """ Apply configured headers **asynchronously** to `response`. - Supports: - - `set_header(key, value)`: async (awaited) or sync (awaits returned awaitable if present). - - `.headers` mapping: async `__setitem__` (awaited) or sync setitem. + This method is STRICTLY async-only. + + Supported patterns: + - await response.set_header(name, value) + - await response.headers.__setitem__(name, value) + + If a sync-only setter is detected, it is called directly. + If an async setter is detected, it is awaited. Raises ------ @@ -662,59 +667,33 @@ async def set_headers_async(self, response: ResponseProtocol) -> None: items = self.header_items() - if isinstance(response, SetHeaderProtocol): + # --- Path 1: response.set_header(...) --- + if hasattr(response, "set_header"): set_header = response.set_header - if inspect.iscoroutinefunction(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 - async def _apply_one(name: str, value: str) -> None: - try: - result = set_header(name, value) - if inspect.isawaitable(result): - await result - except (TypeError, ValueError, AttributeError) as e: - raise HeaderSetError(f"Failed to set header {name!r}: {e}") from e - - for k, v in items: - await _apply_one(k, v) - return - - async def _apply_one_syncish(name: str, value: str) -> None: - try: - res = set_header(name, value) - if inspect.isawaitable(res): - await res # type: ignore[misc] - except (TypeError, ValueError, AttributeError) as e: - raise HeaderSetError(f"Failed to set header {name!r}: {e}") from e - - for k, v in items: - await _apply_one_syncish(k, v) return + # --- Path 2: response.headers[...] mapping --- if hasattr(response, "headers"): hdrs = response.headers - setitem = getattr(hdrs, "__setitem__", None) - - if inspect.iscoroutinefunction(setitem): - - async def _apply_hdr_one(name: str, value: str) -> None: - try: - await setitem(name, value) # type: ignore[misc] - except (TypeError, ValueError, AttributeError) as e: - raise HeaderSetError(f"Failed to set header {name!r}: {e}") from e - - for k, v in items: - await _apply_hdr_one(k, v) - return - def _hdrs_set_one(name: str, value: str) -> None: - try: - hdrs[name] = value - except (TypeError, ValueError, AttributeError) as e: - raise HeaderSetError(f"Failed to set header {name!r}: {e}") from e + try: + for name, value in items: + result = hdrs.__setitem__(name, value) # type: ignore[misc] + if inspect.isawaitable(result): + await result + except (TypeError, ValueError, AttributeError) as e: + raise HeaderSetError(f"Failed to set headers: {e}") from e - for k, v in items: - _hdrs_set_one(k, v) return + # --- Unsupported response --- raise AttributeError("Response object does not support setting headers.") From 8930356c7fa6a1acc93dfb034c4e3e73827de58e Mon Sep 17 00:00:00 2001 From: cak Date: Tue, 9 Dec 2025 05:52:20 -0500 Subject: [PATCH 016/117] refactor: remove runtime_checkable decorator from protocol classes --- secure/secure.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/secure/secure.py b/secure/secure.py index 11e8b70..8bfde6f 100644 --- a/secure/secure.py +++ b/secure/secure.py @@ -7,7 +7,7 @@ import logging import re from types import MappingProxyType -from typing import TYPE_CHECKING, Protocol, runtime_checkable +from typing import TYPE_CHECKING, Protocol if TYPE_CHECKING: from collections.abc import Iterable, Mapping, MutableMapping @@ -60,14 +60,12 @@ class HeaderSetError(RuntimeError): """Raised when applying a header to a response fails.""" -@runtime_checkable class HeadersProtocol(Protocol): """Protocol for response objects that have a 'headers' attribute.""" headers: MutableMapping[str, str] -@runtime_checkable class SetHeaderProtocol(Protocol): """Protocol for response objects that have a 'set_header' method.""" @@ -452,7 +450,7 @@ def _handle_disallowed_dupes( 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) return self From 4cc4c4f345966ccb4a6dc1ee046f7765d872a361 Mon Sep 17 00:00:00 2001 From: cak Date: Tue, 9 Dec 2025 05:56:12 -0500 Subject: [PATCH 017/117] fix: handle duplicate headers during normalization and clarify async method usage --- secure/secure.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/secure/secure.py b/secure/secure.py index 8bfde6f..6a06c65 100644 --- a/secure/secure.py +++ b/secure/secure.py @@ -353,6 +353,13 @@ def _validate_pair(name: str, value: str, strict_mode: bool = strict) -> tuple[s if pair is None: continue k, v = pair + + if k in cleaned: + raise ValueError( + f"Duplicate header {k!r} encountered during normalization. " + "Run deduplicate_headers() first or use header_items() for multi-valued headers." + ) + cleaned[k] = v self._headers_override = MappingProxyType(cleaned) @@ -646,14 +653,16 @@ async def set_headers_async(self, response: ResponseProtocol) -> None: """ Apply configured headers **asynchronously** to `response`. - This method is STRICTLY async-only. + This method is STRICTLY async-only and must be awaited. Supported patterns: - - await response.set_header(name, value) - - await response.headers.__setitem__(name, value) + - `await response.set_header(name, value)` for async frameworks + - `response.set_header(name, value)` for sync setters returning `None` + - `await response.headers.__setitem__(name, value)` for async mappings + - `response.headers[name] = value` for sync mappings - If a sync-only setter is detected, it is called directly. - If an async setter is detected, it is awaited. + If a setter returns an awaitable, it is awaited. + If it returns `None`, it is treated as a synchronous operation. Raises ------ From 229c2c5e1527c8a24fa7d34fa7845c9c728c185c Mon Sep 17 00:00:00 2001 From: cak Date: Wed, 10 Dec 2025 05:04:30 -0500 Subject: [PATCH 018/117] refactor: improve documentation and type hints for header management classes --- secure/secure.py | 450 +++++++++++++++++++++++++++++++---------------- 1 file changed, 302 insertions(+), 148 deletions(-) diff --git a/secure/secure.py b/secure/secure.py index 6a06c65..db1aecd 100644 --- a/secure/secure.py +++ b/secure/secure.py @@ -7,12 +7,11 @@ import logging import re from types import MappingProxyType -from typing import TYPE_CHECKING, Protocol +from typing import TYPE_CHECKING, Literal, Protocol if TYPE_CHECKING: from collections.abc import Iterable, Mapping, MutableMapping - from .headers import ( BaseHeader, CacheControl, @@ -28,6 +27,10 @@ XFrameOptions, ) +# --------------------------------------------------------------------------- +# Configuration / constants +# --------------------------------------------------------------------------- + # Headers that may appear multiple times as separate fields. MULTI_OK: frozenset[str] = frozenset( { @@ -35,7 +38,7 @@ } ) -# Headers where RFC7230-style comma merging is safe/expected +# Headers where RFC7230-style comma merging is safe/expected. COMMA_JOIN_OK: frozenset[str] = frozenset({"cache-control"}) # A default allowlist of secure headers. @@ -55,47 +58,83 @@ } ) +# 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): - """Protocol for response objects that have a 'headers' attribute.""" + """Protocol for response objects that expose a `headers` mapping.""" headers: MutableMapping[str, str] class SetHeaderProtocol(Protocol): - """Protocol for response objects that have a 'set_header' method.""" + """Protocol for response objects that expose a `set_header(name, value)` method.""" - def set_header(self, key: str, value: str) -> None: ... + def set_header(self, key: str, value: str) -> object | None: ... +# Union type for supported response objects. This keeps the public API broad +# enough to cover FastAPI/Starlette, Flask, Django, etc. ResponseProtocol = HeadersProtocol | SetHeaderProtocol -""" -Union type for response objects that conform to either HeadersProtocol or SetHeaderProtocol. -This allows the Secure class to work with a variety of web frameworks. -""" + + +# --------------------------------------------------------------------------- +# Presets +# --------------------------------------------------------------------------- class Preset(Enum): - """Enumeration of predefined security presets for the Secure class.""" + """Predefined security header presets for :class:`Secure`.""" BASIC = "basic" STRICT = "strict" +# --------------------------------------------------------------------------- +# Core API +# --------------------------------------------------------------------------- + + class Secure: """ - A class to configure and apply security headers for web applications. + 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.). + + Typical pipeline: + + >>> secure = ( + ... Secure.with_default_headers().allowlist_headers().deduplicate_headers().validate_and_normalize_headers() + ... ) - The Secure class allows you to specify various HTTP security headers to enhance - the security of your web application. You can use predefined presets or customize - the headers as needed. + Then, inside your framework integration: - Attributes: - headers_list (list[BaseHeader]): List of header objects representing the configured headers. + >>> secure.set_headers(response) + >>> # or in async contexts: + >>> await secure.set_headers_async(response) + + Attributes + ---------- + headers_list : + Ordered list of header objects representing the configured headers. + Methods like :meth:`allowlist_headers` and :meth:`deduplicate_headers` + operate on this list in place and return ``self`` for chaining. """ def __init__( # noqa: PLR0913 @@ -114,24 +153,35 @@ def __init__( # noqa: PLR0913 xfo: XFrameOptions | None = None, ) -> None: """ - Initialize the Secure instance with the specified security headers. - - Args: - cache (CacheControl | None): The Cache-Control header configuration. - coep (CrossOriginEmbedderPolicy | None): The Cross-Origin-Embedder-Policy header configuration. - coop (CrossOriginOpenerPolicy | None): The Cross-Origin-Opener-Policy header configuration. - csp (ContentSecurityPolicy | None): The Content-Security-Policy header configuration. - custom (list[CustomHeader] | None): A list of custom headers to include. - hsts (StrictTransportSecurity | None): The Strict-Transport-Security header configuration. - permissions (PermissionsPolicy | None): The Permissions-Policy header configuration. - referrer (ReferrerPolicy | None): The Referrer-Policy header configuration. - server (Server | None): The Server header configuration. - xcto (XContentTypeOptions | None): The X-Content-Type-Options header configuration. - xfo (XFrameOptions | None): The X-Frame-Options header configuration. + Initialize a :class:`Secure` instance with the specified security headers. + + Parameters + ---------- + cache : + Cache-Control header configuration. + coep : + Cross-Origin-Embedder-Policy header configuration. + coop : + Cross-Origin-Opener-Policy header configuration. + csp : + Content-Security-Policy header configuration. + custom : + Additional custom headers to include (app-specific). + hsts : + Strict-Transport-Security header configuration. + permissions : + Permissions-Policy header configuration. + referrer : + Referrer-Policy header configuration. + server : + Server header configuration. + xcto : + X-Content-Type-Options header configuration. + xfo : + X-Frame-Options header configuration. """ - # Store headers in the order defined by the parameters self.headers_list: list[BaseHeader] = [] - # List of header parameters in the desired order + params: list[BaseHeader | None] = [ cache, coep, @@ -145,12 +195,10 @@ def __init__( # noqa: PLR0913 xfo, ] - # Append non-None headers to the headers list for header in params: if header is not None: self.headers_list.append(header) - # Add custom headers if provided if custom: self.headers_list.extend(custom) @@ -159,10 +207,16 @@ def __init__( # noqa: PLR0913 @classmethod def with_default_headers(cls) -> Secure: """ - Create a Secure instance with a default set of common security headers. + Create a :class:`Secure` instance with a sensible default set of headers. - Returns: - Secure: An instance of Secure with default security headers configured. + This preset is suitable for many modern applications and can be further + customized with methods like :meth:`allowlist_headers` or by adding + additional header objects. + + Returns + ------- + Secure + Instance preconfigured with a default set of headers. """ return cls( cache=CacheControl().no_store(), @@ -183,16 +237,23 @@ def with_default_headers(cls) -> Secure: @classmethod def from_preset(cls, preset: Preset) -> Secure: """ - Create a Secure instance using a predefined security preset. + Create a :class:`Secure` instance using a predefined security preset. - Args: - preset (Preset): The security preset to use (Preset.BASIC or Preset.STRICT). + Parameters + ---------- + preset : + The security preset to use, for example :data:`Preset.BASIC` + or :data:`Preset.STRICT`. - Returns: - Secure: An instance of Secure configured with the selected preset. + Returns + ------- + Secure + Instance configured with the selected preset. - Raises: - ValueError: If an unknown preset is provided. + Raises + ------ + ValueError + If an unknown preset is provided. """ match preset: case Preset.BASIC: @@ -227,50 +288,63 @@ def from_preset(cls, preset: Preset) -> Secure: raise ValueError(f"Unknown preset: {preset}") def __str__(self) -> str: - """ - Return a string representation of the security headers. - - Returns: - str: A string listing the headers and their values. - """ + """Return a human-readable listing of headers and their values.""" return "\n".join(f"{header.header_name}: {header.header_value}" for header in self.headers_list) def __repr__(self) -> str: - """ - Return a detailed string representation of the Secure instance. - - Returns: - str: A string representation including the list of headers. - """ + """Return a detailed representation of the :class:`Secure` instance.""" return f"{self.__class__.__name__}(headers_list={self.headers_list!r})" - def validate_and_normalize_headers( # noqa: PLR0915 + # ------------------------------------------------------------------ + # Header normalization / safety helpers + # ------------------------------------------------------------------ + + def validate_and_normalize_headers( self, *, - on_invalid: str = "drop", # "drop" | "warn" | "raise" - strict: bool = False, # hard-fail on CR/LF + illegal chars + on_invalid: OnInvalidPolicy = "drop", + strict: bool = False, allow_obs_text: bool = False, logger: logging.Logger | None = None, ) -> Secure: """ - Validate and normalize the *current* header items and replace the - immutable headers mapping override in-place. - - This operates on `header_items()` (not `headers`) to preserve: - - ordering - - multi-valued headers - - deduplicated state + Validate and normalize the current header items and cache an immutable mapping. + + 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`. + + Parameters + ---------- + on_invalid : + Policy for invalid headers: + - ``"drop"``: silently drop invalid entries (default). + - ``"warn"``: log a warning and drop invalid entries. + - ``"raise"``: raise :class:`ValueError` on invalid entries. + strict : + If true, treat CR/LF and disallowed characters as hard errors. + allow_obs_text : + If true, allow "obs-text" (bytes 0x80-0xFF) as per older RFCs. + logger : + Optional :class:`logging.Logger` used when ``on_invalid="warn"`` or + when dropping headers with ``on_invalid="drop"`` but logging is desired. + + Returns + ------- + Secure + The same instance, for call chaining. - Returns: - self (chainable) + Raises + ------ + ValueError + If a header name is invalid or if duplicates are found when building + the single-valued mapping. """ - log = logger or logging.getLogger(__name__) - # RFC 7230 token (visible ASCII except separators) - header_name_re = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$") - - # Visible ASCII per RFCs + # Visible ASCII per RFCs. sp = 0x20 vchar_min, vchar_max = 0x21, 0x7E obs_min, obs_max = 0x80, 0xFF @@ -281,19 +355,19 @@ def _handle_invalid(msg: str) -> None: elif on_invalid == "raise" or strict: raise ValueError(msg) - def _validate_pair(name: str, value: str, strict_mode: bool = strict) -> tuple[str, str] | None: # noqa: PLR0912 + def _validate_pair(name: str, value: str, strict_mode: bool = strict) -> tuple[str, str] | None: name = name.strip() - if not header_name_re.match(name): + 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 + # 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 + # CR/LF must never appear in values. if ("\r" in value) or ("\n" in value): if strict_mode: raise ValueError(f"Header {name!r} contained CR/LF") @@ -348,22 +422,28 @@ def _validate_pair(name: str, value: str, strict_mode: bool = strict) -> tuple[s 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 - k, v = pair - if k in cleaned: + norm_name, norm_value = pair + lname = norm_name.lower() + + if lname in seen_lc: raise ValueError( - f"Duplicate header {k!r} encountered during normalization. " + f"Duplicate header {norm_name!r} encountered during normalization. " "Run deduplicate_headers() first or use header_items() for multi-valued headers." ) - cleaned[k] = v + seen_lc.add(lname) + cleaned[norm_name] = norm_value self._headers_override = MappingProxyType(cleaned) + # Reset cached_property. self.__dict__.pop("headers", None) return self @@ -371,21 +451,45 @@ def _validate_pair(name: str, value: str, strict_mode: bool = strict) -> tuple[s def deduplicate_headers( self, *, - action: str = "raise", # "raise" | "first" | "last" | "concat" + action: DeduplicateAction = "raise", comma_join_ok: frozenset[str] = COMMA_JOIN_OK, multi_ok: frozenset[str] = MULTI_OK, logger: logging.Logger | None = None, ) -> Secure: """ - Deduplicate current headers in-place according to the chosen policy, - while respecting headers explicitly allowed to be multi-valued. + Deduplicate headers in :attr:`headers_list` according to the chosen policy. + + Parameters + ---------- + action : + Policy when encountering disallowed duplicates: + - ``"raise"``: raise :class:`ValueError` (default). + - ``"first"``: keep the first instance and drop others. + - ``"last"``: keep the last instance and drop others. + - ``"concat"``: join values with commas when safe. + comma_join_ok : + Names (lowercased) for which RFC 7230-style comma joining is safe. + multi_ok : + Names (lowercased) that are allowed to appear multiple times + (for example Content-Security-Policy). + logger : + Optional :class:`logging.Logger` used for warning messages when + dropping duplicates in non-``"raise"`` modes. + + Returns + ------- + Secure + The same instance, for call chaining. - Returns: - self (chainable) + Raises + ------ + ValueError + 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) + # 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): @@ -393,11 +497,11 @@ def deduplicate_headers( raise TypeError("deduplicate_headers() requires BaseHeader objects only") groups[h.header_name.lower()].append((idx, h)) - # Stable processing order by first appearance + # 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 + # Preserve type stability using CustomHeader as neutral carrier. return CustomHeader(header=name, value=value) def _handle_disallowed_dupes( @@ -405,7 +509,6 @@ def _handle_disallowed_dupes( 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: @@ -424,10 +527,10 @@ def _handle_disallowed_dupes( joined = ", ".join(h.header_value for _, h in entries) return [_clone(nm, joined)], None - # not safe to join + # Not safe to join. return [], entries[0][1].header_name - # default "raise" + # Default "raise". return [], entries[0][1].header_name new_list: list[BaseHeader] = [] @@ -462,30 +565,44 @@ def _handle_disallowed_dupes( return self - def allowlist_headers( # chainable, like deduplicate_headers() + def allowlist_headers( self, *, allowed: Iterable[str] = DEFAULT_ALLOWED_HEADERS, allow_extra: Iterable[str] | None = None, - on_unexpected: str = "raise", # "raise" | "drop" | "warn" - allow_x_prefixed: bool = False, # opt-in for private X-* headers + on_unexpected: OnUnexpectedPolicy = "raise", + allow_x_prefixed: bool = False, logger: logging.Logger | None = None, ) -> Secure: """ - Enforce a case-insensitive allowlist for header names in `self.headers_list`. - - Args: - allowed: Base allowlist of header names (case-insensitive). - allow_extra: Additional names to allow (e.g., app-specific). - on_unexpected: - - "raise": error on any name not in the allowlist (default). - - "drop": remove unexpected headers (logs if logger is set). - - "warn": keep unexpected headers but log a warning. - allow_x_prefixed: If True, allows any header starting with "x-". - logger: Optional logger; used for "warn" or "drop" notifications. - - Returns: - self (chainable). + Enforce a case-insensitive allowlist for header names in :attr:`headers_list`. + + Parameters + ---------- + allowed : + Base allowlist of header names (case-insensitive). + allow_extra : + Additional names to allow, for example app-specific headers. + on_unexpected : + Policy for headers not in the allowlist: + - ``"raise"``: error on any name not in the allowlist (default). + - ``"drop"``: remove unexpected headers (logs if logger is set). + - ``"warn"``: keep unexpected headers but log a warning. + allow_x_prefixed : + If true, allows any header starting with ``"x-"``. + logger : + Optional :class:`logging.Logger` used for warnings in ``"drop"`` and + ``"warn"`` modes. + + Returns + ------- + Secure + The same instance, for call chaining. + + Raises + ------ + ValueError + If ``on_unexpected="raise"`` and any header is not in the allowlist. """ log = logger or logging.getLogger(__name__) @@ -511,13 +628,11 @@ def _keep(name_lc: str) -> bool: kept.append(h) continue - # Unexpected header handling 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) - # do not append else: # "raise" (default) unexpected_names.append(name) @@ -536,24 +651,37 @@ def _keep(name_lc: str) -> bool: return self + # ------------------------------------------------------------------ + # Serialization / access + # ------------------------------------------------------------------ + def header_items(self) -> tuple[tuple[str, str], ...]: """ - Serialize the current headers into (name, value) pairs. + Serialize the current headers into ``(name, value)`` pairs. - Assumes duplicate handling (if any) has already been performed elsewhere, - e.g., via `self.deduplicate_headers(...)`. - """ + 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. + Returns + ------- + 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: - # Support both object form (header_name/header_value) and 2-tuples. if hasattr(h, "header_name") and hasattr(h, "header_value"): - append((h.header_name, h.header_value)) # BaseHeader-style + append((h.header_name, h.header_value)) elif isinstance(h, (tuple, list)) and len(h) >= header_tuple_size: - append((h[0], h[1])) # tuple-like + append((h[0], h[1])) else: raise TypeError("header_items() expected elements with .header_name/.header_value or 2-tuples") @@ -564,10 +692,22 @@ def headers(self) -> Mapping[str, str]: """ Single-valued, immutable mapping of headers. - Raises: - ValueError: if any header name appears more than once (case-insensitive), - including headers in MULTI_OK. Use `header_items()` or the setters to - emit multi-valued headers like Content-Security-Policy or Set-Cookie. + 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. + + Returns + ------- + Mapping[str, str] + Immutable mapping of header names to header values. + + Raises + ------ + ValueError + If any header name appears more than once (case-insensitive) when + building the mapping and no override is set. This includes headers + 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 @@ -584,30 +724,41 @@ def headers(self) -> Mapping[str, str]: return MappingProxyType(data) + # ------------------------------------------------------------------ + # Application to framework responses + # ------------------------------------------------------------------ + def set_headers(self, response: ResponseProtocol) -> None: """ - Apply configured headers **synchronously** to `response`. + Apply configured headers synchronously to ``response``. - This method is STRICTLY sync-only. - If an async setter is detected, a RuntimeError is raised. + This method is strictly sync-only. It is suitable for synchronous + frameworks or sync response objects in async frameworks. - Supported patterns: - - response.set_header(name, value) → sync only - - response.headers[name] = value → sync mapping + Supported patterns + ------------------ + * ``response.set_header(name, value)`` (synchronous). + * ``response.headers[name] = value`` (mapping interface). + + Parameters + ---------- + response : + Response object implementing either :class:`SetHeaderProtocol` or + :class:`HeadersProtocol`. Raises ------ RuntimeError - If any async setter is detected. + If an async setter is detected (for example an async method is used + in a sync context). AttributeError - If the response lacks both `.set_header` and `.headers`. + If the response lacks both ``.set_header`` and ``.headers``. HeaderSetError If setting an individual header fails. """ - items = self.header_items() - # --- Path 1: response.set_header(...) --- + # Path 1: response.set_header(...) if hasattr(response, "set_header"): set_header = response.set_header @@ -629,7 +780,7 @@ def set_headers(self, response: ResponseProtocol) -> None: return - # --- Path 2: response.headers[...] mapping --- + # Path 2: response.headers[...] mapping if hasattr(response, "headers"): hdrs = response.headers setitem = getattr(hdrs, "__setitem__", None) @@ -651,30 +802,34 @@ def set_headers(self, response: ResponseProtocol) -> None: async def set_headers_async(self, response: ResponseProtocol) -> None: """ - Apply configured headers **asynchronously** to `response`. + Apply configured headers asynchronously to ``response``. - This method is STRICTLY async-only and must be awaited. + This method is designed for async frameworks such as FastAPI and + Starlette. It transparently supports sync or async setters. - Supported patterns: - - `await response.set_header(name, value)` for async frameworks - - `response.set_header(name, value)` for sync setters returning `None` - - `await response.headers.__setitem__(name, value)` for async mappings - - `response.headers[name] = value` for sync mappings + Supported patterns + ------------------ + * ``await response.set_header(name, value)`` for async setters. + * ``response.set_header(name, value)`` for sync setters returning ``None``. + * ``await response.headers.__setitem__(name, value)`` for async mappings. + * ``response.headers[name] = value`` for sync mappings. - If a setter returns an awaitable, it is awaited. - If it returns `None`, it is treated as a synchronous operation. + Parameters + ---------- + response : + Response object implementing either :class:`SetHeaderProtocol` or + :class:`HeadersProtocol`. Raises ------ AttributeError - If the response lacks both `.set_header` and `.headers`. + If the response lacks both ``.set_header`` and ``.headers``. HeaderSetError If setting an individual header fails. """ - items = self.header_items() - # --- Path 1: response.set_header(...) --- + # Path 1: response.set_header(...) if hasattr(response, "set_header"): set_header = response.set_header @@ -688,7 +843,7 @@ async def set_headers_async(self, response: ResponseProtocol) -> None: return - # --- Path 2: response.headers[...] mapping --- + # Path 2: response.headers[...] mapping if hasattr(response, "headers"): hdrs = response.headers @@ -702,5 +857,4 @@ async def set_headers_async(self, response: ResponseProtocol) -> None: return - # --- Unsupported response --- raise AttributeError("Response object does not support setting headers.") From 2a5fbb7325907c71578b0540f84bb547b9af9cfa Mon Sep 17 00:00:00 2001 From: cak Date: Wed, 10 Dec 2025 05:05:20 -0500 Subject: [PATCH 019/117] refactor: add noqa comments to suppress linting warnings in header validation methods --- secure/secure.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/secure/secure.py b/secure/secure.py index db1aecd..6f1238e 100644 --- a/secure/secure.py +++ b/secure/secure.py @@ -299,7 +299,7 @@ def __repr__(self) -> str: # Header normalization / safety helpers # ------------------------------------------------------------------ - def validate_and_normalize_headers( + def validate_and_normalize_headers( # noqa: PLR0915 self, *, on_invalid: OnInvalidPolicy = "drop", @@ -355,7 +355,7 @@ def _handle_invalid(msg: str) -> None: elif on_invalid == "raise" or strict: raise ValueError(msg) - def _validate_pair(name: str, value: str, strict_mode: bool = strict) -> tuple[str, str] | None: + def _validate_pair(name: str, value: str, strict_mode: bool = strict) -> tuple[str, str] | None: # noqa: PLR0912 name = name.strip() if not HEADER_NAME_RE.match(name): From a6f1b38e13d26652239c1c93d7209c53c781ccbf Mon Sep 17 00:00:00 2001 From: cak Date: Wed, 10 Dec 2025 05:23:29 -0500 Subject: [PATCH 020/117] refactor: enhance error handling and validation logic in Secure class methods --- secure/secure.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/secure/secure.py b/secure/secure.py index 6f1238e..d34c819 100644 --- a/secure/secure.py +++ b/secure/secure.py @@ -325,6 +325,7 @@ def validate_and_normalize_headers( # noqa: PLR0915 - ``"raise"``: raise :class:`ValueError` on invalid entries. strict : If true, treat CR/LF and disallowed characters as hard errors. + Other invalid cases (name/value) are governed by ``on_invalid``. allow_obs_text : If true, allow "obs-text" (bytes 0x80-0xFF) as per older RFCs. logger : @@ -339,8 +340,9 @@ def validate_and_normalize_headers( # noqa: PLR0915 Raises ------ ValueError - If a header name is invalid or if duplicates are found when building - the single-valued mapping. + If a header name is invalid (when ``on_invalid="raise"``), + 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__) @@ -352,10 +354,12 @@ def validate_and_normalize_headers( # noqa: PLR0915 def _handle_invalid(msg: str) -> None: if on_invalid == "warn": log.warning(msg) - elif on_invalid == "raise" or strict: + elif on_invalid == "raise": raise ValueError(msg) + # on_invalid == "drop": silently drop - def _validate_pair(name: str, value: str, strict_mode: bool = strict) -> tuple[str, str] | None: # noqa: PLR0912 + 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): @@ -369,7 +373,7 @@ def _validate_pair(name: str, value: str, strict_mode: bool = strict) -> tuple[s # CR/LF must never appear in values. if ("\r" in value) or ("\n" in value): - if strict_mode: + if strict_flag: raise ValueError(f"Header {name!r} contained CR/LF") value = " ".join(value.splitlines()) @@ -408,7 +412,7 @@ def _validate_pair(name: str, value: str, strict_mode: bool = strict) -> tuple[s ): append(ch) else: - if strict_mode: + if strict_flag: raise ValueError(f"Header {name!r} contains disallowed char U+{code:04X}") append(" ") @@ -849,7 +853,7 @@ async def set_headers_async(self, response: ResponseProtocol) -> None: try: for name, value in items: - result = hdrs.__setitem__(name, value) # type: ignore[misc] + result = hdrs.__setitem__(name, value) if inspect.isawaitable(result): await result except (TypeError, ValueError, AttributeError) as e: From acd92d0cf53cba2e0cff10fd6d5447b6c7f0ecc8 Mon Sep 17 00:00:00 2001 From: cak Date: Wed, 10 Dec 2025 06:14:28 -0500 Subject: [PATCH 021/117] refactor: streamline Secure class initialization with preset configurations --- secure/secure.py | 53 +++++++++++++++++++++++------------------------- 1 file changed, 25 insertions(+), 28 deletions(-) diff --git a/secure/secure.py b/secure/secure.py index d34c819..5e3bd2a 100644 --- a/secure/secure.py +++ b/secure/secure.py @@ -209,30 +209,16 @@ def with_default_headers(cls) -> Secure: """ Create a :class:`Secure` instance with a sensible default set of headers. - This preset is suitable for many modern applications and can be further + This configuration is suitable for many modern applications and can be customized with methods like :meth:`allowlist_headers` or by adding - additional header objects. + additional header builder objects. Returns ------- Secure Instance preconfigured with a default set of headers. """ - return cls( - cache=CacheControl().no_store(), - coop=CrossOriginOpenerPolicy().same_origin(), - csp=ContentSecurityPolicy() - .default_src("'self'") - .script_src("'self'") - .style_src("'self'") - .object_src("'none'"), - hsts=StrictTransportSecurity().max_age(31536000), - permissions=PermissionsPolicy().geolocation().microphone().camera(), - referrer=ReferrerPolicy().strict_origin_when_cross_origin(), - server=Server().set(""), - xcto=XContentTypeOptions().nosniff(), - xfo=XFrameOptions().sameorigin(), - ) + return cls.from_preset(Preset.BASIC) @classmethod def from_preset(cls, preset: Preset) -> Secure: @@ -242,8 +228,9 @@ def from_preset(cls, preset: Preset) -> Secure: Parameters ---------- preset : - The security preset to use, for example :data:`Preset.BASIC` - or :data:`Preset.STRICT`. + The security preset to use, for example :data:`Preset.BASIC` for a + balanced default profile or :data:`Preset.STRICT` for a hardened + configuration with stronger guarantees. Returns ------- @@ -258,8 +245,16 @@ def from_preset(cls, preset: Preset) -> Secure: match preset: case Preset.BASIC: return cls( - cache=CacheControl().no_store(), + coop=CrossOriginOpenerPolicy().same_origin(), + csp=( + ContentSecurityPolicy() + .default_src("'self'") + .script_src("'self'") + .style_src("'self'") + .object_src("'none'") + ), hsts=StrictTransportSecurity().max_age(31536000), + permissions=PermissionsPolicy().geolocation().microphone().camera(), referrer=ReferrerPolicy().strict_origin_when_cross_origin(), server=Server().set(""), xcto=XContentTypeOptions().nosniff(), @@ -270,14 +265,16 @@ def from_preset(cls, preset: Preset) -> Secure: cache=CacheControl().no_store(), 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().preload(), + 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().preload()), permissions=PermissionsPolicy().geolocation().microphone().camera(), referrer=ReferrerPolicy().no_referrer(), server=Server().set(""), From 0e1407d29764e981d1521133bc368b569440aab9 Mon Sep 17 00:00:00 2001 From: cak Date: Wed, 10 Dec 2025 06:21:11 -0500 Subject: [PATCH 022/117] docs: update README for clarity and consistency, enhancing descriptions and structure --- README.md | 350 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 247 insertions(+), 103 deletions(-) diff --git a/README.md b/README.md index 3a17b31..7f90f5c 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,49 @@ -# secure.py +# secure -_A simple, yet powerful way to secure your Python web applications across multiple frameworks._ +A small, focused library for adding modern security headers to Python web applications. [![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/) -[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) -[![Downloads](https://pepy.tech/badge/secure)](https://pepy.tech/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) -## **Introduction** +--- + +## Introduction + +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. + +`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: -In today's web landscape, security is paramount. **secure.py** is a lightweight Python library designed to effortlessly add **security headers** to your web applications, protecting them from common vulnerabilities. Whether you're using **Django**, **Flask**, **FastAPI**, or any other popular framework, `secure.py` provides a unified API to enhance your application's security posture. +```python +import secure +``` --- -## **Why Use secure.py?** +## Why use `secure` -- 🔒 **Apply Essential Security Headers**: Implement headers like CSP, HSTS, and more with minimal effort. -- 🛠️ **Consistent API Across Frameworks**: A unified approach for different web frameworks. -- ⚙️ **Customizable with Secure Defaults**: Start secure out-of-the-box and customize as needed. -- 🚀 **Easy Integration**: Compatible with Python's most-used frameworks. -- 🐍 **Modern Pythonic Design**: Leverages Python 3.10+ features for cleaner and more efficient code. +- 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** +## Supported frameworks -**secure.py** supports the following Python web 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 | | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------ | @@ -50,112 +66,133 @@ In today's web landscape, security is paramount. **secure.py** is a lightweight --- -## **Features** +## 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. -- 🔒 **Secure Headers**: Automatically apply headers like `Strict-Transport-Security`, `X-Frame-Options`, and more. -- 🛠️ **Customizable Policies**: Flexibly build your own security policies using method chaining. -- 🌐 **Framework Integration**: Compatible with various frameworks, ensuring cross-compatibility. -- 🚀 **No External Dependencies**: Lightweight and easy to include in any project. -- 🧩 **Easy to Use**: Integrate security headers in just a few lines of code. -- ⚡ **Asynchronous Support**: Async support for modern frameworks like **FastAPI** and **Starlette**. -- 📝 **Enhanced Type Hinting**: Complete type annotations for better developer experience. -- 📚 **Attribution to Trusted Sources**: Implements recommendations from MDN and OWASP. +- **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** +## Requirements -- **Python 3.10** or higher +- **Python 3.10 or higher** - This library leverages modern Python features introduced in Python 3.10 and 3.11, such as: + `secure` targets modern Python and is currently tested on Python 3.10 through 3.13. - - **Union Type Operator (`|`)**: Simplifies type annotations. - - **Structural Pattern Matching (`match` statement)**: Enhances control flow. - - **Improved Type Hinting and Annotations**: Provides better code clarity and maintenance. - - **`cached_property`**: Optimize memory usage and performance. + It uses features introduced in Python 3.10, including: - **Note:** If you're using an older version of Python (3.6 to 3.9), please use version **0.3.0** of this library, which maintains compatibility with those versions. + - 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. + This library has no external dependencies outside of the Python standard library. --- -## **Installation** - -You can install secure.py using pip, pipenv, or poetry: - -**pip**: +## Installation -```bash -pip install secure -``` +You can install `secure` with your preferred Python package manager. -**Pipenv**: +### Using `uv` ```bash -pipenv install secure +uv add secure ``` -**Poetry**: +### Using `pip` ```bash -poetry add secure +pip install secure ``` --- -## **Getting Started** - -Once installed, you can quickly integrate `secure.py` into your project: +## Quick start -### Synchronous Usage +The core entry point is the `Secure` class. A typical simple setup looks like this: ```python import secure -# Initialize secure headers with default settings secure_headers = secure.Secure.with_default_headers() -# Apply the headers to your framework response object +# For a synchronous framework secure_headers.set_headers(response) + +# For an asynchronous framework +await secure_headers.set_headers_async(response) ``` -### Asynchronous Usage +`Secure.with_default_headers()` is equivalent to `Secure.from_preset(Preset.BASIC)`. -For frameworks like **FastAPI** and **Starlette** that support asynchronous operations, use the async method: +`set_headers` and `set_headers_async` both operate on a response object that either: -```python -import secure +- Exposes a `set_header(name, value)` method, or +- Exposes a mutable `headers` mapping that supports item assignment. -# Initialize secure headers with default settings -secure_headers = secure.Secure.with_default_headers() +If your framework uses a different contract, see the framework specific guides or use `header_items()` to apply headers manually. -# Apply the headers asynchronously to your framework response object -await secure_headers.set_headers_async(response) +--- + +## Default secure headers + +When you call `Secure.with_default_headers()` (or `Secure.from_preset(Preset.BASIC)`), `secure` configures a balanced, modern set of headers suitable for many applications: + +```http +Cross-Origin-Opener-Policy: same-origin +Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; object-src 'none' +Strict-Transport-Security: max-age=31536000 +Permissions-Policy: geolocation=(), microphone=(), camera=() +Referrer-Policy: strict-origin-when-cross-origin +Server: +X-Content-Type-Options: nosniff +X-Frame-Options: SAMEORIGIN ``` -### **Example Usage** +These defaults help limit cross origin data leaks, mitigate clickjacking and MIME sniffing, and establish a conservative Content Security Policy you can extend later. + +--- + +## Presets + +If you prefer to think in terms of profiles instead of individual headers, `secure` provides presets via the `Preset` enum and `Secure.from_preset`. ```python import secure +from secure import Preset -# Create a Secure instance with default headers -secure_headers = secure.Secure.with_default_headers() +# A balanced starting point for most applications +secure_headers = secure.Secure.from_preset(Preset.BASIC) -# Apply default secure headers to a response object -secure_headers.set_headers(response) +# A stricter profile for security focused deployments +strict_headers = secure.Secure.from_preset(Preset.STRICT) ``` ---- - -## **Default Secure Headers** +### BASIC preset -By default, `secure.py` applies the following headers when using `with_default_headers()`: +The `BASIC` preset is a balanced default that matches `Secure.with_default_headers()`. It configures a modern baseline that works for many applications: ```http -Cache-Control: no-store Cross-Origin-Opener-Policy: same-origin Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; object-src 'none' Strict-Transport-Security: max-age=31536000 @@ -163,45 +200,66 @@ Permissions-Policy: geolocation=(), microphone=(), camera=() Referrer-Policy: strict-origin-when-cross-origin Server: X-Content-Type-Options: nosniff +X-Frame-Options: SAMEORIGIN +``` + +Use this when you want a strong starting point that you can refine over time. + +### 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 +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; preload +Permissions-Policy: geolocation=(), microphone=(), camera=() +Referrer-Policy: no-referrer +Server: +X-Content-Type-Options: nosniff +X-Frame-Options: DENY ``` +Start with `BASIC` and move to `STRICT` once you have validated that your application works correctly with the stricter Content Security Policy, caching, and frame restrictions. + --- -## **Policy Builders** +## Policy builders -`secure.py` allows you to customize headers such as **Content-Security-Policy** and **Permissions-Policy** with ease: +`secure` lets you build rich header values through small, focused builder classes. Two common examples are `ContentSecurityPolicy` and `PermissionsPolicy`. -### **Content-Security-Policy Example** +### Content Security Policy ```python import secure -# Build a custom CSP policy csp = ( secure.ContentSecurityPolicy() .default_src("'self'") - .script_src("'self'", "cdn.example.com") + .script_src("'self'", "cdn.typeerror.com") .style_src("'unsafe-inline'") - .img_src("'self'", "images.example.com") - .connect_src("'self'", "api.example.com") + .img_src("'self'", "images.typeerror.com") + .connect_src("'self'", "api.typeerror.com") ) -# Apply it to secure headers secure_headers = secure.Secure(csp=csp) ``` -**Resulting HTTP headers:** +Resulting header: ```http -Content-Security-Policy: default-src 'self'; script-src 'self' cdn.example.com; style-src 'unsafe-inline'; img-src 'self' images.example.com; connect-src 'self' api.example.com +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 ``` -### **Permissions-Policy Example** +You can treat the CSP builder as a safe string builder for CSP directives and keep all CSP logic in one place. + +### Permissions Policy ```python import secure -# Build a custom Permissions Policy permissions = ( secure.PermissionsPolicy() .geolocation("'self'") @@ -209,21 +267,75 @@ permissions = ( .microphone("'none'") ) -# Apply it to secure headers secure_headers = secure.Secure(permissions=permissions) ``` -**Resulting HTTP headers:** +Resulting header: ```http Permissions-Policy: geolocation=('self'), camera=('none'), microphone=('none') ``` +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 +import secure + +logger = logging.getLogger("secure") + +secure_headers = ( + secure.Secure.with_default_headers() + .allowlist_headers( + allowed=secure.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=secure.COMMA_JOIN_OK, + multi_ok=secure.MULTI_OK, + logger=logger, + ) + .validate_and_normalize_headers( + on_invalid="drop", # "drop" (default), "warn", or "raise" + strict=False, + allow_obs_text=False, + logger=logger, + ) +) +``` + +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. + +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. + --- -## **Framework Examples** +## Framework examples -### **FastAPI** +Below are simple examples for a synchronous and an asynchronous framework. See the framework specific guides for more detailed patterns. + +### FastAPI ```python from fastapi import FastAPI @@ -243,7 +355,7 @@ async def add_security_headers(request, call_next): @app.get("/") def read_root(): - return {"Hello": "World"} + return {"hello": "world"} ``` ### Flask @@ -274,51 +386,83 @@ if __name__ == "__main__": --- -## **Documentation** +## 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 more details, including advanced configurations and integration examples, please visit the **[full documentation](https://github.com/TypeError/secure/tree/main/docs)**. +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. + +Documentation: --- -## **Attribution** +## Attribution -This library implements security recommendations from trusted sources: +`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/)) -We have included attribution comments in the source code where appropriate. +Attribution comments are included in the source code where appropriate. --- -## **Resources** +## Resources -- [OWASP - Secure Headers Project](https://owasp.org/www-project-secure-headers/) +- [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: Security Headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers#security) -- [web.dev: Security Best Practices](https://web.dev) -- [The World Wide Web Consortium (W3C)](https://www.w3.org) +- [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** +## License -This project is licensed under the terms of the **[MIT License](https://opensource.org/licenses/MIT)**. +This project is licensed under the terms of the [MIT License](https://opensource.org/licenses/MIT). --- -## **Contributing** +## Contributing + +Issues and pull requests are welcome. If you would like to discuss an idea, open an issue on GitHub so we can talk about the design before implementation. -Contributions are welcome! If you'd like to contribute to `secure.py`, please feel free to open an issue or submit a pull request on **[GitHub](https://github.com/TypeError/secure)**. +Repository: --- -## **Changelog** +## Changelog -For a detailed list of changes, please refer to the **[CHANGELOG](https://github.com/TypeError/secure/blob/main/CHANGELOG.md)**. +See the [CHANGELOG](https://github.com/TypeError/secure/blob/main/CHANGELOG.md) for a detailed list of changes by release. --- -## **Acknowledgements** +## Acknowledgements -We would like to thank the contributors of MDN Web Docs and OWASP Secure Headers Project for their invaluable resources and guidelines that help make the web a safer place. +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. From 14d22c90d9bb297eb8c9336c9ebcd53496cf92d4 Mon Sep 17 00:00:00 2001 From: cak Date: Thu, 11 Dec 2025 05:16:40 -0500 Subject: [PATCH 023/117] feat: add new security headers and enhance Secure class with modern presets --- secure/headers/__init__.py | 6 + secure/headers/base_header.py | 22 +++- secure/headers/content_security_policy.py | 11 ++ .../headers/cross_origin_resource_policy.py | 93 ++++++++++++++++ secure/headers/x_dns_prefetch_control.py | 81 ++++++++++++++ .../x_permitted_cross_domain_policies.py | 103 ++++++++++++++++++ secure/secure.py | 68 ++++++++++++ 7 files changed, 381 insertions(+), 3 deletions(-) create mode 100644 secure/headers/cross_origin_resource_policy.py create mode 100644 secure/headers/x_dns_prefetch_control.py create mode 100644 secure/headers/x_permitted_cross_domain_policies.py diff --git a/secure/headers/__init__.py b/secure/headers/__init__.py index ae6effb..3ab20e2 100644 --- a/secure/headers/__init__.py +++ b/secure/headers/__init__.py @@ -3,13 +3,16 @@ from .content_security_policy import ContentSecurityPolicy from .cross_origin_embedder_policy import CrossOriginEmbedderPolicy from .cross_origin_opener_policy import CrossOriginOpenerPolicy +from .cross_origin_resource_policy import CrossOriginResourcePolicy from .custom_header import CustomHeader from .permissions_policy import PermissionsPolicy from .referrer_policy import ReferrerPolicy from .server import Server from .strict_transport_security import StrictTransportSecurity from .x_content_type_options import XContentTypeOptions +from .x_dns_prefetch_control import XDnsPrefetchControl from .x_frame_options import XFrameOptions +from .x_permitted_cross_domain_policies import XPermittedCrossDomainPolicies __all__ = [ "BaseHeader", @@ -17,11 +20,14 @@ "ContentSecurityPolicy", "CrossOriginEmbedderPolicy", "CrossOriginOpenerPolicy", + "CrossOriginResourcePolicy", "CustomHeader", "PermissionsPolicy", "ReferrerPolicy", "Server", "StrictTransportSecurity", "XContentTypeOptions", + "XDnsPrefetchControl", "XFrameOptions", + "XPermittedCrossDomainPolicies", ] diff --git a/secure/headers/base_header.py b/secure/headers/base_header.py index 80b6f87..bc53f25 100644 --- a/secure/headers/base_header.py +++ b/secure/headers/base_header.py @@ -30,6 +30,9 @@ class HeaderName(Enum): # Context isolation CROSS_ORIGIN_OPENER_POLICY = "Cross-Origin-Opener-Policy" + # Cross-origin resource sharing + CROSS_ORIGIN_RESOURCE_POLICY = "Cross-Origin-Resource-Policy" + # Permissions PERMISSION_POLICY = "Permissions-Policy" @@ -45,9 +48,15 @@ class HeaderName(Enum): # MIME type protection X_CONTENT_TYPE_OPTIONS = "X-Content-Type-Options" + # DNS prefetching control + X_DNS_PREFETCH_CONTROL = "X-DNS-Prefetch-Control" + # Clickjacking protection X_FRAME_OPTIONS = "X-Frame-Options" + # Cross-domain policies + X_PERMITTED_CROSS_DOMAIN_POLICIES = "X-Permitted-Cross-Domain-Policies" + class HeaderDefaultValue(Enum): """Enumeration of default values for standard HTTP security headers. @@ -61,9 +70,7 @@ class HeaderDefaultValue(Enum): CACHE_CONTROL = "no-store" # Basic Content Security Policy to allow resources only from the same origin - CONTENT_SECURITY_POLICY = ( - "default-src 'self'; script-src 'self'; style-src 'self'; object-src 'none'" - ) + CONTENT_SECURITY_POLICY = "default-src 'self'; script-src 'self'; style-src 'self'; object-src 'none'" # Cross-Origin Embedder Policy set to 'require-corp' to enforce stricter security. # This ensures that embedded cross-origin resources must explicitly allow being embedded. @@ -73,6 +80,9 @@ class HeaderDefaultValue(Enum): # Cross-Origin Opener Policy to isolate browsing contexts and prevent cross-origin leaks CROSS_ORIGIN_OPENER_POLICY = "same-origin" + # Cross-Origin Resource Policy to restrict resource loading to the same origin + CROSS_ORIGIN_RESOURCE_POLICY = "same-origin" + # Permissions Policy to disable risky features by default (geolocation, microphone, camera) PERMISSION_POLICY = "geolocation=(), microphone=(), camera=()" @@ -88,9 +98,15 @@ class HeaderDefaultValue(Enum): # Prevent MIME-type sniffing to block potential security threats from improperly typed content X_CONTENT_TYPE_OPTIONS = "nosniff" + # X-DNS-Prefetch-Control to disable DNS prefetching for privacy + X_DNS_PREFETCH_CONTROL = "off" + # Clickjacking protection, allows framing only from the same origin X_FRAME_OPTIONS = "SAMEORIGIN" + # X-Permitted-Cross-Domain-Policies to disallow all cross-domain policies + X_PERMITTED_CROSS_DOMAIN_POLICIES = "none" + @dataclass class BaseHeader: diff --git a/secure/headers/content_security_policy.py b/secure/headers/content_security_policy.py index 0990b4f..665c310 100644 --- a/secure/headers/content_security_policy.py +++ b/secure/headers/content_security_policy.py @@ -256,6 +256,17 @@ def script_src(self, *sources: str) -> ContentSecurityPolicy: """ return self.custom_directive("script-src", *sources) + def script_src_attr(self, *sources: str) -> ContentSecurityPolicy: + """Set valid origins for JavaScript sources. + + Resources: + https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/script-src-attr + + Returns: + The `ContentSecurityPolicy` instance for method chaining. + """ + return self.custom_directive("script-src-attr", *sources) + def style_src(self, *sources: str) -> ContentSecurityPolicy: """Set valid origins for CSS and styles. diff --git a/secure/headers/cross_origin_resource_policy.py b/secure/headers/cross_origin_resource_policy.py new file mode 100644 index 0000000..1a7fc3e --- /dev/null +++ b/secure/headers/cross_origin_resource_policy.py @@ -0,0 +1,93 @@ +# Security header recommendations and information from the MDN Web Docs and the OWASP Secure Headers Project +# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cross-Origin-Resource-Policy +# https://owasp.org/www-project-secure-headers/#cross-origin-resource-policy +# +# Cross-Origin-Resource-Policy by Mozilla Contributors is licensed under CC-BY-SA 2.5. +# https://developer.mozilla.org/en-US/docs/MDN/Community/Roles_teams#contributor +# https://creativecommons.org/licenses/by-sa/2.5/ + +from __future__ import annotations # type: ignore + +from dataclasses import dataclass, field + +from secure.headers.base_header import BaseHeader, HeaderDefaultValue, HeaderName + + +@dataclass +class CrossOriginResourcePolicy(BaseHeader): + """ + Represents the `Cross-Origin-Resource-Policy` HTTP header. + + This header controls which origins are allowed to load a given resource. It + helps protect resources from being loaded in unexpected cross-origin + contexts. + + Default header value: `same-origin` + + Common values: + - `same-origin` Only same-origin documents may load the resource. + - `same-site` Same-site documents may load the resource. + - `cross-origin` Any origin may load the resource. + + Example: + corp = CrossOriginResourcePolicy().same_origin() + print(corp.header_name) # Output: 'Cross-Origin-Resource-Policy' + print(corp.header_value) # Output: 'same-origin' + + Resources: + - https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cross-Origin-Resource-Policy + - https://owasp.org/www-project-secure-headers/#cross-origin-resource-policy + """ + + header_name: str = HeaderName.CROSS_ORIGIN_RESOURCE_POLICY.value + _value: str = field(default_factory=lambda: HeaderDefaultValue.CROSS_ORIGIN_RESOURCE_POLICY.value) + + @property + def header_value(self) -> str: + """Return the current header value.""" + return self._value + + def set(self, value: str) -> CrossOriginResourcePolicy: + """ + Set a custom value for the `Cross-Origin-Resource-Policy` header. + + Args: + value: + The header value to use. Typical values are `same-origin`, + `same-site`, or `cross-origin`. + + Returns: + The `CrossOriginResourcePolicy` instance for method chaining. + """ + self._value = value + return self + + def same_origin(self) -> CrossOriginResourcePolicy: + """ + Restrict resource loading to the same origin. + + Returns: + The `CrossOriginResourcePolicy` instance for method chaining. + """ + self._value = "same-origin" + return self + + def same_site(self) -> CrossOriginResourcePolicy: + """ + Allow resource loading from the same site. + + Returns: + The `CrossOriginResourcePolicy` instance for method chaining. + """ + self._value = "same-site" + return self + + def cross_origin(self) -> CrossOriginResourcePolicy: + """ + Allow resource loading from any origin. + + Returns: + The `CrossOriginResourcePolicy` instance for method chaining. + """ + self._value = "cross-origin" + return self diff --git a/secure/headers/x_dns_prefetch_control.py b/secure/headers/x_dns_prefetch_control.py new file mode 100644 index 0000000..7693f36 --- /dev/null +++ b/secure/headers/x_dns_prefetch_control.py @@ -0,0 +1,81 @@ +# Security header recommendations and information from the MDN Web Docs and the OWASP Secure Headers Project +# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-DNS-Prefetch-Control +# https://owasp.org/www-project-secure-headers/#x-dns-prefetch-control +# +# X-DNS-Prefetch-Control by Mozilla Contributors is licensed under CC-BY-SA 2.5. +# https://developer.mozilla.org/en-US/docs/MDN/Community/Roles_teams#contributor +# https://creativecommons.org/licenses/by-sa/2.5/ + +from __future__ import annotations # type: ignore + +from dataclasses import dataclass, field + +from secure.headers.base_header import BaseHeader, HeaderDefaultValue, HeaderName + + +@dataclass +class XDnsPrefetchControl(BaseHeader): + """ + Represents the `X-DNS-Prefetch-Control` HTTP header. + + This header controls whether the browser is allowed to perform DNS + prefetching for links on the page, which can improve performance at the + cost of some privacy. + + Default header value: `off` + + Common values: + - `off` Disable DNS prefetching. + - `on` Enable DNS prefetching. + + Example: + xdfc = XDnsPrefetchControl().disable() + print(xdfc.header_name) # 'X-DNS-Prefetch-Control' + print(xdfc.header_value) # 'off' + + Resources: + - https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-DNS-Prefetch-Control + - https://owasp.org/www-project-secure-headers/#x-dns-prefetch-control + """ + + header_name: str = HeaderName.X_DNS_PREFETCH_CONTROL.value + _value: str = field(default_factory=lambda: HeaderDefaultValue.X_DNS_PREFETCH_CONTROL.value) + + @property + def header_value(self) -> str: + """Return the current header value.""" + return self._value + + def set(self, value: str) -> XDnsPrefetchControl: + """ + Set a custom value for the `X-DNS-Prefetch-Control` header. + + Args: + value: + The header value to use. Typical values are `on` or `off`. + + Returns: + The `XDnsPrefetchControl` instance for method chaining. + """ + self._value = value + return self + + def allow(self) -> XDnsPrefetchControl: + """ + Enable DNS prefetching (`on`). + + Returns: + The `XDnsPrefetchControl` instance for method chaining. + """ + self._value = "on" + return self + + def disable(self) -> XDnsPrefetchControl: + """ + Disable DNS prefetching (`off`). + + Returns: + The `XDnsPrefetchControl` instance for method chaining. + """ + self._value = "off" + return self diff --git a/secure/headers/x_permitted_cross_domain_policies.py b/secure/headers/x_permitted_cross_domain_policies.py new file mode 100644 index 0000000..015a8dc --- /dev/null +++ b/secure/headers/x_permitted_cross_domain_policies.py @@ -0,0 +1,103 @@ +# Security header recommendations and information from the MDN Web Docs and the OWASP Secure Headers Project +# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Permitted-Cross-Domain-Policies +# https://owasp.org/www-project-secure-headers/#x-permitted-cross-domain-policies +# +# X-Permitted-Cross-Domain-Policies by Mozilla Contributors is licensed under CC-BY-SA 2.5. +# https://developer.mozilla.org/en-US/docs/MDN/Community/Roles_teams#contributor +# https://creativecommons.org/licenses/by-sa/2.5/ + +from __future__ import annotations # type: ignore + +from dataclasses import dataclass, field + +from secure.headers.base_header import BaseHeader, HeaderDefaultValue, HeaderName + + +@dataclass +class XPermittedCrossDomainPolicies(BaseHeader): + """ + Represents the `X-Permitted-Cross-Domain-Policies` HTTP header. + + This header controls which cross-domain policy files (for example for Adobe + products) are allowed to control access to your content. + + Default header value: `none` + + Valid values: + - `none` No cross-domain policies are allowed. + - `master-only` Only a master policy file is allowed. + - `by-content-type` Only policy files served with an appropriate + content type are allowed. + - `all` All policy files on this domain are allowed. + Example: + xpcdp = XPermittedCrossDomainPolicies().none() + print(xpcdp.header_name) # 'X-Permitted-Cross-Domain-Policies' + print(xpcdp.header_value) # 'none' + + Resources: + - https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Permitted-Cross-Domain-Policies + - https://owasp.org/www-project-secure-headers/#x-permitted-cross-domain-policies + """ + + header_name: str = HeaderName.X_PERMITTED_CROSS_DOMAIN_POLICIES.value + _value: str = field(default_factory=lambda: HeaderDefaultValue.X_PERMITTED_CROSS_DOMAIN_POLICIES.value) + + @property + def header_value(self) -> str: + """Return the current header value.""" + return self._value + + def set(self, value: str) -> XPermittedCrossDomainPolicies: + """ + Set a custom value for the `X-Permitted-Cross-Domain-Policies` header. + + Args: + value: + The header value to use. It should be one of `none`, + `master-only`, `by-content-type`, or `all`. + + Returns: + The `XPermittedCrossDomainPolicies` instance for method chaining. + """ + self._value = value + return self + + def none(self) -> XPermittedCrossDomainPolicies: + """ + Disallow all cross-domain policy files. + + Returns: + The `XPermittedCrossDomainPolicies` instance for method chaining. + """ + self._value = "none" + return self + + def master_only(self) -> XPermittedCrossDomainPolicies: + """ + Allow only a single master cross-domain policy file. + + Returns: + The `XPermittedCrossDomainPolicies` instance for method chaining. + """ + self._value = "master-only" + return self + + def by_content_type(self) -> XPermittedCrossDomainPolicies: + """ + Allow policy files that are served with an appropriate content type. + + Returns: + The `XPermittedCrossDomainPolicies` instance for method chaining. + """ + self._value = "by-content-type" + return self + + def all(self) -> XPermittedCrossDomainPolicies: + """ + Allow all cross-domain policy files on this domain. + + Returns: + The `XPermittedCrossDomainPolicies` instance for method chaining. + """ + self._value = "all" + return self diff --git a/secure/secure.py b/secure/secure.py index 5e3bd2a..bad1ab7 100644 --- a/secure/secure.py +++ b/secure/secure.py @@ -18,13 +18,16 @@ ContentSecurityPolicy, CrossOriginEmbedderPolicy, CrossOriginOpenerPolicy, + CrossOriginResourcePolicy, CustomHeader, PermissionsPolicy, ReferrerPolicy, Server, StrictTransportSecurity, XContentTypeOptions, + XDnsPrefetchControl, XFrameOptions, + XPermittedCrossDomainPolicies, ) # --------------------------------------------------------------------------- @@ -50,11 +53,16 @@ "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", } ) @@ -101,6 +109,7 @@ class Preset(Enum): """Predefined security header presets for :class:`Secure`.""" BASIC = "basic" + MODERN = "modern" STRICT = "strict" @@ -143,11 +152,14 @@ def __init__( # noqa: PLR0913 cache: CacheControl | None = None, coep: CrossOriginEmbedderPolicy | None = None, coop: CrossOriginOpenerPolicy | None = None, + corp: CrossOriginResourcePolicy | None = None, csp: ContentSecurityPolicy | None = None, custom: list[CustomHeader] | None = None, hsts: StrictTransportSecurity | None = None, permissions: PermissionsPolicy | None = None, referrer: ReferrerPolicy | None = None, + xpcdp: XPermittedCrossDomainPolicies | None = None, + xdfc: XDnsPrefetchControl | None = None, server: Server | None = None, xcto: XContentTypeOptions | None = None, xfo: XFrameOptions | None = None, @@ -163,6 +175,8 @@ def __init__( # noqa: PLR0913 Cross-Origin-Embedder-Policy header configuration. coop : Cross-Origin-Opener-Policy header configuration. + corp : + Cross-Origin-Resource-Policy header configuration. csp : Content-Security-Policy header configuration. custom : @@ -173,6 +187,10 @@ def __init__( # noqa: PLR0913 Permissions-Policy header configuration. referrer : Referrer-Policy header configuration. + xpcdp : + X-Permitted-Cross-Domain-Policies header configuration. + xdfc : + X-DNS-Prefetch-Control header configuration. server : Server header configuration. xcto : @@ -186,10 +204,13 @@ def __init__( # noqa: PLR0913 cache, coep, coop, + corp, csp, hsts, permissions, referrer, + xpcdp, + xdfc, server, xcto, xfo, @@ -244,6 +265,51 @@ def from_preset(cls, preset: Preset) -> Secure: """ match preset: case Preset.BASIC: + csp = ( + 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() + ) + + return cls( + coop=CrossOriginOpenerPolicy().same_origin(), + coep=None, + csp=csp, + corp=CrossOriginResourcePolicy().same_origin(), + hsts=StrictTransportSecurity().max_age(31536000).include_subdomains(), + permissions=None, + referrer=ReferrerPolicy().no_referrer(), + server=Server().set(""), + 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.MODERN: return cls( coop=CrossOriginOpenerPolicy().same_origin(), csp=( @@ -260,6 +326,7 @@ def from_preset(cls, preset: Preset) -> Secure: xcto=XContentTypeOptions().nosniff(), xfo=XFrameOptions().sameorigin(), ) + case Preset.STRICT: return cls( cache=CacheControl().no_store(), @@ -281,6 +348,7 @@ def from_preset(cls, preset: Preset) -> Secure: xcto=XContentTypeOptions().nosniff(), xfo=XFrameOptions().deny(), ) + case _: raise ValueError(f"Unknown preset: {preset}") From 6ed8538a7c115cf5035068d2896718d7a35d040c Mon Sep 17 00:00:00 2001 From: cak Date: Thu, 11 Dec 2025 05:34:05 -0500 Subject: [PATCH 024/117] feat: enhance Cache-Control header with max-age directive and add request directive helpers --- secure/headers/base_header.py | 2 +- secure/headers/cache_control.py | 48 +++++++++++++++++++++++++++++++-- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/secure/headers/base_header.py b/secure/headers/base_header.py index bc53f25..1307610 100644 --- a/secure/headers/base_header.py +++ b/secure/headers/base_header.py @@ -67,7 +67,7 @@ class HeaderDefaultValue(Enum): """ # Cache-Control to prevent caching of sensitive data - CACHE_CONTROL = "no-store" + CACHE_CONTROL = "no-store, max-age=0" # Basic Content Security Policy to allow resources only from the same origin CONTENT_SECURITY_POLICY = "default-src 'self'; script-src 'self'; style-src 'self'; object-src 'none'" diff --git a/secure/headers/cache_control.py b/secure/headers/cache_control.py index 04c35dd..b99f8c1 100644 --- a/secure/headers/cache_control.py +++ b/secure/headers/cache_control.py @@ -20,7 +20,11 @@ class CacheControl(BaseHeader): If no directives are added, it returns the default value. - Default header value: `no-store` + This class also provides helpers for request directives such as + `max-stale`, `min-fresh`, and `only-if-cached`, for cases where + you want to construct `Cache-Control` on outgoing requests. + + Default header value: `no-store, max-age=0` Example: cache_control = CacheControl().no_cache().no_store().max_age(0) @@ -100,6 +104,34 @@ def max_age(self, seconds: int) -> CacheControl: self._build(f"max-age={seconds}") return self + def max_stale(self, seconds: int | None = None) -> CacheControl: + """Add the 'max-stale' request directive, optionally with a limit. + + If `seconds` is omitted, any stale age is acceptable (per MDN). + + Resources: + https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#max-stale + """ + if seconds is None: + directive = "max-stale" + else: + if seconds < 0: + raise ValueError("seconds must be a non-negative integer") + directive = f"max-stale={seconds}" + self._build(directive) + return self + + def min_fresh(self, seconds: int) -> CacheControl: + """Add the 'min-fresh' request directive. + + Resources: + https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#min-fresh + """ + if seconds < 0: + raise ValueError("seconds must be a non-negative integer") + self._build(f"min-fresh={seconds}") + return self + def must_revalidate(self) -> CacheControl: """Add the 'must-revalidate' directive. @@ -115,6 +147,9 @@ def must_revalidate(self) -> CacheControl: def must_understand(self) -> CacheControl: """Add the 'must-understand' directive. + Typically paired with 'no-store' to ensure caches only store responses whose + caching requirements they understand. + Resources: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#must-understand @@ -137,7 +172,7 @@ def no_cache(self) -> CacheControl: return self def no_store(self) -> CacheControl: - """Add the 'no-store' directive, preventing caching entirely. + """Add the 'no-store' directive, preventing the response from being stored by any cache. Resources: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#no-store @@ -160,6 +195,15 @@ def no_transform(self) -> CacheControl: self._build("no-transform") return self + def only_if_cached(self) -> CacheControl: + """Add the 'only-if-cached' request directive. + + Resources: + https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#only-if-cached + """ + self._build("only-if-cached") + return self + def private(self) -> CacheControl: """Add the 'private' directive, allowing caching only by the end user's browser. From b8f7f1739ebfabbff9db3d9710e027e3a522ac3b Mon Sep 17 00:00:00 2001 From: cak Date: Thu, 11 Dec 2025 05:52:28 -0500 Subject: [PATCH 025/117] feat: enhance Content Security Policy with additional directives for improved security --- secure/headers/base_header.py | 10 +- secure/headers/content_security_policy.py | 174 ++++++++++++++++------ 2 files changed, 140 insertions(+), 44 deletions(-) diff --git a/secure/headers/base_header.py b/secure/headers/base_header.py index 1307610..01474f2 100644 --- a/secure/headers/base_header.py +++ b/secure/headers/base_header.py @@ -70,7 +70,15 @@ class HeaderDefaultValue(Enum): CACHE_CONTROL = "no-store, max-age=0" # Basic Content Security Policy to allow resources only from the same origin - CONTENT_SECURITY_POLICY = "default-src 'self'; script-src 'self'; style-src 'self'; object-src 'none'" + CONTENT_SECURITY_POLICY = ( + "default-src 'self'; " + "script-src 'self'; " + "style-src 'self'; " + "object-src 'none'; " + "base-uri 'self'; " + "frame-ancestors 'self'; " + "form-action 'self'" + ) # Cross-Origin Embedder Policy set to 'require-corp' to enforce stricter security. # This ensures that embedded cross-origin resources must explicitly allow being embedded. diff --git a/secure/headers/content_security_policy.py b/secure/headers/content_security_policy.py index 665c310..79a18bb 100644 --- a/secure/headers/content_security_policy.py +++ b/secure/headers/content_security_policy.py @@ -1,5 +1,5 @@ # Security header recommendations and information from the MDN Web Docs and the OWASP Secure Headers Project -# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy +# https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy # https://owasp.org/www-project-secure-headers/#content-security-policy # # Content-Security-Policy by Mozilla Contributors is licensed under CC-BY-SA 2.5. @@ -19,10 +19,11 @@ class ContentSecurityPolicy(BaseHeader): Represents the `Content-Security-Policy` HTTP header, which helps prevent cross-site injections by specifying allowed sources for content. - Default header value: `default-src 'self'; script-src 'self'; style-src 'self'; object-src 'none'` + Default header value: `default-src 'self'; script-src 'self'; style-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'` Resources: - - https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/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/Guides/CSP - https://developers.google.com/web/fundamentals/security/csp - https://owasp.org/www-project-secure-headers/#content-security-policy """ @@ -70,7 +71,7 @@ def clear(self) -> ContentSecurityPolicy: return self def report_only(self) -> ContentSecurityPolicy: - """Set `Content-Security-Policy` header to `Content-Security-Policy-Report-Only`. + """Set header name to `Content-Security-Policy-Report-Only` for report-only mode. Returns: The `ContentSecurityPolicy` instance for method chaining. @@ -91,11 +92,15 @@ def custom_directive(self, directive: str, *sources: str) -> ContentSecurityPoli self._build(directive, *sources) return self + # ------------------------------------------------------------------------- + # Directive helpers (alphabetical by directive name) + # ------------------------------------------------------------------------- + def base_uri(self, *sources: str) -> ContentSecurityPolicy: - """Set valid origins for `` element. + """Set valid sources for the document `` element. Resources: - https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/base-uri + https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/base-uri Returns: The `ContentSecurityPolicy` instance for method chaining. @@ -103,10 +108,14 @@ def base_uri(self, *sources: str) -> ContentSecurityPolicy: return self.custom_directive("base-uri", *sources) def child_src(self, *sources: str) -> ContentSecurityPolicy: - """Set valid origins for web workers. + """Set valid sources for web workers and nested browsing contexts. + + Note: + In CSP Level 3, `frame-src` and `worker-src` are preferred. `child-src` + acts mainly as a fallback for those directives. Resources: - https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/child-src + https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/child-src Returns: The `ContentSecurityPolicy` instance for method chaining. @@ -114,10 +123,10 @@ def child_src(self, *sources: str) -> ContentSecurityPolicy: return self.custom_directive("child-src", *sources) def connect_src(self, *sources: str) -> ContentSecurityPolicy: - """Set valid origins for script interfaces (e.g., XMLHttpRequest, WebSocket). + """Set valid sources for script interfaces (for example, XHR, Fetch, WebSocket). Resources: - https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/connect-src + https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/connect-src Returns: The `ContentSecurityPolicy` instance for method chaining. @@ -125,21 +134,35 @@ def connect_src(self, *sources: str) -> ContentSecurityPolicy: return self.custom_directive("connect-src", *sources) def default_src(self, *sources: str) -> ContentSecurityPolicy: - """Set fallback valid origins for other directives. + """Set fallback sources for other fetch directives. Resources: - https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/default-src + https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/default-src Returns: The `ContentSecurityPolicy` instance for method chaining. """ return self.custom_directive("default-src", *sources) + def fenced_frame_src(self, *sources: str) -> ContentSecurityPolicy: + """Set valid sources for `` nested browsing contexts. + + Note: + This directive is currently experimental and not supported in all browsers. + + Resources: + https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/fenced-frame-src + + Returns: + The `ContentSecurityPolicy` instance for method chaining. + """ + return self.custom_directive("fenced-frame-src", *sources) + def font_src(self, *sources: str) -> ContentSecurityPolicy: - """Set valid origins for `@font-face`. + """Set valid sources for font resources (for `@font-face`, etc.). Resources: - https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/font-src + https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/font-src Returns: The `ContentSecurityPolicy` instance for method chaining. @@ -147,10 +170,10 @@ def font_src(self, *sources: str) -> ContentSecurityPolicy: return self.custom_directive("font-src", *sources) def form_action(self, *sources: str) -> ContentSecurityPolicy: - """Set valid origins for form submissions. + """Set valid action URLs for form submissions. Resources: - https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/form-action + https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/form-action Returns: The `ContentSecurityPolicy` instance for method chaining. @@ -158,10 +181,10 @@ def form_action(self, *sources: str) -> ContentSecurityPolicy: return self.custom_directive("form-action", *sources) def frame_ancestors(self, *sources: str) -> ContentSecurityPolicy: - """Set valid origins that can embed the resource (e.g., iframes). + """Set valid sources that can embed this resource (for example, in `