diff --git a/CHANGELOG.md b/CHANGELOG.md index 9815b1f..56920e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,6 +86,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). disable the recurring loop. ### Security +- Reject non-exact integer subclasses in shared policy integer fields before + retaining trusted configuration state. Exact built-in integers and existing + ASCII decimal strings remain supported, preserving defaults and ranges while + preventing subclass-controlled values from crossing immutable policy construction. - Erase private request-method normalization exception provenance from caller-visible policy denials. - Pin the credential-free verifier to a reviewed Python 3.13 diff --git a/docs/research/policy-configuration-integrity.md b/docs/research/policy-configuration-integrity.md new file mode 100644 index 0000000..ea35b13 --- /dev/null +++ b/docs/research/policy-configuration-integrity.md @@ -0,0 +1,76 @@ +# Trusted policy configuration value integrity + +## Decision + +EgressWeave treats policy construction as a trusted startup boundary and stores a +canonical immutable policy value after normalization. Integer-form policy inputs +that become durable authority or resource-limit state therefore accept only an +exact built-in `int`. Non-exact integer subclasses are rejected instead of being +retained inside `EgressPolicy`. + +The reviewed environment-configuration contract remains unchanged: ASCII decimal +strings are accepted where the corresponding public field already supports them +and are converted to exact built-in integers before range checks, relational +checks, policy fingerprinting, DNS validation, request validation, or transport +delegation. Exact built-in integers continue to be accepted directly. + +This restriction applies to the shared integer normalization paths for allowed +ports, maximum resolved-address count, positive header-field counts, and positive +request/response byte budgets. It does not change configured defaults, allowed +ranges, authority pairing, DNS policy, TLS identity, proxy isolation, HTTP method +policy, request/response framing, or the generic request-time denial boundary. + +## Why exact type matters at this boundary + +Python deliberately supports subclassing immutable built-in types such as `int`, +and `isinstance(value, int)` is true for instances of subclasses. Python's data +model also permits subclasses of immutable built-ins to customize instance +creation. A broad `isinstance` check is therefore a polymorphism contract, not +proof that the stored object is the canonical built-in integer value expected by +a closed immutable policy representation. + +EgressWeave does not need that polymorphism for policy scalar fields. Supported +customization is expressed through documented values, not user-defined numeric +classes. Requiring `type(value) is int` on integer-form inputs prevents a subclass +object from surviving normalization and later participating in policy hashing or +equality, authority tuples, arithmetic or comparison boundaries, or provider +delegation. Environment text still reaches the same canonical state through +explicit decimal conversion. + +This supported-value sealing does not make EgressWeave a Python sandbox. Code +that is already executing inside the embedding process retains ordinary Python +capabilities. The boundary exists to make the documented policy value object +canonical, predictable, reviewable, and stable across standalone and modular +integrations. + +## Enforcement invariants + +1. Integer-form allowed ports must be exact built-in integers; reviewed ASCII + decimal strings are converted to built-in integers. +2. Integer-form DNS candidate limits must be exact built-in integers; reviewed + ASCII decimal strings are converted before positivity checks. +3. Shared positive field-count and byte-budget normalizers reject integer + subclasses and preserve their existing positive-value constraints. +4. Booleans remain invalid integer configuration even though Python defines + `bool` as an `int` subclass. +5. Existing decimal-string syntax, defaults, public builder signatures, and + request-time generic denial behavior remain unchanged. +6. Invalid trusted startup configuration continues to raise actionable + field-specific `TypeError` or `ValueError` rather than becoming an opaque + request-time policy denial. +7. Regression tests exercise the public `EgressPolicy` constructors so the + contract is proven at the API boundary rather than only against internal + helpers. + +## Operator migration + +Applications that supply plain integers or ASCII decimal environment values need +no change. Applications that pass custom subclasses of `int` for ports or finite +resource budgets should materialize an exact built-in integer before policy +construction. This is a pre-1.0 tightening of an ambiguous configuration shape; +it does not widen egress authority or change any finite default. + +## Reference — APA 7th + +Python Software Foundation. (2026). *Data model — Python 3.14.6 documentation*. +https://docs.python.org/3.14/reference/datamodel.html diff --git a/src/egressweave/_policy_normalization.py b/src/egressweave/_policy_normalization.py index 8292fa3..0a190a5 100644 --- a/src/egressweave/_policy_normalization.py +++ b/src/egressweave/_policy_normalization.py @@ -125,7 +125,7 @@ def _normalize_allowed_port(value: object) -> int | None: raise ValueError("allowed_ports entries must be decimal port numbers") port = int(normalized) else: - if isinstance(value, bool) or not isinstance(value, int): + if type(value) is not int: raise TypeError("allowed_ports entries must be integer port numbers") port = value @@ -188,7 +188,7 @@ def _normalize_max_resolved_addresses(value: object) -> int: ) address_count = int(normalized) else: - if isinstance(value, bool) or not isinstance(value, int): + if type(value) is not int: raise TypeError("max_resolved_addresses must be an integer count") address_count = value @@ -205,7 +205,7 @@ def _normalize_positive_count(value: object, field_name: str) -> int: raise ValueError(f"{field_name} must be a positive decimal count") item_count = int(normalized) else: - if isinstance(value, bool) or not isinstance(value, int): + if type(value) is not int: raise TypeError(f"{field_name} must be an integer count") item_count = value @@ -224,7 +224,7 @@ def _normalize_positive_byte_count(value: object, field_name: str) -> int: ) byte_count = int(normalized) else: - if isinstance(value, bool) or not isinstance(value, int): + if type(value) is not int: raise TypeError(f"{field_name} must be an integer byte count") byte_count = value diff --git a/tests/test_policy_integer_value_types.py b/tests/test_policy_integer_value_types.py new file mode 100644 index 0000000..f1a5112 --- /dev/null +++ b/tests/test_policy_integer_value_types.py @@ -0,0 +1,125 @@ +"""Security contracts for exact built-in integer policy values.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from egressweave import EgressPolicy + + +class _PolicyIntegerSubclass(int): + """Represent an unreviewed integer subclass crossing trusted configuration.""" + + +def test_policy_rejects_integer_subclass_for_allowed_port() -> None: + """Reject non-exact ports before retaining normalized authority state.""" + with pytest.raises(TypeError, match="allowed_ports"): + EgressPolicy.from_hosts( + "api.example.com", + allowed_ports=[_PolicyIntegerSubclass(443)], + ) + + +def test_policy_rejects_integer_subclass_for_exact_authority_port() -> None: + """Reject a non-exact port in the exact-authority constructor as well.""" + with pytest.raises(TypeError, match="allowed_ports"): + EgressPolicy.from_authorities( + [("api.example.com", _PolicyIntegerSubclass(443))], + ) + + +@pytest.mark.parametrize( + "field_name", + [ + "max_resolved_addresses", + "max_request_header_fields", + "max_response_header_fields", + "max_request_bytes", + "max_response_bytes", + "max_response_header_bytes", + "max_request_header_bytes", + "max_request_target_bytes", + ], +) +def test_policy_rejects_integer_subclass_for_resource_limits(field_name: str) -> None: + """Reject non-exact integers before retaining finite resource limits.""" + with pytest.raises(TypeError, match=field_name): + EgressPolicy.from_hosts( + "api.example.com", + **{field_name: _PolicyIntegerSubclass(8)}, # type: ignore[arg-type] + ) + + +def test_policy_keeps_exact_integer_and_decimal_string_configuration() -> None: + """Preserve reviewed exact integers and decimal environment values.""" + exact_integer = EgressPolicy.from_hosts( + "api.example.com", + allowed_ports=[8443], + max_resolved_addresses=8, + max_request_header_fields=32, + max_response_header_fields=32, + max_request_bytes=4096, + max_response_bytes=4096, + max_response_header_bytes=4096, + max_request_header_bytes=4096, + max_request_target_bytes=8192, + ) + decimal_string = EgressPolicy.from_hosts( + "api.example.com", + allowed_ports=["8443"], + max_resolved_addresses="8", + max_request_header_fields="32", + max_response_header_fields="32", + max_request_bytes="4096", + max_response_bytes="4096", + max_response_header_bytes="4096", + max_request_header_bytes="4096", + max_request_target_bytes="8192", + ) + + assert decimal_string == exact_integer + assert all(type(port) is int for port in decimal_string.allowed_ports) + assert type(decimal_string.max_resolved_addresses) is int + assert type(decimal_string.max_request_header_fields) is int + assert type(decimal_string.max_response_header_fields) is int + assert type(decimal_string.max_request_bytes) is int + assert type(decimal_string.max_response_bytes) is int + assert type(decimal_string.max_response_header_bytes) is int + assert type(decimal_string.max_request_header_bytes) is int + assert type(decimal_string.max_request_target_bytes) is int + + +def test_exact_authority_keeps_integer_and_decimal_string_port_equivalent() -> None: + """Preserve exact-authority ergonomics while storing canonical integer ports.""" + exact_integer = EgressPolicy.from_authorities([("api.example.com", 8443)]) + decimal_string = EgressPolicy.from_authorities([("api.example.com", "8443")]) + + assert decimal_string == exact_integer + assert decimal_string.allowed_authorities == frozenset( + {("api.example.com", 8443)} + ) + assert all(type(port) is int for port in decimal_string.allowed_ports) + assert all( + type(port) is int for _, port in decimal_string.allowed_authorities + ) + + +def test_policy_configuration_integrity_guide_is_discoverable_and_current() -> None: + """Document the supported primitive-value boundary without sandbox claims.""" + guide_path = Path("docs/research/policy-configuration-integrity.md") + + assert guide_path.is_file() + guide = guide_path.read_text(encoding="utf-8") + assert "exact built-in `int`" in guide + assert "ASCII decimal strings" in guide + assert "does not make EgressWeave a Python sandbox" in guide + assert "https://docs.python.org/3.14/reference/datamodel.html" in guide + + +def test_changelog_records_shared_policy_integer_value_sealing() -> None: + """Record the trusted scalar policy tightening in release history.""" + changelog = Path("CHANGELOG.md").read_text(encoding="utf-8") + + assert "Reject non-exact integer subclasses in shared policy integer fields" in changelog