diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dfaecb..4b60593 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -100,6 +100,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 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. +- Reject non-exact string subclasses in HTTP method policy values before + normalization, comma-separated parsing, or runtime authorization. - 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 index ea35b13..9c0c4df 100644 --- a/docs/research/policy-configuration-integrity.md +++ b/docs/research/policy-configuration-integrity.md @@ -20,6 +20,21 @@ 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. +HTTP method policy values are sealed at the same trusted startup boundary. Each +method value must be an exact built-in `str` before trimming, uppercase +canonicalization, or RFC 9110 token validation can invoke string behavior. +Supported comma-separated operator syntax remains available only when the outer +configuration value itself is an exact built-in `str`; non-exact string +subclasses are rejected before `split()` can run. Runtime method authorization +uses the same exact-string boundary and returns the existing generic denial for +unsupported caller values. + +This method-value restriction preserves the documented default and deny-all sets, +ordinary exact strings, comma-separated ergonomics, uppercase canonicalization, +RFC 9110 token validation, and unconditional `CONNECT` denial. It does not make +EgressWeave a Python sandbox: arbitrary trusted Python already executing in the +embedding process retains ordinary Python capabilities. + ## Why exact type matters at this boundary Python deliberately supports subclassing immutable built-in types such as `int`, @@ -61,6 +76,11 @@ integrations. 7. Regression tests exercise the public `EgressPolicy` constructors so the contract is proven at the API boundary rather than only against internal helpers. +8. HTTP method entries and supported comma-separated method configuration must + be exact built-in strings before any subclass-controllable normalization or + splitting operation. +9. Runtime HTTP method authorization rejects non-exact string subclasses without + invoking their normalization methods. ## Operator migration @@ -70,6 +90,12 @@ 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. +Applications that supply ordinary method strings or the existing comma-separated +method syntax also need no change. Integrations that pass subclasses of `str` for +method configuration should materialize exact built-in strings before policy +construction. This is likewise a supported-value tightening, not an expansion of +HTTP authority. + ## Reference — APA 7th Python Software Foundation. (2026). *Data model — Python 3.14.6 documentation*. diff --git a/src/egressweave/_policy_normalization.py b/src/egressweave/_policy_normalization.py index 0a190a5..52ecdb8 100644 --- a/src/egressweave/_policy_normalization.py +++ b/src/egressweave/_policy_normalization.py @@ -164,7 +164,7 @@ def _normalize_allowed_method(value: object) -> str: is never accepted: its semantics create an application-layer tunnel whose destination is independent of the validated URL authority. """ - if not isinstance(value, str): + if type(value) is not str: raise TypeError("allowed_methods entries must be HTTP method strings") normalized = value.strip().upper() diff --git a/src/egressweave/policy.py b/src/egressweave/policy.py index 1c69a1f..80afe95 100644 --- a/src/egressweave/policy.py +++ b/src/egressweave/policy.py @@ -69,6 +69,13 @@ ) +def _split_exact_method_string(value: object) -> list[str]: + """Split a comma-separated method list only after exact-string validation.""" + if type(value) is not str: + raise TypeError("allowed_methods must use exact built-in strings") + return value.split(",") + + @dataclass(frozen=True) class EgressPolicy: """Immutable outbound-egress allowlist and resource policy. @@ -244,7 +251,7 @@ def __post_init__(self) -> None: method_values: Iterable[object] if isinstance(self.allowed_methods, str): - method_values = self.allowed_methods.split(",") + method_values = _split_exact_method_string(self.allowed_methods) else: method_values = self.allowed_methods normalized_methods = frozenset( @@ -371,7 +378,7 @@ def from_hosts( method_items: Iterable[str] if isinstance(allowed_methods, str): - method_items = allowed_methods.split(",") + method_items = _split_exact_method_string(allowed_methods) else: method_items = allowed_methods @@ -434,7 +441,7 @@ def from_authorities( ) method_items: Iterable[str] if isinstance(allowed_methods, str): - method_items = allowed_methods.split(",") + method_items = _split_exact_method_string(allowed_methods) else: method_items = allowed_methods @@ -504,4 +511,4 @@ def allows_http_method(self, method: str) -> bool: normalized = _normalize_allowed_method(method) except (TypeError, ValueError): return False - return normalized in self.allowed_methods + return normalized in self.allowed_methods \ No newline at end of file diff --git a/tests/test_policy_method_value_integrity.py b/tests/test_policy_method_value_integrity.py new file mode 100644 index 0000000..c80c8ee --- /dev/null +++ b/tests/test_policy_method_value_integrity.py @@ -0,0 +1,94 @@ +"""Regression tests for exact built-in HTTP method policy values.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from egressweave.policy import EgressPolicy + + +class _NonExactMethod(str): + """Keep subclass identity if trusted normalization invokes polymorphic methods.""" + + def strip(self, chars: str | None = None) -> _NonExactMethod: + """Return this subclass instead of a canonical built-in string.""" + return self + + def upper(self) -> _NonExactMethod: + """Return this subclass instead of a canonical built-in string.""" + return self + + +class _ExplodingMethodList(str): + """Expose polymorphic dispatch in comma-separated method parsing.""" + + def split(self, sep: str | None = None, maxsplit: int = -1) -> list[str]: + """Fail if trusted construction invokes subclass-controlled splitting.""" + raise AssertionError("string subclass split executed") + + +def test_method_policy_rejects_str_subclass_before_normalization() -> None: + """Reject subclass-controlled method normalization during policy construction.""" + with pytest.raises(TypeError, match="allowed_methods"): + EgressPolicy.from_hosts( + "api.example.com", + allowed_methods={_NonExactMethod("GET")}, + ) + + +def test_direct_policy_rejects_str_subclass_before_comma_split() -> None: + """Reject a direct comma-string subclass before invoking its split method.""" + with pytest.raises(TypeError, match="allowed_methods"): + EgressPolicy( + allowed_hosts=frozenset({"api.example.com"}), + allowed_methods=_ExplodingMethodList("GET,POST"), + ) + + +def test_from_hosts_rejects_str_subclass_before_comma_split() -> None: + """Reject a host-factory comma-string subclass before invoking split.""" + with pytest.raises(TypeError, match="allowed_methods"): + EgressPolicy.from_hosts( + "api.example.com", + allowed_methods=_ExplodingMethodList("GET,POST"), + ) + + +def test_from_authorities_rejects_str_subclass_before_comma_split() -> None: + """Reject an authority-factory comma-string subclass before invoking split.""" + with pytest.raises(TypeError, match="allowed_methods"): + EgressPolicy.from_authorities( + [("api.example.com", 443)], + allowed_methods=_ExplodingMethodList("GET,POST"), + ) + + +def test_runtime_method_authorization_rejects_str_subclass_before_normalization() -> None: + """Reject subclass-controlled normalization at the request authorization boundary.""" + policy = EgressPolicy.from_hosts( + "api.example.com", + allowed_methods={"GET"}, + ) + + assert policy.allows_http_method(_NonExactMethod("GET")) is False + + +def test_policy_configuration_integrity_guide_covers_exact_method_strings() -> None: + """Document the exact HTTP method value boundary and preserved string syntax.""" + guide = Path("docs/research/policy-configuration-integrity.md").read_text( + encoding="utf-8" + ) + + assert "exact built-in `str`" in guide + assert "HTTP method" in guide + assert "comma-separated" in guide + assert "does not make EgressWeave a Python sandbox" in guide + + +def test_changelog_records_http_method_value_sealing() -> None: + """Record the method-string policy tightening in release history.""" + changelog = Path("CHANGELOG.md").read_text(encoding="utf-8") + + assert "Reject non-exact string subclasses in HTTP method policy values" in changelog