fix(egress): gate the forward proxy host behind a dedicated [egress].allowed_proxy list (BACKLOG #1659) - #1230
Conversation
…t (BACKLOG #1659) The ADR 0126 forward proxy is a credential-bearing egress host that no allow-list checked. Under the default proxy_auth_type = basic the engine mints a pre-emptive Proxy-Authorization that urllib carries to the proxy on an http destination as a request header and on an https one inside the CONNECT tunnel, so an un-listed proxy would receive that credential on first delivery, and for a permitted cleartext http destination the PHI body would follow it. Gate it with a new [egress].allowed_proxy list, checked on both the outbound arm (check_egress_allowed) and the fhir_lookup read arm (check_fhir_lookup_allowed), outside each one's allowed_http guard -- that list being empty says nothing about whether the proxy is permitted. Deny-by-default, matching [ai].allowed_endpoints (ADR 0135) rather than the permissive-when-empty destination lists: an explicit proxy_url with an empty allowed_proxy is refused at config load. An empty list refuses nothing until a proxy is configured, and permissive-when-empty would leave the credential-bearing host ungated on the default posture. proxy_url is deliberately NOT added to _CREDENTIAL_EGRESS_URL_KEYS. That table gates against [egress].allowed_http, which ADR 0126 rules the proxy host out of: "The forward proxy is an operator-chosen transport intermediary, not a PHI destination; gating it against allowed_http would be wrong (one corporate proxy fronts many hosts, and it would have to be co-listed with every destination)." A note at the table records this so the next pass does not re-file it. rest.py's _PROXY_DEFAULT becomes public PROXY_DEFAULT so the gate can exempt the sentinel without a second copy of the literal.
…KLOG #1659) Five of these fail without the gate calls, confirmed by neutralizing the two call sites and re-running: an un-listed proxy is refused, a listed one passes (host-only entry any port, pinned port exact), the empty list is deny-by-default, the fhir_lookup read arm is gated, and the site-wide [egress].proxy_url default is gated through the same _dest_config copy-in the runner does. Three deliberately do NOT fail without the change and say so in their own docstrings: the pin that proxy_url stays out of _CREDENTIAL_EGRESS_URL_KEYS (a guard against a future edit -- the ledger row asks for that edit in writing), the "default" sentinel carve-out (it asserts a non-raise), and the pin that a listed proxy does not satisfy the destination gate. Also corrects the open-egress gate's "EIGHT allowed_* lists" wording to "EIGHT allowed_* DESTINATION lists": allowed_proxy is a ninth allowed_* key, is not in _allowlist_for, and must not satisfy that gate.
…ment A (BACKLOG #1659) The ADR's scope sentence stays true and is not reopened: [egress].allowed_http still gates the destination, and the proxy host is still out of its scope for the reason recorded there. What that sentence did not settle is whether the proxy is gated at all, and it was not. The amendment records the dedicated [egress].allowed_proxy list, its deny-by-default semantics and why they differ from the destination lists, the "default" sentinel carve-out and its residual gap, AC-10, and that proxy_url is deliberately kept out of _CREDENTIAL_EGRESS_URL_KEYS. Adds the key to the [egress] table in docs/CONFIGURATION.md and amends the ADR index row so a reader of the summary does not conclude the proxy is ungated.
LANDER INSPECTION -- labelled self-review, not a peer reviewPosted under the korus The best thing in this PR is that it found the invariant its own change would have falsified
A counted invariant in a comment is exactly what a new key breaks with nothing reporting it. Deny-by-default is real in code, not only in proseEmpty list plus a configured proxy refuses. Contrast The ordering that the whole gate depends on also holds: every The one thing I would ask for, and it is the PR's own lesson applied one screen away
This PR edits that file to add an Three further spots in No CI gate catches any of these. No test pins the egress table or the eight-count, and none What I am doing about itNot blocking. The code is correct, it is a security improvement, and holding a sound deny-by-default If you push it, I will re-read and land. If you would rather keep the diff tight, say so and I will VerdictVerdict: merge. |
|
CI failed while this pull request was in the merge queue, so the queue ejected it. Its own head can still be green: the queue revalidates the merge, and the path gates that skip on a pull request run there. Read the run before retrying. https://github.com/MEFORORG/MessageFoundry/actions/runs/35166188124 |
BACKLOG #1659 — the forward proxy host is a credential-bearing egress target the allow-list never checks. P1.
What was wrong
The ADR 0126 forward/egress web proxy is a credential-bearing egress host that no allow-list checked.
check_egress_allowed's HTTP arm readsdest.settings["url"]and then the credential token URLs in_CREDENTIAL_EGRESS_URL_KEYS, which holds onlysmart_token_urlandoauth2_token_url.Under the default
proxy_auth_type = basicthe connector mints a pre-emptiveProxy-Authorization: Basic ...(transports/rest.py,proxy_auth_handler_from_settings), and ADR 0126 records that urllib moves it into theCONNECTtunnel for an https destination, so it reaches the proxy on both destination schemes.Severity is conditional, per CLAUDE.md section 0: nothing is deployed. An un-listed proxy would receive that credential on a site's first delivery, and for a permitted cleartext
httpdestination the PHI request would follow it.proxy_urlis operator configuration, never attacker input.What was built, and what was deliberately not
Not built: adding
proxy_urlto_CREDENTIAL_EGRESS_URL_KEYS. The ledger row's own closing step 1 asks for exactly that, and it is the mechanism ADR 0126 forbids in terms:ADR 0126 is Accepted, dated 2026-07-17, with no amendment section and nothing superseding it;
docs/adr/README.mdrestates the scope line. Verified atorigin/mainbefore building. A note now sits at_CREDENTIAL_EGRESS_URL_KEYSnaming ADR 0126 as the reason the key stays out, and a test pins it, so the next pass does not re-file this.Built instead: a dedicated
[egress].allowed_proxylist. It answers the ADR's stated objection rather than evading it — listing the proxy once permits it for every destination, so there is no co-listing — and it leaves the ADR's scope sentence literally true. It is also the house pattern: ADR 0135 gave the AI broker[ai].allowed_endpointsfor the same reason, thatallowed_httpis permissive-when-empty and cannot be the gate for a credential-bearing egress surface.EgressSettings.allowed_proxy,host/host:portmatching like every other[egress]list, envMEFOR_EGRESS_ALLOWED_PROXY.check_egress_allowed(outbound REST/SOAP/FHIR/DICOMweb) andcheck_fhir_lookup_allowed(thefhir_lookupread) — outside each one'sallowed_httpguard, because an emptyallowed_httpsays nothing about whether the proxy is permitted. That lockstep is the DELTA-04 lesson."default"sentinel is skipped.Deny semantics, and why the two lists differ
Deny-by-default, following
[ai].allowed_endpointsrather thanallowed_http's permissive-when-empty: an explicitproxy_urlwith an emptyallowed_proxyis refused at config load.The asymmetry is deliberate and is documented at the field, at the helper, in
docs/CONFIGURATION.mdand in the ADR amendment. The reasoning:allowed_proxyrefuses nothing until a proxy is actually configured, so an operator who uses no proxy is unaffected. That is unlikeallowed_http, where deny-when-empty would refuse every HTTP destination on a stock config.Why the
"default"sentinel is exemptproxy_config_from_settingsalready refuses to combineproxy_url = "default"with explicit proxy credentials, so that path mints noProxy-Authorization— the credential-bearing case is exactly the explicit-URL case. It also names no address at config time; urllib resolves the OS proxy per request, so there is no host for a config-load gate to match. A residual gap is recorded in the ADR amendment: a system proxy chosen by the OS environment still sees theCONNECTtarget or a cleartext body, and no config-time list can name it.Corrections to two prose claims this change would otherwise falsify
docs/SECURITY.md. The row's closing step 2 named SECURITY.md; its twoallowed_httpmentions (around :1343 and :2253) are both specific and correct, so neither was touched. The universal actually lived atwiring_runner.py's_CREDENTIAL_EGRESS_URL_KEYSheader, which said every such key "must ride the same[egress].allowed_httpallowlist or it is a fail-open credential-exfiltration hole". That is now scoped to destination-class hosts, with the proxy exception and its ADR-0126 reason beside it.allowed_*lists" in__main__.py's open-egress gate and intest_security_config.py's docstring now read "EIGHTallowed_*DESTINATION lists", namingallowed_proxyas a ninthallowed_*key that is not one of them, is not in_allowlist_for, and must never satisfy that gate.The recorded risk was already retired
The row warned the default sentinel must be skipped without breaking the runner's copy-in of
[egress].proxy_url. Confirmed rather than assumed:_apply_egress_proxy_defaultruns inside_dest_configahead of the gate, and atwiring_runner.py:7170ahead ofcheck_fhir_lookup_allowed, so the merged value is already indest.settingswhen the gate reads it.test_egress_default_proxy_is_gated_by_allowed_proxyasserts that through the real_dest_config. No reordering needed.Tests
Five new tests fail without the fix, confirmed by neutralizing the two
_check_forward_proxy_egresscall sites and re-running (5 failed, 36 passed; restored byte-identical afterwards):test_outbound_denies_unlisted_proxy_hosttest_outbound_permits_listed_proxy_hosttest_proxy_gate_is_deny_by_default_with_an_empty_listtest_proxy_gate_covers_the_fhir_lookup_read_armtest_egress_default_proxy_is_gated_by_allowed_proxyThree deliberately do NOT fail without the change, and each says so in its own docstring rather than being presented as red-first: the pin that
proxy_urlstays out of_CREDENTIAL_EGRESS_URL_KEYS(a guard against a future edit — the one the ledger row asks for in writing), the"default"carve-out (it asserts a non-raise), and the pin that a listed proxy does not satisfy the destination gate.Checks run
ruff format --checkandruff checkover the tree: clean.mypy messagefoundry(strict): 13 errors in 3 files, all pre-existing and all missing-optional-dependency noise inparsing/dicom/_deps.py,parsing/fhir/_deps.pyandtransports/dicom.py. Zero in any file this change touches.pytest, targeted census: 2456 passed, 94 skipped, plus a follow-up run of 167 more. Enumeration method: every test module matching any symbol this change touches (EgressSettings,check_egress_allowed,check_fhir_lookup_allowed,_allowlist_for,_apply_egress_proxy_default,_dest_config,_CREDENTIAL_EGRESS_URL_KEYS,PROXY_DEFAULT,proxy_url,allowed_http) or referencing the docs edited, unioned — 58 files — then a second pass over the modules mentioningEgressSettingsoregressthat the first missed, plus the doc-drift and ADR-index gates.dev,harness,fhir,dicom,x12,xml,vault), so the collection-gated modules actually ran rather than removing themselves.Skipped: the full suite. It does not finish inside one turn on this box.
Hosted legs to read:
windows-service-smoke(NSSM, never visible locally), theideleg,webconsole, and the Linux matrix legs. Nothing here touches the service, the IDE or the console, so no failure is expected from them.Collisions
messagefoundry/pipeline/wiring_runner.pyandmessagefoundry/config/settings.pyare also edited by PR 1212, in the[inbound]streaming region. This change stays in the[egress]region —_allowlist_for,_CREDENTIAL_EGRESS_URL_KEYS,check_fhir_lookup_allowed,check_egress_allowed— and did not need to touch 1212's region. The doc edits are in their own last commit so they can be resolved separately.One unrelated working-tree edit to
scripts/hooks/context-budget.ps1was present in this worktree and is not part of this change; it was left unstaged rather than discarded, since an uncommitted edit is not recoverable.Open questions
proxy_no_proxydoes not exempt the gate. A destination on the bypass list gets no proxy handler and no credential, so in principle a fully-bypassed connection needs no listed proxy. The gate ignores that: it asks whether the configured proxy address is permitted, not whether a particular target happens to bypass it. Simpler, fail-closed, and the bypass is evaluated per target host at construction — including token-endpoint hosts, which can differ from the destination — so a per-connection "is everything bypassed?" test would be fragile. Flagging it in case the reviewer wants the carve-out."default"sentinel's residual gap is recorded in the ADR amendment rather than closed. Closing it would mean resolvinggetproxies()at config load and gating what it returns, which makes a config-load refusal depend on process environment at that moment. Not built; say if it should be filed.