diff --git a/CHANGELOG.md b/CHANGELOG.md index a1e9dc0c..a916e69a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,11 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). without changing the centrally managed review-agent credential contract. ### Security +- Require the exact `TLSConfiguration` type before TLS context creation. A + subclass can no longer override `create_ssl_context()` to replace the reviewed + immutable policy with a context that disables hostname or certificate + verification; private trust, mTLS, and explicit TLS 1.2 compatibility remain + available through the documented declarative fields. - Canonicalize the public manifest writer's optional `forbidden_root` before any output-parent creation or output-path access. Missing, non-directory, symlinked, unresolvable, or otherwise noncanonical roots now fail with one diff --git a/docs/research/tls-configuration.md b/docs/research/tls-configuration.md index 7ee06bf1..e9cd100c 100644 --- a/docs/research/tls-configuration.md +++ b/docs/research/tls-configuration.md @@ -50,6 +50,16 @@ trusted integration point for deferred secret retrieval. Every transport owns the fresh context that results, eliminating post-validation caller mutation as an authority channel. +The public context helper accepts only the exact `TLSConfiguration` type before +it invokes `create_ssl_context()`. Subclassing this security value object is not +an extension mechanism: a subclass could otherwise replace +`create_ssl_context()` with caller-controlled dispatch and return a context that +disables hostname verification or certificate verification while still passing +an `isinstance` check. Rejecting subclasses before that method is invoked keeps +the reviewed configuration fields, not polymorphic code, authoritative for TLS +policy. This is a pre-1.0 secure-default tightening and does not claim to sandbox +arbitrary trusted Python executing inside the embedding process. + ## Trust-store semantics The default preserves EgressWeave's existing HTTPX trust behavior while @@ -103,6 +113,11 @@ default. An existing endpoint that cannot yet negotiate TLS 1.3 can opt into `minimum_version=ssl.TLSVersion.TLSv1_2`; this is an explicit compatibility exception that should be inventoried and removed after the peer is upgraded. +Applications that previously subclassed `TLSConfiguration` must migrate to an +exact instance using the documented declarative fields. Private trust roots, +mutual-TLS identities, deferred private-key passwords, and the explicit TLS 1.2 +compatibility floor remain supported without subclassing. + The configuration is threaded through both public builders and both already-validated pinned-client builders. It changes only TLS trust and client identity; exact authority, DNS pinning, proxy isolation, request framing and @@ -114,6 +129,9 @@ independently enforced. Aviram, N. (2026). *Deprecating obsolete key exchange methods in TLS 1.2 and DTLS 1.2* (RFC 10015). RFC Editor. https://www.rfc-editor.org/rfc/rfc10015.html +MITRE Corporation. (2026). *CWE-295: Improper certificate validation* (CWE +Version 4.20). https://cwe.mitre.org/data/definitions/295.html + OpenSSL Project Authors. (2026). *openssl-ciphers*. OpenSSL 3.0 documentation. https://docs.openssl.org/3.0/man1/openssl-ciphers/ diff --git a/src/egressweave/tls.py b/src/egressweave/tls.py index 556de828..e242b37c 100644 --- a/src/egressweave/tls.py +++ b/src/egressweave/tls.py @@ -212,7 +212,7 @@ def create_egress_ssl_context( """Create the default HTTPX context or a fresh configured enterprise context.""" if configuration is None: return _create_httpx_ssl_context(verify=True, trust_env=False) - if not isinstance(configuration, TLSConfiguration): + if type(configuration) is not TLSConfiguration: raise TypeError("tls_configuration must be TLSConfiguration or None") return configuration.create_ssl_context() diff --git a/tests/test_tls_configuration_exact_type.py b/tests/test_tls_configuration_exact_type.py new file mode 100644 index 00000000..3daa619f --- /dev/null +++ b/tests/test_tls_configuration_exact_type.py @@ -0,0 +1,28 @@ +"""Exact-type security contracts for enterprise TLS configuration.""" + +from __future__ import annotations + +import ssl + +import pytest + +from egressweave.tls import TLSConfiguration, create_egress_ssl_context + + +class _VerificationDisablingTLSConfiguration(TLSConfiguration): + """Return an insecure context if subclass-controlled dispatch is allowed.""" + + def create_ssl_context(self) -> ssl.SSLContext: + """Build a context that deliberately violates the EgressWeave TLS contract.""" + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + return context + + +def test_context_helper_rejects_tls_configuration_subclasses_before_dispatch() -> None: + """Reject polymorphic configuration before subclass code can replace TLS policy.""" + configuration = _VerificationDisablingTLSConfiguration() + + with pytest.raises(TypeError, match="TLSConfiguration or None"): + create_egress_ssl_context(configuration) diff --git a/tests/test_tls_configuration_exact_type_documentation.py b/tests/test_tls_configuration_exact_type_documentation.py new file mode 100644 index 00000000..6d2f01d5 --- /dev/null +++ b/tests/test_tls_configuration_exact_type_documentation.py @@ -0,0 +1,33 @@ +"""Documentation contracts for the exact TLS configuration type boundary.""" + +from __future__ import annotations + +from pathlib import Path + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +TLS_GUIDE = REPOSITORY_ROOT / "docs" / "research" / "tls-configuration.md" +CHANGELOG = REPOSITORY_ROOT / "CHANGELOG.md" + + +def _normalized(path: Path) -> str: + """Return repository documentation with insignificant whitespace collapsed.""" + return " ".join(path.read_text(encoding="utf-8").split()) + + +def test_tls_guide_requires_exact_configuration_type_before_dispatch() -> None: + """Explain why subclass dispatch cannot replace the reviewed TLS policy.""" + guide = _normalized(TLS_GUIDE) + + assert "exact `TLSConfiguration` type" in guide + assert "subclass" in guide + assert "before" in guide and "create_ssl_context" in guide + assert "hostname verification" in guide + assert "certificate verification" in guide + + +def test_changelog_records_exact_tls_configuration_type_boundary() -> None: + """Keep the pre-1.0 TLS policy-integrity tightening visible to integrators.""" + changelog = _normalized(CHANGELOG) + + assert "exact `TLSConfiguration` type" in changelog + assert "subclass" in changelog