diff --git a/messagefoundry/config/settings.py b/messagefoundry/config/settings.py index 731627fa..ca4ddec4 100644 --- a/messagefoundry/config/settings.py +++ b/messagefoundry/config/settings.py @@ -904,6 +904,30 @@ def _check_tls_cert_dependency(self) -> ApiSettings: # Validate the DECLARED Posture-B proxy TLS floor for internal coherence (#200, ASVS 11.6.2) — # an attestation, but a *coherent* one (a NIST version floor; forward-secret ciphers if named). validate_proxy_tls_posture(self.proxy_tls_min_version, self.proxy_tls_ciphers) + # ASVS 3.4.1 — an IP-literal public_origin under a declared TLS posture makes HSTS INERT. + # RFC 6797 §8.1.1: a UA "MUST NOT note" an IP-literal host as a Known HSTS Host, so the + # Strict-Transport-Security header the engine emits for such an origin is required to be + # DISCARDED by every conforming browser. The control would report success while doing nothing — + # the exact shape this codebase keeps finding and refusing to ship. + # + # Checked HERE rather than in the public_origin field validator because the field alone cannot + # see the posture: a bare http:// loopback origin with no TLS declared is a legitimate dev + # flow, and only the model knows whether a TLS posture is in play. + if self.public_origin and (self.tls_terminated_upstream or self.tls_enabled): + host = urlsplit(self.public_origin).hostname or "" + try: + ipaddress.ip_address(host) + except ValueError: + pass # a DNS name — HSTS is notable, nothing to refuse + else: + raise ValueError( + f"[api].public_origin {self.public_origin!r} is an IP literal while a TLS posture " + "is declared. RFC 6797 §8.1.1 forbids a browser from noting an IP-literal host as " + "an HSTS host, so the Strict-Transport-Security header would be silently " + "discarded and the console would have no HTTPS-downgrade protection (ASVS 3.4.1). " + "Use a DNS hostname for the console — a dedicated subdomain, since " + "includeSubDomains on a hospital apex forces https on every sibling host." + ) return self diff --git a/tests/test_settings.py b/tests/test_settings.py index c795aeec..a7ce7df9 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -11,6 +11,7 @@ from pydantic import ValidationError from messagefoundry.config.settings import ( + ApiSettings, AuthSettings, DrSettings, ServiceSettings, @@ -1169,3 +1170,59 @@ def test_copy_on_send_default_is_on() -> None: ).pipeline.snapshot_on_send is False ) + + +# --- ASVS 3.4.1: HSTS must not be emitted for an origin the browser will discard it for ------------- + + +@pytest.mark.parametrize( + "origin", + [ + "https://10.20.30.40:8443", + "https://192.168.1.10", + "https://[2001:db8::1]", + "https://[::1]:8443", + ], +) +def test_an_ip_literal_public_origin_is_refused_under_a_declared_tls_posture(origin: str) -> None: + """RFC 6797 §8.1.1: a UA **MUST NOT** note an IP-literal host as a Known HSTS Host. + + So for an IP-literal `public_origin` the engine's `Strict-Transport-Security` header is required + to be DISCARDED by every conforming browser — the console would have no HTTPS-downgrade + protection while the header, the config and the scorecard all reported the control as present. + A control that reports success while doing nothing is worse than an absent one, because nothing + prompts anyone to look. + + Mutation: delete the IP-literal block in `_check_tls_cert_dependency`. Red: DID NOT RAISE. + """ + with pytest.raises(ValidationError, match="IP literal"): + ApiSettings( + public_origin=origin, + tls_terminated_upstream=True, + trusted_proxies=["127.0.0.1"], + serve_ui=True, + ) + + +def test_a_dns_public_origin_is_accepted_under_a_declared_tls_posture() -> None: + """The positive control. Without it the refusal above could be over-broad and nothing would say so. + + Mutation: refuse every host rather than only IP literals. Red: this raises.""" + s = ApiSettings( + public_origin="https://ops.example.com", + tls_terminated_upstream=True, + trusted_proxies=["127.0.0.1"], + serve_ui=True, + ) + assert s.public_origin == "https://ops.example.com" + + +def test_an_ip_literal_origin_is_still_fine_with_no_tls_posture_declared() -> None: + """The boundary that keeps this shippable. A loopback dev flow declares no TLS posture, emits no + HSTS, and must stay working — the refusal is about a header that would be silently discarded, not + about IP literals as such. + + Mutation: drop the `tls_terminated_upstream or tls_enabled` condition. Red: this raises, and every + developer running on 127.0.0.1 is locked out.""" + s = ApiSettings(public_origin="http://127.0.0.1:8765", serve_ui=True) + assert s.public_origin == "http://127.0.0.1:8765"