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