Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 5 additions & 4 deletions changelog.d/3010.security.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@
**The nested group search is paged**, at 500 entries per page. So an account in more groups than
a directory's unpaged cap no longer fails sign-in with sizeLimitExceeded. AD's cap, MaxPageSize,
is 1000 by default. A page that does not end with success refuses the whole search. So does a
later page that drops the paging control, and a search still paging after 20 pages. The pages
already read are never used as the whole answer. A refused paged search is abandoned (RFC 2696)
once the directory has sent a cookie, at the cost of one more round trip. Paging was measured
against a loopback server through ldap3, not against a domain controller.
later page that drops the paging control, and a search past its paging bounds
(`BACKLOG #3696`). The pages already read are never used as the whole answer. A refused paged
search is abandoned (RFC 2696) once the directory has sent a cookie, at the cost of one more
round trip. Paging was measured against a loopback server through ldap3, not against a domain
controller.
**A response ldap3 cannot decode is a directory error, not an unhandled error.** ldap3's default
decoder raises `KeyError`, `TypeError`, `IndexError`, `AttributeError` or `UnicodeError` on a
malformed response. Cases include at least a result code outside its table, a malformed paged
Expand Down
13 changes: 13 additions & 0 deletions changelog.d/3696.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
- **A paged LDAP group search has one overall deadline, and its cap counts groups, not pages.**
All the pages of the nested group search now share one deadline, the new
`[auth].ad_group_search_deadline`, 120 s by default. Each page's reads stay bounded by
`[auth].ad_receive_timeout`. Before, each page could take the whole receive timeout. It is not
a hard wall-clock bound. A search still paging at the deadline is refused as the directory not
answering. The search is now refused past 10,000 groups at any page size. Before, it was
refused past 20 pages, which is 2,000 groups at 100 a page. Both refusals warn. **The session
reconciler pages, and audits `auth.ad_reconcile_paging_refused`, for an account either refusal
leaves unjudged**, once as the account enters that state rather than on every pass. A disable or
deletion still revokes such an account; its role and scope re-diff does not run. A plain
directory outage still pages nobody. A deadline above its default is a named loosening. The
limits are in [CONNECTIONS.md](../docs/CONNECTIONS.md), the Active Directory rows of Table B.
(vault `BACKLOG #3696` items 3 and 4)
8 changes: 7 additions & 1 deletion docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -755,13 +755,14 @@ document ([SECURITY-DOCS-POLICY.md](SECURITY-DOCS-POLICY.md)).
| `ad_bind_dn` | str | — | service-account DN used for lookups. It must be able to read each user's `userAccountControl`: an account whose value it cannot read is refused at sign-in (BACKLOG #1639). The session reconciler reads it as **undetermined**, not absent. One undetermined answer strikes the way a disabled account does, but only when it is the only one the reconciler knows of across its rotation, the same pass read the attribute on another account, and no earlier wave has latched the hold. Otherwise the undetermined accounts are held: their sessions are not revoked and their strikes reset. Two or more known at once latch the hold, which then holds even a single one until none remains. The rest of the pass goes ahead, subject to the mass-revoke breaker ([ADR 0195](adr/0195-brake-the-ad-session-reconciler-on-an-undetermined-useraccountcontrol-wave.md)). |
| `ad_bind_password` | secret | — | supply via env (`MEFOR_AUTH_AD_BIND_PASSWORD`), or use `ad_bind_password_secret`. A value in the file is still accepted, with a WARNING at load; keep it out of the file |
| `ad_bind_password_secret` | str | — | connector `SecretProvider` reference (ADR 0019 §5) — when set and `[secrets].provider` is configured, the bind password is resolved from that backend (e.g. a Vault KV `path#field`) instead of `ad_bind_password`. A reference, not a secret. |
| `ad_use_nested_groups` | bool | `true` | resolve nested groups (`LDAP_MATCHING_RULE_IN_CHAIN`) |
| `ad_use_nested_groups` | bool | `true` | resolve nested groups (`LDAP_MATCHING_RULE_IN_CHAIN`). The search is paged, with a deadline and a cap on groups; [CONNECTIONS.md](CONNECTIONS.md#table-b--per-service-resource-strategy-asvs-1313), the Active Directory row, gives its bounds |
| `ad_tls_verify` | bool | `true` | validate the LDAPS certificate. `false` is refused at start, with no escape, because every bind carries a password ([why](SECURITY-LOOSENING.md#no-escape-sends-a-credential-to-an-unverified-peer)). For a private CA, set `ad_tls_ca_cert_file` |
| `ad_tls_ca_cert_file` | str | — | trust an internal CA for LDAPS without disabling verification. Read once at start, and every LDAPS bind trusts those bytes. **A changed file takes effect at the next restart.** The PEM rules, and what a reload does, are under `[api].tls_client_ca_file` (BACKLOG #2185) |
| `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). Inert under `[security].enforcement = enforce`; see its [SECURITY-LOOSENING.md](SECURITY-LOOSENING.md) entry |
| `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_group_search_deadline` | float | `120.0` | seconds — the one deadline all the pages of the nested group search share (vault BACKLOG #3696). Each page's reads are still bounded by `ad_receive_timeout`, so a directory that stops answering fails at that, unless less than that is left of the deadline; this refuses a search whose pages keep arriving. The first page is never cut short by it. Same validation, up to `3600`, and a value above `120` is a [loosening](SECURITY-LOOSENING.md#authad_connect_timeout-ad_receive_timeout-or-ad_group_search_deadline-above-its-default-with-ad_enabled--a-stalled-directory-call-holds-a-thread-longer). It is not a hard wall-clock bound. A search refused at it fails that directory call, at least a sign-in, a step-up or a session recheck, and the session reconciler pages once for that account; [CONNECTIONS.md](CONNECTIONS.md#table-b--per-service-resource-strategy-asvs-1313), the Active Directory rows, says what it covers and what it does not |
| `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. A pass revokes no more accounts than it probed. So at or below `ad_session_revoke_max`, the breaker can never trip. With AD on and a non-zero interval, the engine then logs a **WARNING** at start that names both settings. It still loads, and revocation still works. To arm the breaker, raise this budget above the cap. |
Expand Down Expand Up @@ -862,6 +863,11 @@ source `directory-reconciler-probe-fault` (BACKLOG #3100). The reconciler raises
which a probe fails to read an entry. Nothing resolves it automatically. Resolve it by hand once the
WARNING lines that name each faulting account stop.

A paging refusal opens one `ad_reconcile_aborted` instance per account, under the source
`directory-reconciler-paging:<username>` (vault BACKLOG #3696). Nothing resolves it automatically.
Resolve it by hand once that account no longer hits the bound, for example once its sign-in
succeeds again.

A pass judges only accounts that hold a session, and nothing reads an account again once it has
left. An account leaves at least when it signs out, reaches the session cap, is revoked by the
reconciler, is disabled locally, or is deleted, and when the hourly session reaper deletes an idle
Expand Down
4 changes: 2 additions & 2 deletions docs/CONNECTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4006,8 +4006,8 @@ reading this page already applies to a file the scan never opened.
| Store — SQLite (`[store].backend = sqlite`) | `PRAGMA busy_timeout` 5000 ms on the writer and on every read-pool connection; **the pool borrow itself carries no timeout**; no network timeout applies | connections are closed on store close | a busy database retries inside the store layer | n/a |
| Store — SQL Server (`[store].backend = sqlserver`) | three distinct bounds: `[store].connect_timeout` 15 s (**login**, passed as pyodbc `timeout=`), `[store].command_timeout` 30 s (**statement**, applied per acquire as the pyodbc connection attribute) and `[store].acquire_timeout` 30 s (the **pool borrow**); `[store].warm_pool_timeout` 15 s bounds the warm-up | every acquire releases back to the pool; the pool is closed on shutdown; a borrow the pool satisfies after the borrower gave up is released back rather than stranded | a driver error — or a `StoreAcquireTimeout` — propagates to the stage worker and the row stays claimable | stage handoffs re-run idempotently; `reset_stale_inflight` recovers on restart |
| Store — Postgres (`[store].backend = postgres`) | `[store].connect_timeout` 15 s (`create_pool(timeout=…)`), `[store].command_timeout` 30 s as `asyncpg`'s per-statement bound, and `[store].acquire_timeout` 30 s on the message-pipeline pool borrows (see the coverage note above) | as SQL Server | as SQL Server | as SQL Server |
| Active Directory — login binds (`[auth].ad_server`) | `[auth].ad_connect_timeout` 10 s on the LDAP TCP connect and `[auth].ad_receive_timeout` 10 s on every LDAP response read — threaded into **every** `ldap3` `Server`/`Connection` construction | the service-account connection is context-managed; the user bind is unbound in a `finally`, so a **rejected** password releases it too (the common adversarial case) | fails closed with `LdapError`; the login is rejected and audited | **single-shot** — two binds, no retry loop |
| Active Directory — session reconciler (`ad_session_recheck_seconds`) | the same `ad_connect_timeout` / `ad_receive_timeout` | as the login path — context-managed connections | a pass that fails is retried on the next interval; strike state is process-local | **single-shot per pass**; `ad_session_recheck_strikes` (2) required before a revoke |
| Active Directory — login binds (`[auth].ad_server`) | `[auth].ad_connect_timeout` 10 s on the LDAP TCP connect and `[auth].ad_receive_timeout` 10 s on every LDAP response read — threaded into **every** `ldap3` `Server`/`Connection` construction. The nested group search is paged, and **all its pages share one `[auth].ad_group_search_deadline`** 120 s: each socket read of a page after the first waits at most the shorter of `ad_receive_timeout` and what was left when the page was asked for, no page is asked for once it is spent, and the abandon of a refused search gets only what is left (vault BACKLOG #3696). **It is not a hard wall-clock bound**: the cap is set once per page and applies to each read, so a page that arrives over several reads, each inside that cap, can hold the search up to the number of reads times what was left. The page size is the directory's choice, since it may ignore the 500 the engine asks for | the service-account connection is context-managed; the user bind is unbound in a `finally`, so a **rejected** password releases it too (the common adversarial case) | fails closed with `LdapError`; the login is rejected and audited, and so is a group search past its deadline or past 10,000 groups, whatever page size the directory returns (one WARNING per process for each) | **single-shot** — two binds, no retry loop; the group search's pages are one search, not retries |
| Active Directory — session reconciler (`ad_session_recheck_seconds`) | the same `ad_connect_timeout` / `ad_receive_timeout` / `ad_group_search_deadline` | as the login path — context-managed connections | a pass that fails is retried on the next interval; strike state is process-local. A group search refused for its deadline, its 10,000-group cap or its page backstop reads that account as unavailable on every pass. A disable or deletion still revokes it, because the user search runs first, but its roles and channel scope are not re-diffed while that lasts. So it pages, as `ad_reconcile_aborted` under the source `directory-reconciler-paging:<username>`, and audits `auth.ad_reconcile_paging_refused`, once as the account enters that state, never on every pass. It pages again only after a pass judges the account, which an outage, a referral or a probe fault does not, or after a restart (vault BACKLOG #3696). A plain outage still pages nobody | **single-shot per pass**; `ad_session_recheck_strikes` (2) required before a revoke |
| Kerberos / SPNEGO SSO (`kerberos_spn`) | **none engine-owned** — the OS provider owns any KDC timeout | the SPNEGO context is per-request | a failed step raises `LdapError` and audits a login reject | **single-shot**, single-leg — no NTLM fallback, no multi-leg handshake |
| OIDC IdP — token endpoint (`oidc_token_endpoint`) | 10 s — `auth/oidc/flow.py`'s **own** `exchange_code(timeout=…)` default (`AuthService._oidc_exchange` passes none). The JWKS leg's 10 s is a **separate** literal, `oidc_http.DEFAULT_IDP_TIMEOUT_SECONDS`; the two coincide but are independent | the response is context-managed; the body is size-capped | a non-200 or oversize body fails the login closed | **single-shot** — one POST per login |
| OIDC IdP — JWKS fetch (`oidc_jwks_uri`) | 10 s — `oidc_http.DEFAULT_IDP_TIMEOUT_SECONDS`, the constant `jwks_fetcher` is the only consumer of | the response is context-managed; the size cap is enforced on the socket read | a fetch failure fails verification closed | **single-shot**, further damped by the refetch floor |
Expand Down
Loading
Loading