diff --git a/messagefoundry/__main__.py b/messagefoundry/__main__.py index da5062d1..c91ef533 100644 --- a/messagefoundry/__main__.py +++ b/messagefoundry/__main__.py @@ -1884,13 +1884,17 @@ def _serve(args: argparse.Namespace) -> int: # DECLARED reverse proxy the request Host header is client-forwardable — without the # exact origin, the /ui same-origin CSRF check degrades to Host comparison and the # WebAuthn rp_id would have anchored to attacker-influenceable input. + # Names the OPERATOR-FACING key (BACKLOG #1026), for the same reason as the ASVS 12.1.1 + # refusal further down: ADR 0118 relocated `[api].public_origin` to + # `[security].web_console_public_address` and REJECTS the old spelling as file or env + # input, so an instruction to set it fails at load. print( - "error: [api].serve_ui with [api].tls_terminated_upstream requires " - '[api].public_origin (e.g. "https://mefor.example.org") — behind a declared ' + "error: serving the web console behind a declared TLS terminator requires an " + 'external origin (e.g. "https://mefor.example.org") — behind a declared ' "reverse proxy the Host header is client-forwardable, so the browser console's " "same-origin CSRF check and the WebAuthn passkey origin binding need the exact " - "external origin. Set [api].public_origin to the origin the browser uses. See " - "docs/security/OFF-LOOPBACK-DEPLOYMENT.md (ADR 0068).", + "external origin. Set [security].web_console_public_address to the origin the " + "browser uses. See docs/security/OFF-LOOPBACK-DEPLOYMENT.md (ADR 0068).", file=sys.stderr, ) return 2 @@ -2153,12 +2157,23 @@ def _serve(args: argparse.Namespace) -> int: and enforcing and not settings.api.public_origin ): + # THE REMEDIATION NAMES THE KEY THE LOADER ACCEPTS, NOT THE FIELD THIS CODE READS + # (BACKLOG #1026). `[api].public_origin` is the INTERNAL field; ADR 0118 relocated the + # operator-facing key to `[security].web_console_public_address` and REJECTS the old + # spelling as file or env input (`_RELOCATED_TO_SECURITY` in config/settings.py). So the + # refusal this block shipped with handed an operator a remediation that fails at load: do + # what it says and the next start dies on "unrecognized config key(s)". + # + # A hard refusal that names an unusable fix is worse than one that names none -- it costs a + # restart cycle to discover, and it reads as authoritative because it is coming from the + # gate itself. tests/test_api_tls.py pins the remediation string AGAINST the relocation map + # so the two cannot drift apart again. print( f"error: refusing to serve on a PHI instance ({env_name!r}) behind a declared TLS " - "terminator under `enforce` without [api].public_origin — the ASVS 12.1.1 TLS-floor " + "terminator under `enforce` without an external origin — the ASVS 12.1.1 TLS-floor " "probe dials that origin, so leaving it unset silently disables the check rather than " - 'failing it. Set [api].public_origin to the origin the browser uses (e.g. "https://' - 'mefor.example.org"). See docs/security/OFF-LOOPBACK-DEPLOYMENT.md.', + "failing it. Set [security].web_console_public_address to the origin the browser uses " + '(e.g. "https://mefor.example.org"). See docs/security/OFF-LOOPBACK-DEPLOYMENT.md.', file=sys.stderr, ) return 2 diff --git a/tests/test_api_tls.py b/tests/test_api_tls.py index f013e424..b8b3083f 100644 --- a/tests/test_api_tls.py +++ b/tests/test_api_tls.py @@ -1443,7 +1443,10 @@ def test_phi_behind_a_declared_terminator_refuses_without_public_origin_console_ _posture_probe_toml(tmp_path, public_origin=None, serve_ui=False) assert _run_posture_b(tmp_path, monkeypatch, env="prod") == 2 err = capsys.readouterr().err - assert "public_origin" in err + # The remediation names the OPERATOR-FACING key, not the internal field. It used to say + # `[api].public_origin`, which ADR 0118 relocated and the loader REJECTS -- see the pinning + # test below (BACKLOG #1026). + assert "web_console_public_address" in err assert "12.1.1" in err, "the refusal must name the control it protects, not just the setting" @@ -1454,7 +1457,7 @@ def test_the_refusal_does_not_depend_on_the_console( property of the POSTURE rather than of an unrelated console setting.""" _posture_probe_toml(tmp_path, public_origin=None, serve_ui=True) assert _run_posture_b(tmp_path, monkeypatch, env="prod") == 2 - assert "public_origin" in capsys.readouterr().err + assert "web_console_public_address" in capsys.readouterr().err def test_a_non_phi_instance_is_not_refused( @@ -1466,3 +1469,40 @@ def test_a_non_phi_instance_is_not_refused( _posture_probe_toml(tmp_path, public_origin=None, serve_ui=False, synthetic=True) rc = _run_posture_b(tmp_path, monkeypatch, env="prod") assert rc != 2 or "public_origin" not in capsys.readouterr().err + + +def test_the_refusal_names_a_key_the_loader_actually_accepts() -> None: + """BACKLOG #1026: the refusal handed the operator a remediation that fails at load. + + `[api].public_origin` is the INTERNAL field this code reads. ADR 0118 relocated the + operator-facing key to `[security].web_console_public_address` and REJECTS the old spelling as + file or env input, so an operator who did what the refusal said got "unrecognized config key(s)" + on the next start. A hard refusal naming an unusable fix is worse than one naming none: it costs + a restart cycle to discover and it reads as authoritative, because it comes from the gate itself. + + PINNED AGAINST THE RELOCATION MAP RATHER THAN AGAINST A STRING LITERAL. Asserting the message + contains some remembered spelling would pass just as well after a future relocation moved the key + again -- the two would drift apart silently, which is the defect this test exists to stop. So the + expected key is READ FROM `_RELOCATED_TO_SECURITY`, and a relocation that is not mirrored in the + message reds here. + """ + from messagefoundry.config.settings import _RELOCATED_TO_SECURITY + + expected = _RELOCATED_TO_SECURITY[("api", "public_origin")] + src = (Path(__file__).resolve().parents[1] / "messagefoundry" / "__main__.py").read_text( + encoding="utf-8" + ) + # The 12.1.1 refusal block, located by the control it names rather than by a line number. + marker = "the ASVS 12.1.1 TLS-floor " + assert marker in src, "the 12.1.1 refusal moved; re-anchor this test on the control it names" + start = src.index(marker) + block = src[max(0, start - 600) : start + 600] + assert expected in block, ( + f"the ASVS 12.1.1 refusal does not name {expected!r}, the key the loader accepts. " + "_RELOCATED_TO_SECURITY says that is where [api].public_origin moved, so a message naming " + "the old spelling tells an operator to write a key that is REFUSED at load." + ) + assert "[api].public_origin" not in block, ( + "the refusal still names the relocated spelling, which the loader rejects as file or env " + "input. Name the [security] key instead." + )