Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions changelog.d/2530.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
- **The AD hop follows no LDAP referral.** ldap3 follows one by default, and on a bound connection
it binds to the referred host with the same service-account password, over a TLS setup without the
pinned CA or the narrowed suites, or over plain `ldap://`. A first deployment would have sent that
password to whatever host one referral named. Every `ldap3.Connection` the engine builds now sets
`auto_referrals=False`, and its `ldap3.Server` sets `allowed_referral_hosts=[]`. A referral result
to a search or to the user bind is refused as a directory error naming only the referred hosts,
and one to the service-account bind fails that bind as before. Sign-in audits it as
`auth.login_error`, and the session reconciler never revokes on it. A site whose users or groups
span several domains of a forest would need a global catalog, or a search base in the bound
controller's own domain. The `BACKLOG #2494` TLS-context entry says a followed referral still gets
a plain ldap3 context. This supersedes that note: no referral is followed, so no referred hop is
opened. See [ADR 0180](../docs/adr/0180-asserting-tls-suites-on-a-library-that-exposes-no-sslcontext.md)
Amendment F. (`BACKLOG #2530`)
8 changes: 8 additions & 0 deletions changelog.d/2546.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
- **AD sign-in no longer fails on Linux over the receive timeout.** The engine passed
`[auth].ad_receive_timeout`, a float, straight to ldap3. On every non-Windows host ldap3 packs
that value as an integer. So each AD socket open would have raised `struct.error` after the TCP
connect and before the bind was sent. On a first Linux deployment, AD sign-in would have failed
for every user. Windows was not affected. The engine now passes ldap3 the timeout rounded up to
whole seconds, so it is never shorter than configured. Both AD timeouts are also refused at
config load above 3600 seconds. That cap keeps them far below the point where a socket timeout
overflows, which would fail sign-in outside the audited error path. (`BACKLOG #2546`)
4 changes: 2 additions & 2 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -665,8 +665,8 @@ document ([SECURITY-DOCS-POLICY.md](SECURITY-DOCS-POLICY.md)).
| `ad_tls_ca_cert_file` | str | — | trust an internal CA for LDAPS without disabling verification |
| `ad_tls_ca_cert_pin` | str | — | optional lowercase-hex SHA-256 pin over the corresponding CA anchor PEM (`ad_tls_ca_cert_file`); a mismatch refuses at load + reload (ASVS 6.7.1); unset = no pin (dormant); set but empty or whitespace refuses at load |
| `ad_allow_insecure_ldap` | bool | `false` | explicit opt-in to a non-`ldaps://` bind (trusted-network dev only) |
| `ad_connect_timeout` | float | `10.0` | seconds — bounds the LDAP/LDAPS **TCP connect** on every `ldap3` `Server` the authenticator builds (ASVS 13.1.3). Must be finite and `> 0`; `0`, negative, `inf` and `NaN` are refused at config load. `ldap3`'s own default is `None` (wait forever), so without this an unresponsive DC pinned a thread-pool worker indefinitely |
| `ad_receive_timeout` | float | `10.0` | seconds — bounds **each LDAP response read** (both binds and every search) on every `ldap3` `Connection`. Same finite-positive validation |
| `ad_connect_timeout` | float | `10.0` | seconds — bounds the LDAP/LDAPS **TCP connect** on every `ldap3` `Server` the authenticator builds (ASVS 13.1.3). Must be finite and `> 0`; `0`, negative, `inf` and `NaN` are refused at config load, and so is anything above `3600`. `ldap3`'s own default is `None` (wait forever), so without this an unresponsive DC pinned a thread-pool worker indefinitely |
| `ad_receive_timeout` | float | `10.0` | seconds — bounds **each socket receive** during an LDAP response (both binds and every search) on every `ldap3` `Connection`. Same validation, up to `3600`. The engine rounds it up to whole seconds before handing it to `ldap3`, so `9.25` acts as `10` |
| `ad_session_recheck_seconds` | int | `300` | **Directory session reconciliation** ([ADR 0079](adr/0079-kerberos-idp-session-coordination.md) mechanism 2). How often to re-resolve directory principals holding **live** sessions and revoke those AD has disabled or deleted — without it, an AD disable does not take effect until the `[security].max_session_hours` cap (12 h). **`300` (five minutes) is the default** (ADR 0148 GIVEN 1 — the hardened path is the shipped path), floored at **60 s** (a pass costs one LDAP bind per signed-in directory user). `0` disables the loop and is a **loosening** once AD is on — `security_loosenings()` names it. The default is **inert without AD** (`should_reconcile()` also needs an LDAP client), so a non-AD deployment is unaffected; an **explicit** non-zero value without `ad_enabled` is still refused rather than left silently dead. |
| `ad_session_recheck_strikes` | int | `2` | Consecutive passes a principal must fail to resolve before its sessions are revoked. *The search matched nothing* cannot tell *deleted* from *moved out of the search base*, so a single ambiguous result must never revoke; a set disabled bit and an unreadable `userAccountControl` strike the same way. A wave of unreadable answers is held instead of revoked, with no setting ([ADR 0195](adr/0195-brake-the-ad-session-reconciler-on-an-undetermined-useraccountcontrol-wave.md)). Range 1–10. |
| `ad_session_recheck_max_users` | int | `200` | Per-pass bind budget. Beyond this, remaining users are picked up by later passes (least-recently-probed first), so a large estate degrades to a longer effective interval instead of a bind storm. |
Expand Down
2 changes: 1 addition & 1 deletion docs/PHI.md
Original file line number Diff line number Diff line change
Expand Up @@ -880,7 +880,7 @@ audit chain or be false.
| MLLP inbound/outbound | Plaintext by default; **MLLP-over-TLS (TLS 1.2+, server-cert verify + hostname, opt-in mTLS) when `tls=true`** `[BUILT — WP-13b]`. A non-loopback plaintext MLLP listener is **refused at startup** (exposed-gate, ADR 0002 §0) unless `tls=true` or `serve --allow-insecure-bind`. | — |
| File connector | Plaintext `.hl7` on disk/share | Rely on volume/share encryption; SFTP later |
| Engine API ↔ console | Loopback HTTP by default; off-loopback requires TLS — **in-process** (`[api].tls_cert_file`, WP-13a) **or upstream** at a trusted reverse proxy (`tls_terminated_upstream` + `trusted_proxies`, WP-15) `[BUILT]`. Upstream, the proxy-to-engine hop is plaintext unless `tls_cert_file` is set; the site secures it, and `serve` requires `plaintext_upstream_hop_acknowledged` (BACKLOG #1179). HSTS engages on `https`; forwarded headers are trusted only from `trusted_proxies`. | — |
| AD / LDAP auth | **LDAPS** with cert verification (`ad_tls_verify`) `[BUILT]` | — |
| AD / LDAP auth | **LDAPS** with cert verification (`ad_tls_verify`) `[BUILT]`. No LDAP referral is followed, so the bind credentials never leave this hop for a referred host; a referral refuses the sign-in (BACKLOG #2530, 2026-09-30). A multi-domain forest would need a global catalog or a search base in the bound controller's own domain. | — |
| PostgreSQL / SQL Server backend | TLS-to-DB on by default (`[store].encrypt`), server cert **validated** (`trust_server_certificate=false`) `[BUILT]`. Trust a private/internal DB CA without disabling validation via `[store].ssl_root_cert` file-pin (Postgres CA-bundle, SQL Server ODBC 18.1+ `ServerCertificate` leaf-pin) **or** a Windows machine-store (`LocalMachine\Root`) CA import. | — |

**Hard rule:** never bind the API to `0.0.0.0` (or any non-loopback interface) without TLS in front
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 0180 — Asserting TLS suites on a library that exposes no SSLContext

- **Status:** Accepted (amended 2026-09-03, extended 2026-09-04 — see Amendment A; amended 2026-09-26 by BACKLOG #2034 — see Amendment B; amended 2026-09-27 by BACKLOG #300 — see Amendment C; amended 2026-09-28 by BACKLOG #300 — see Amendment D; amended 2026-09-30 by BACKLOG #2494 — see Amendment E)
- **Status:** Accepted (amended 2026-09-03, extended 2026-09-04 — see Amendment A; amended 2026-09-26 by BACKLOG #2034 — see Amendment B; amended 2026-09-27 by BACKLOG #300 — see Amendment C; amended 2026-09-28 by BACKLOG #300 — see Amendment D; amended 2026-09-30 by BACKLOG #2494 — see Amendment E; amended 2026-09-30 by BACKLOG #2530 — see Amendment F)
- **Date:** 2026-08-28
- **Related:** BACKLOG #1317 · `messagefoundry/config/tls_policy.py` (`harden_cipher_suites`, `build_asserted_https_handler`, `assert_ldap3_tls_suites`, `assert_hvac_tls_suites`) · `messagefoundry/auth/ldap.py` · `messagefoundry/config/secretprovider_vault.py` · `messagefoundry/store/keyprovider_vault.py` · `messagefoundry/store/crypto_transit.py` · `tests/test_tls_cipher_assertion_sites.py` · `.github/workflows/ci.yml`

Expand Down Expand Up @@ -323,3 +323,25 @@ the post-handshake check.
The engine now builds the LDAPS context itself, and an engine subclass of `ldap3.Tls` wraps each
connection with it, so the replica and the `ciphers=` string of Amendment C are gone. ADR 0188's
amendment of the same date records the change, what it keeps and what it does not cover.

## Amendment F (2026-09-30) -- the AD hop follows no LDAP referral (BACKLOG #2530)

Every context above guards one hop: the one to `[auth].ad_server`. ldap3 2.9.1 could leave it. By
default it follows a referral, and on a bound connection it binds to the referred host with the
same user and password (`strategy/base.py`, `create_referral_connection`). It builds a plain
`ldap3.Tls` for that hop from a few attributes, so the hop has no pinned CA bytes, none of the
narrowing, and no TLS at all for an `ldap://` referral. A first deployment would therefore send the
service-account password to whatever host one referral named.

`messagefoundry/auth/ldap.py` now builds every `Connection` with `auto_referrals=False` and every
`Server` with `allowed_referral_hosts=[]`. Each alone stops the follow, and
`tests/test_ldap_referrals.py` measures both arms against loopback servers. A referral result
(resultCode 10) to a search or to the user bind is now an `LdapError` that names the referred hosts
and nothing else from the URL. Sign-in audits it as `auth.login_error`, and the session reconciler
reads it as unavailable, so it never revokes. A search continuation reference is not a referral
result: ldap3 never follows one, and it still reads as no entry from that subtree.

A site whose users or groups live in more than one domain of a forest would need a global catalog,
or a search base in the bound controller's own domain, instead of referrals. A global catalog
carries the membership of universal groups only, so roles mapped to another domain's domain-local
or global groups would not resolve through it.
Loading
Loading