diff --git a/CHANGELOG.md b/CHANGELOG.md index d2eef161..7bf94677 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,9 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). without changing the centrally managed review-agent credential contract. ### Security +- Reject non-exact string subclasses in HTTP method policy values before + normalization or comma-separated parsing, preserving ordinary built-in method + strings, RFC 9110 token validation, uppercase canonicalization, and `CONNECT` denial. - 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 diff --git a/docs/research/policy-configuration-integrity.md b/docs/research/policy-configuration-integrity.md index ea35b13f..35b6a1d9 100644 --- a/docs/research/policy-configuration-integrity.md +++ b/docs/research/policy-configuration-integrity.md @@ -14,28 +14,39 @@ 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. +HTTP method policy values use the same supported-value sealing principle. Each +individual method token must be an exact built-in `str` before whitespace removal +or uppercase normalization can run. The existing exact comma-separated +`allowed_methods` string remains supported and is split only after its outer value +has been proven to be an exact built-in string. The resulting method entries still +follow the RFC 9110 token grammar, are canonicalized to uppercase, and always +reject `CONNECT`. + 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. +request/response byte budgets, plus the reviewed HTTP method normalization paths. +It does not change configured defaults, allowed ranges, authority pairing, DNS +policy, TLS identity, proxy isolation, 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. +Python deliberately supports subclassing immutable built-in types such as `int` +and `str`. `isinstance(value, int)` or `isinstance(value, str)` therefore accepts +subclass instances, while Python's data model permits immutable built-in +subclasses to customize behavior. A broad `isinstance` check is consequently a +polymorphism contract, not proof that the stored or parsed value is the canonical +built-in primitive 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. +customization is expressed through documented values, not user-defined numeric or +string 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. Requiring `type(value) is str` for HTTP methods prevents a +subclass from controlling `strip()`, `upper()`, or the comma-separated `split()` +step before trusted normalization. Environment text still reaches the same +canonical state through explicit decimal conversion or ordinary built-in strings. This supported-value sealing does not make EgressWeave a Python sandbox. Code that is already executing inside the embedding process retains ordinary Python @@ -51,26 +62,39 @@ integrations. 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 +4. HTTP method entries must be exact built-in strings before whitespace removal + or uppercase normalization; non-exact `str` subclasses are rejected. +5. The exact built-in comma-separated `allowed_methods` form remains supported, + but a string subclass is rejected before `split()` can run. RFC 9110 token + validation, uppercase canonicalization, and unconditional `CONNECT` rejection + remain unchanged. +6. Booleans remain invalid integer configuration even though Python defines `bool` as an `int` subclass. -5. Existing decimal-string syntax, defaults, public builder signatures, and +7. 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 +8. 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 +9. 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. +Applications that supply plain integers, ASCII decimal environment values, or +ordinary built-in HTTP method strings need no change. The existing exact +comma-separated `allowed_methods` string is also unchanged. Applications that +pass custom subclasses of `int` for ports or finite resource budgets should +materialize an exact built-in integer before policy construction; applications +that pass custom subclasses of `str` for HTTP methods should materialize an +ordinary built-in string first. This is a pre-1.0 tightening of ambiguous +configuration shapes; it does not widen egress authority or change any finite +default. + +## References — APA 7th -## Reference — APA 7th +Fielding, R., Nottingham, M., & Reschke, J. (2022). *HTTP semantics* (RFC 9110; +STD 97). RFC Editor. https://www.rfc-editor.org/rfc/rfc9110.html 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 0a190a5d..52ecdb86 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 1c69a1ff..12e8285a 100644 --- a/src/egressweave/policy.py +++ b/src/egressweave/policy.py @@ -244,6 +244,8 @@ def __post_init__(self) -> None: method_values: Iterable[object] if isinstance(self.allowed_methods, str): + if type(self.allowed_methods) is not str: + raise TypeError("allowed_methods entries must be HTTP method strings") method_values = self.allowed_methods.split(",") else: method_values = self.allowed_methods @@ -371,6 +373,8 @@ def from_hosts( method_items: Iterable[str] if isinstance(allowed_methods, str): + if type(allowed_methods) is not str: + raise TypeError("allowed_methods entries must be HTTP method strings") method_items = allowed_methods.split(",") else: method_items = allowed_methods @@ -434,6 +438,8 @@ def from_authorities( ) method_items: Iterable[str] if isinstance(allowed_methods, str): + if type(allowed_methods) is not str: + raise TypeError("allowed_methods entries must be HTTP method strings") method_items = allowed_methods.split(",") else: method_items = allowed_methods diff --git a/tests/test_policy_method_value_integrity.py b/tests/test_policy_method_value_integrity.py new file mode 100644 index 00000000..4fe42e0d --- /dev/null +++ b/tests/test_policy_method_value_integrity.py @@ -0,0 +1,96 @@ +"""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 unsafe 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 entries must be HTTP method strings$", + ): + 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 entries must be HTTP method strings$", + ): + 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 entries must be HTTP method strings$", + ): + 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 entries must be HTTP method strings$", + ): + EgressPolicy.from_authorities( + [("api.example.com", 443)], + allowed_methods=_ExplodingMethodList("GET,POST"), + ) + + +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