b33f0f93c9
- Over-length key records (key_id > 64, prefix > 16, display name > 256) are refused with ArgumentException before connecting; an over-64 key-id lookup or mutation answers not-found without querying (SqlClient silently truncated the sized parameter, so a different id sharing the first 64 characters matched). - Every key_id predicate adds an exact VARBINARY comparison, so lookups are case- and trailing-space-exact like SQLite; the PK seek is unchanged and there is no schema change. - SqliteApiKeyStoreImporter reports case-only key-id collisions and over-length rows in new init lists (KeyIdCollisions, KeysRejected) instead of counting them as skips or importing them truncated. - Audit text truncation kept deliberately and documented. Offline refused-port tests and live SQL Server 2022 tests fail pre-fix; SqlServer suite 101/101 live. Auth findings now 0 open; CLAUDE.md records the #46/#50 work; code-reviews/README.md regenerated. (scadaproj#46) Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
562 lines
38 KiB
Markdown
562 lines
38 KiB
Markdown
# Code Review — Auth
|
||
|
||
| Field | Value |
|
||
|-------|-------|
|
||
| Library | `ZB.MOM.WW.Auth/` |
|
||
| Packages | `ZB.MOM.WW.Auth.Abstractions`, `ZB.MOM.WW.Auth.Ldap`, `ZB.MOM.WW.Auth.ApiKeys`, `ZB.MOM.WW.Auth.AspNetCore` |
|
||
| Component spec | `components/auth/spec/SPEC.md` |
|
||
| Shared contract | `components/auth/shared-contract/ZB.MOM.WW.Auth.md` |
|
||
| Status | Reviewed |
|
||
| Last reviewed | 2026-11-09 (re-review; baseline 2026-06-01 at `5f75cd4`) |
|
||
| Reviewer | Claude (2026-11-09 re-review, scadaproj#15; baseline: Claude automated) |
|
||
| Commit reviewed | `91fb2b4` |
|
||
| Open findings | 0 |
|
||
|
||
## Summary
|
||
|
||
**2026-11-09 re-review (`91fb2b4`, scadaproj#15).** Re-reviewed at `91fb2b4` (on-disk version `0.4.3`; the feed carries `0.4.1`), covering everything added
|
||
since the `5f75cd4` baseline: LDAP `FallbackServers` failover with a sticky preferred endpoint (0.2.0), the AD
|
||
continuation-referral skip (0.2.1), the new `ZB.MOM.WW.Auth.ApiKeys.SqlServer` package (stores, migrator that
|
||
copes with concurrent boots, SQLite importer, `AddZbApiKeyAuthSqlServer`) (0.3.0), opt-in LDAP user bind
|
||
(`BindMode=User` / `UserBindTemplate` / `AcceptDomainQualifiedNames`) (0.4.0), and the canonical username plus
|
||
empty-password refusal (0.4.1). Auth-001..006 remain correctly resolved, and the new code holds up on its main
|
||
security points:
|
||
- **One connection per attempt.** Every endpoint attempt gets a fresh `ILdapConnection`, disposed by `using` on
|
||
every path, including failover.
|
||
- **Failover is system-side only.** A `BadCredentials`/49 answer is final and is never replayed against another
|
||
DC.
|
||
- **Empty passwords are refused before any network call in both modes**, which closes the unauthenticated-bind
|
||
hole.
|
||
- **Filter values are still RFC 4515-escaped.**
|
||
- **The SQL Server store is parameterised throughout.** The schema name passes a strict ASCII allow-list
|
||
re-checked at the public DDL entry point.
|
||
- **Concurrent first-boot migration converges.** The retry on 1205/2714/1913/2627 with jitter works: the live
|
||
suite passes 92/92 against a throwaway SQL Server 2022, and the DBA script plus a row-only login migrate cleanly.
|
||
- **The SQL Server DI extension has container-building tests.**
|
||
|
||
The main new issue is in user-bind mode. The account that binds and the entry whose groups and username are
|
||
returned are found independently: the first from `UserBindTemplate`, the second from a `UserNameAttribute`
|
||
search. Nothing checks that they are the same object, so a UPN template, which the validator's own error text
|
||
offers first, can sign one user in with another user's groups (Auth-007). The remaining findings are Low:
|
||
- **Silent truncation in the SQL Server store.** The store sets explicit `SqlParameter` sizes and uses
|
||
case-insensitive key ids, so it does not match SQLite's semantics (Auth-008).
|
||
- **Username from a multi-valued attribute.** The canonical username takes the first value of a possibly
|
||
multi-valued attribute (Auth-009).
|
||
- **Store faults escape the verifier.** `ApiKeyVerifier` still lets store exceptions out, which matters more now
|
||
that the store can be a network database (Auth-010).
|
||
|
||
Offline suite: 331 passed / 76 skipped (live-gated). With `AUTH_SQLSERVER_CONNSTR` set, the SqlServer suite
|
||
runs 92/92.
|
||
|
||
**2026-06-01 baseline (`5f75cd4`).** The security-critical core of this library is sound: API-key secrets are 32-byte CSPRNG values,
|
||
hashing is HMAC-SHA256 keyed by an external pepper, verification uses
|
||
`CryptographicOperations.FixedTimeEquals`, the SQLite stores use parameterized SQL throughout,
|
||
LDAP filter values are RFC 4515-escaped before being interpolated into the search filter, group-DN
|
||
parsing is RFC 4514 escape-aware, and the LDAP service is genuinely fail-closed (never throws,
|
||
requires exactly one match plus a verified password plus >=1 group for success). Test coverage is
|
||
strong and contract-focused (~172 tests; every failure mode, the parser, the hasher, the escaper,
|
||
the stores, the migrator and the DI wiring are exercised). The findings below are about
|
||
robustness-around-the-edges and documentation, not the cryptographic core. The two themes are
|
||
(1) two narrow paths that can turn a recoverable condition into a thrown exception on the
|
||
authentication hot path, contradicting the library's own "only exception path is cancellation" /
|
||
"fail fast at startup" promises, and (2) public-facing documentation (README) that misstates the
|
||
hashing algorithm and the AspNetCore surface. The library is **not yet adopted** by the three apps
|
||
(tracked in `components/auth/GAPS.md`).
|
||
|
||
## Checklist coverage
|
||
|
||
2026-11-09 re-review at `91fb2b4`:
|
||
|
||
| # | Category | Examined | Notes |
|
||
|---|----------|----------|-------|
|
||
| 1 | Correctness & logic bugs | ☑ | Failover walk, sticky index, referral skip, `LdapUserName` normaliser and the expiry check are correct. See Auth-008 (silent truncation / case-insensitive key ids diverge from SQLite) and Auth-009 (canonical username = first value of a multi-valued attribute) |
|
||
| 2 | Public API surface & compatibility | ☑ | `IApiKeyStoreMigrator` seam is clean and the old `SqliteAuthStoreMigrator` ctor is kept. Two changes are 0.x source/binary breaks with no other effect: the new `IApiKeyAdminStore` members and the optional parameter on `ApiKeyRecord`. No issues found |
|
||
| 3 | Concurrency & thread safety | ☑ | The sticky `_preferredIndex` uses `Volatile`, so its race is benign. The SQL Server migrator's retry with jitter converged under the live 8-way test. Stores open one connection per operation. No issues found |
|
||
| 4 | Error handling & resilience | ☑ | `LdapAuthService` stays fail-closed on every stage and `MarkUsedAsync` is best-effort (Auth-002 holds). See Auth-010: store lookup faults (e.g. `SqlException` on a shared-DB outage) still escape `ApiKeyVerifier`, despite its "only cancellation" contract |
|
||
| 5 | Security & secret handling | ☑ | Empty passwords are refused in both modes, BadCredentials is never failed over, filter escaping is intact, SQL is parameterised and the schema identifier is allow-listed. See Auth-007 (user-bind mode reads a different entry from the one that bound). DN templates are not escaped, but README documents this |
|
||
| 6 | Performance & resource management | ☑ | `ILdapConnection` is disposed via `using` inside the per-attempt try (every path, incl. failover). SQL connections, commands and readers use `await using`, and `OpenConnectionAsync` disposes on a failed open. No issues found |
|
||
| 7 | Spec & shared-contract adherence | ☑ | SQL Server rows mirror SQLite column-for-column, apart from Auth-008. The `IApiKeyVerifier` "only cancellation" contract is violated (Auth-010) |
|
||
| 8 | Packaging, dependencies & project layout | ☑ | New SqlServer package is cleanly split and depends only on Abstractions-level `Microsoft.Extensions.*`. The Pkcs/SQLitePCLRaw transitive pins are present. No issues found |
|
||
| 9 | Testing coverage | ☑ | Strong: failover, user-bind, referral, normaliser, validator, live SQL Server suites, plus DI tests that build containers for `AddZbApiKeyAuthSqlServer`, `AddZbApiKeyAuth` and `AddZbLdapAuth`. Gaps: no test ties the bound identity to the returned entry (Auth-007), no over-length or case-variant key-id test (Auth-008), no multi-valued username attribute test (Auth-009), no store-throws verifier test (Auth-010) |
|
||
| 10 | Documentation & XML docs | ☑ | Thorough. `UserBindTemplate` docs warn about UPN mismatch, but the validator message offers the UPN form first (Auth-007). `ApiKeyVerifier` remark overstates its exception contract (Auth-010) |
|
||
|
||
<details><summary>2026-06-01 baseline checklist (<code>5f75cd4</code>)</summary>
|
||
|
||
| # | Category | Examined | Notes |
|
||
|---|----------|----------|-------|
|
||
| 1 | Correctness & logic bugs | ☑ | Core logic correct; see Auth-003 (JSON parse can throw on hot path), Auth-005 (KeyPrefix value inconsistency) |
|
||
| 2 | Public API surface & compatibility | ☑ | Surface is minimal, sealed records, correct nullable annotations, internal seams hidden via `InternalsVisibleTo`. No issues found |
|
||
| 3 | Concurrency & thread safety | ☑ | Options/records immutable; services stateless singletons; fresh SQLite connection per op with WAL + busy_timeout. No issues found |
|
||
| 4 | Error handling & resilience | ☑ | LDAP path fully fail-closed; but see Auth-001 (no `ValidateOnStart`), Auth-002 (`MarkUsedAsync` failure fails a valid auth), Auth-003 |
|
||
| 5 | Security & secret handling | ☑ | Peppered HMAC-SHA256 + FixedTimeEquals, CSPRNG secrets, parameterized SQL, LDAP escaping, hash-free list projection, secrets never logged. See Auth-006 (TLS cert-validation hook) |
|
||
| 6 | Performance & resource management | ☑ | Connections disposed via `await using`; `LdapConnection` disposed; no hot-path allocs of note. No issues found |
|
||
| 7 | Spec & shared-contract adherence | ☑ | Matches SPEC §1–§5 closely (HMAC-SHA256 pepper, constant-time compare, discriminated failures, cookie hardening). See Auth-001 (insecure-refusal deferred to first use) |
|
||
| 8 | Packaging, dependencies & project layout | ☑ | Clean 4-package split, central versions, minimal deps, Abstractions has no runtime deps. No issues found |
|
||
| 9 | Testing coverage | ☑ | Broad and contract-level; gaps: no test for corrupt-`scopes` deserialization (Auth-003) or `MarkUsedAsync`-throws (Auth-002); `ValidateOnStart` behavior untested (Auth-001) |
|
||
| 10 | Documentation & XML docs | ☑ | XML docs thorough and accurate; README is stale — see Auth-004 |
|
||
|
||
</details>
|
||
|
||
## Findings
|
||
|
||
### Auth-001 — LDAP options validator is registered but never runs at startup
|
||
|
||
| | |
|
||
|--|--|
|
||
| Severity | Medium |
|
||
| Category | Error handling & resilience |
|
||
| Status | Resolved |
|
||
| Location | `ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.AspNetCore/ServiceCollectionExtensions.cs:40` |
|
||
|
||
**Description**
|
||
|
||
`AddZbLdapAuth` binds `LdapOptions` with `services.Configure<LdapOptions>(...)` and registers the
|
||
validator with a plain `services.AddSingleton<IValidateOptions<LdapOptions>, LdapOptionsValidator>()`.
|
||
It never calls `OptionsBuilder.ValidateOnStart()`. With `Microsoft.Extensions.Options`, an
|
||
`IValidateOptions<T>` runs only when the options instance is first materialized (`IOptions<T>.Value`),
|
||
not at host startup. Here the only consumer that materializes the options is the `ILdapAuthService`
|
||
singleton factory (line 48), whose lambda runs on first resolution of `ILdapAuthService` — i.e. on
|
||
the first login attempt, not at boot.
|
||
|
||
The result contradicts the explicit promise in both the method comment ("Fail fast at startup on a
|
||
misconfigured directory rather than on first login") and the validator's own XML doc ("Validates
|
||
`LdapOptions` at startup so a misconfiguration fails fast at boot"). It also weakens SPEC §2 step 1,
|
||
which says an insecure transport without `AllowInsecure` must be *refused* — that refusal is silently
|
||
deferred until a user tries to log in. A process can therefore boot green with `Transport=None` and
|
||
no `AllowInsecure`, and only surface the misconfiguration as a failed login later. Because this is a
|
||
shared library consumed by three hosts, the missing guard affects all of them.
|
||
|
||
**Recommendation**
|
||
|
||
Register options via the builder and add `ValidateOnStart`:
|
||
|
||
```csharp
|
||
services.AddOptions<LdapOptions>()
|
||
.Bind(config.GetSection(sectionPath))
|
||
.ValidateOnStart();
|
||
services.AddSingleton<IValidateOptions<LdapOptions>, LdapOptionsValidator>();
|
||
```
|
||
|
||
(`ValidateOnStart` relies on the host's start-time options validation, which the ASP.NET Core hosts
|
||
already run.) Add a test asserting that building + starting the host fails for an insecure config.
|
||
|
||
**Resolution**
|
||
|
||
Resolved in `544a6dd`, 2026-06-01 — `AddZbLdapAuth` now binds via `AddOptions<LdapOptions>().Bind(...).ValidateOnStart()` so the validator runs at host start, not on first login (test: `ServiceCollectionExtensionsTests.AddZbLdapAuth_StartingHost_FailsForInsecureConfig`, plus a `...SucceedsForSecureConfig` companion).
|
||
|
||
### Auth-002 — A failed `MarkUsedAsync` write turns a valid API key into a thrown exception
|
||
|
||
| | |
|
||
|--|--|
|
||
| Severity | Medium |
|
||
| Category | Error handling & resilience |
|
||
| Status | Resolved |
|
||
| Location | `ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/ApiKeyVerifier.cs:66` |
|
||
|
||
**Description**
|
||
|
||
After a successful constant-time secret match, `VerifyAsync` does
|
||
`await store.MarkUsedAsync(record.KeyId, _timeProvider.GetUtcNow(), ct)` before returning the
|
||
success result. `MarkUsedAsync` is a best-effort "last used" bookkeeping write. If that write fails
|
||
(SQLite still `SQLITE_BUSY` after the 5 s busy-timeout, disk full, DB locked by a long migration,
|
||
etc.), the exception propagates out of `VerifyAsync` and the otherwise-valid credential fails
|
||
authentication. The verifier's own class remark states "The only exception path is cancellation",
|
||
so callers (gRPC interceptors / middleware) will not be wrapping this in a try/catch and a transient
|
||
storage hiccup becomes an auth outage for a legitimate caller.
|
||
|
||
**Recommendation**
|
||
|
||
Treat the usage write as best-effort: either swap the order (return success, then fire-and-record),
|
||
or wrap the `MarkUsedAsync` call so a failure is logged/swallowed rather than failing the
|
||
verification — the authentication decision has already been made by line 60. If `MarkUsedAsync` is
|
||
allowed to throw, update the XML remark to say so. Add a test where the store's `MarkUsedAsync`
|
||
throws and assert the result is still `Succeeded == true`.
|
||
|
||
**Resolution**
|
||
|
||
Resolved in `544a6dd`, 2026-06-01 — `VerifyAsync` now wraps the best-effort `MarkUsedAsync` write in try/catch, re-throwing only `OperationCanceledException` and swallowing all other failures so a valid key still authenticates (tests: `ApiKeyVerifierTests.VerifyAsync_ValidKey_MarkUsedThrows_StillSucceeds` and `..._MarkUsedThrowsOperationCanceled_Propagates`).
|
||
|
||
### Auth-003 — Corrupt `scopes`/`constraints` column throws `JsonException` through the verifier
|
||
|
||
| | |
|
||
|--|--|
|
||
| Severity | Medium |
|
||
| Category | Correctness & logic bugs |
|
||
| Status | Resolved |
|
||
| Location | `ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/ScopeSerializer.cs:31`, `ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteApiKeyStore.cs:71` |
|
||
|
||
**Description**
|
||
|
||
`SqliteApiKeyStore.ReadRecord` calls `ScopeSerializer.Deserialize(reader.GetString(4))`, which does
|
||
`JsonSerializer.Deserialize<string[]>(value)`. If the `scopes` column ever holds non-array or
|
||
malformed JSON (operator tampering, a partial write, a future schema/format change, or a bug in
|
||
another writer), `Deserialize` throws `JsonException`. That exception propagates out of
|
||
`FindByKeyIdAsync`, and therefore out of `ApiKeyVerifier.VerifyAsync`, as an unhandled exception —
|
||
again contradicting the "only exception path is cancellation" contract and converting a single
|
||
poisoned row into a crash on the auth path rather than a structured fail-closed result. The same
|
||
applies to the admin `ListAsync` projection.
|
||
|
||
**Recommendation**
|
||
|
||
Decide the policy explicitly. Either (a) catch `JsonException` in `ScopeSerializer.Deserialize` and
|
||
treat a malformed column as an empty/invalid scope set so verification fails closed structurally, or
|
||
(b) catch it at the store boundary and surface it as a controlled error. Add a test that inserts a
|
||
row with a malformed `scopes` value and asserts the verifier returns a structured failure (or a
|
||
documented exception) rather than an unhandled `JsonException`.
|
||
|
||
**Resolution**
|
||
|
||
Resolved in `544a6dd`, 2026-06-01 — `ScopeSerializer.Deserialize` now catches `JsonException` and fails closed to an empty scope set (policy (a)), so a corrupt `scopes`/`constraints` column degrades to a zero-scope identity instead of throwing through the store and verifier (tests: `SqliteApiKeyStoreTests.ScopeSerializer_DeserializeMalformed_ReturnsEmptySet_DoesNotThrow` (Theory) and `..._FindByKeyId_CorruptScopesColumn_ReturnsRecordWithEmptyScopes_DoesNotThrow`).
|
||
|
||
### Auth-004 — README misstates the hashing algorithm and the AspNetCore public surface
|
||
|
||
| | |
|
||
|--|--|
|
||
| Severity | Low |
|
||
| Category | Documentation & XML docs |
|
||
| Status | Resolved |
|
||
| Location | `ZB.MOM.WW.Auth/README.md:13`, `ZB.MOM.WW.Auth/README.md:14` |
|
||
|
||
**Description**
|
||
|
||
The README's package table contains two inaccuracies that matter for a security library:
|
||
|
||
1. It describes `ZB.MOM.WW.Auth.ApiKeys` as a store "with pepper-based **PBKDF2** hashing". The code
|
||
uses **HMAC-SHA256** keyed by the pepper (`ApiKeySecretHasher.Hash` → `new HMACSHA256(pepperBytes)`),
|
||
not PBKDF2. This is the correct algorithm per SPEC §4 ("HMAC-SHA256 with an external pepper"), so
|
||
the *code* is right and the *README* is wrong — but a reader auditing the crypto will be misled
|
||
about iteration count / KDF properties that do not exist here.
|
||
2. It says `ZB.MOM.WW.Auth.AspNetCore` provides "ASP.NET Core DI helpers (`AddZbAuth`) ... and
|
||
`LdapOptionsValidator` registration. **Wires together Ldap + ApiKeys + cookie middleware**." The
|
||
actual public surface is a single extension method `AddZbLdapAuth` (LDAP only). There is no
|
||
`AddZbAuth`, no API-key re-export from this package, and no cookie-middleware wiring —
|
||
`ZbCookieDefaults.Apply` is a helper the consumer must call itself, and API-key DI lives in the
|
||
separate `ZB.MOM.WW.Auth.ApiKeys` package (`AddZbApiKeyAuth`). The shared contract
|
||
(`ZB.MOM.WW.Auth.md`) likewise lists a combined DI shape that the code splits differently.
|
||
|
||
**Recommendation**
|
||
|
||
Fix the README table: change "PBKDF2" to "HMAC-SHA256", and correct the AspNetCore row to describe
|
||
the real surface (`AddZbLdapAuth`, `ZbCookieDefaults.Apply`, `ZbClaimTypes`) and note that API-key
|
||
wiring is `AddZbApiKeyAuth` in the ApiKeys package. Optionally reconcile the shared-contract doc's
|
||
DI-method names with the shipped names.
|
||
|
||
**Resolution**
|
||
|
||
Resolved in `544a6dd`, 2026-06-01 — README package table corrected: "PBKDF2" → "pepper-keyed HMAC-SHA256" and the AspNetCore row rewritten to the real surface (`AddZbLdapAuth`, `ZbCookieDefaults.Apply`, `ZbClaimTypes`, LDAP-only, no API-key/cookie-middleware wiring), with the stale `ApiKeys` dependency dropped from that row to match the `.csproj` (test: doc-only).
|
||
|
||
### Auth-005 — `CreateKeyAsync` persists `KeyPrefix` as `prefix_keyId`, inconsistent with the read path
|
||
|
||
| | |
|
||
|--|--|
|
||
| Severity | Low |
|
||
| Category | Correctness & logic bugs |
|
||
| Status | Resolved |
|
||
| Location | `ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Admin/ApiKeyAdminCommands.cs:104` |
|
||
|
||
**Description**
|
||
|
||
When creating a key, `KeyPrefix` is set to `$"{_options.TokenPrefix}_{keyId}"` (e.g. `mxgw_key-1`),
|
||
embedding the key id into the column. Elsewhere `KeyPrefix` is treated as the bare token prefix:
|
||
the verifier test (`ApiKeyVerifierTests.BuildRecord`) and the store test
|
||
(`SqliteApiKeyStoreTests.SampleRecord`) seed it as `"mxgw"` / `"mxgw_ab12"`, and the `ApiKeyRecord`
|
||
contract gives no indication it should include the key id. `KeyPrefix` is not consulted during
|
||
verification (the token is re-parsed from the header), so there is no security impact — but the
|
||
persisted/displayed value is inconsistent and self-referential (the key id is already its own
|
||
column), which will confuse admin tooling that surfaces `KeyPrefix`.
|
||
|
||
**Recommendation**
|
||
|
||
Persist `KeyPrefix = _options.TokenPrefix` (the bare prefix) and let the key id remain the `KeyId`
|
||
column, or document precisely what `KeyPrefix` is meant to contain on the `ApiKeyRecord` /
|
||
`ApiKeyListItem` records and align all call sites to it.
|
||
|
||
**Resolution**
|
||
|
||
Resolved in `544a6dd`, 2026-06-01 — `CreateKeyAsync` now persists `KeyPrefix = _options.TokenPrefix` (the bare prefix) instead of `prefix_keyId`, aligning the write path with the read/test paths (test: `ApiKeyAdminCommandsTests.CreateKey_PersistsBareTokenPrefix_NotPrefixUnderscoreKeyId`).
|
||
|
||
### Auth-006 — LDAP connection ignores `allowInsecure` and offers no TLS certificate-validation hook
|
||
|
||
| | |
|
||
|--|--|
|
||
| Severity | Low |
|
||
| Category | Security & secret handling |
|
||
| Status | Resolved |
|
||
| Location | `ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/Internal/NovellLdapConnection.cs:16` |
|
||
|
||
**Description**
|
||
|
||
`NovellLdapConnection.Connect` receives `bool allowInsecure` but never uses it; transport-security
|
||
correctness rests entirely on `LdapOptionsValidator` having run beforehand (and that registration is
|
||
itself lazy — see Auth-001). The method also sets `SecureSocketLayer`/`StartTls` but never configures
|
||
a server-certificate validation delegate or any pinning/CA hook. The underlying
|
||
`Novell.Directory.Ldap.NETStandard` 3.6.0 validates against the OS trust store by default (it does
|
||
*not* blind-accept), so this is not an unauthenticated-TLS hole today — hence Low — but the library
|
||
gives a consumer no seam to tighten (pin a CA, validate the SAN against `Server`, or deliberately
|
||
accept a self-signed dev cert), and the unused parameter reads as a guard that does nothing.
|
||
|
||
**Recommendation**
|
||
|
||
Either remove the unused `allowInsecure` parameter from the `ILdapConnection.Connect` seam or use it
|
||
to gate an explicit "accept invalid cert" path for dev only. Add an optional
|
||
`RemoteCertificateValidationCallback` (or CA-pinning option) so production consumers can harden LDAPS
|
||
beyond the OS default, and document the default validation behavior.
|
||
|
||
**Resolution**
|
||
|
||
Resolved in `544a6dd`, 2026-06-01 — added an optional `LdapOptions.ServerCertificateValidationCallback` (BCL `RemoteCertificateValidationCallback`, no new dependency) threaded through the `ILdapConnection.Connect` seam; `NovellLdapConnection` now builds `LdapConnectionOptions.ConfigureRemoteCertificateValidationCallback(...)` (a supplied callback wins; otherwise the previously-dead `allowInsecure` gates a dev-only accept-any path; otherwise OS-trust-store default), and the default validation behaviour is documented in XML/README (tests: `LdapAuthServiceTests.Connect_ReceivesConfiguredCertValidationCallback`, `..._Connect_ReceivesAllowInsecureFlag_FromOptions`, `..._Connect_NoCertCallbackConfigured_PassesNull`).
|
||
|
||
<!-- 2026-11-09 re-review (scadaproj#15) findings below. -->
|
||
|
||
### Auth-007 — User-bind mode never checks that the entry whose groups it returns is the account that bound
|
||
|
||
| | |
|
||
|--|--|
|
||
| Severity | Medium |
|
||
| Priority | P2 |
|
||
| Category | Security & secret handling |
|
||
| Status | Resolved |
|
||
| Location | `ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs:329` |
|
||
|
||
**Description**
|
||
|
||
`CompleteAsUser` authenticates by binding as `UserBindTemplate.Replace("{0}", username)` (line 329–332). It then
|
||
runs a separate search, `(<UserNameAttribute>=<username>)` (line 345 → `SearchUser`, line 361), and returns the
|
||
groups and username of whatever single entry that search finds (line 349 → `SucceedFromEntry`). Two different
|
||
directory lookups resolve the bind name and the search filter. Nothing checks that the entry found is the account
|
||
the directory just authenticated. There is no comparison of DNs, no RFC 4532 "Who am I?", and no search keyed on
|
||
the bind identity. The XML doc and README describe the UPN mismatch as guidance only ("a mismatch can … bind a
|
||
different account than the one whose groups are read"). The validator's failure message
|
||
(`LdapOptionsValidator.cs:77`) offers `"{0}@example.com"` as its first example template. A DN-form template is also
|
||
spliced in unescaped (documented), which is another way for the bind identity and the search key to diverge.
|
||
|
||
**Failure scenario**
|
||
|
||
`BindMode=User` with `UserBindTemplate="{0}@corp.com"` and `UserNameAttribute=sAMAccountName` on AD, a combination
|
||
the validator message suggests. Mallory's account has explicit UPN `jsmith@corp.com` and `sAMAccountName=jsmith2`,
|
||
which is common after a rename or when two people share a name. A separate admin account has `sAMAccountName=jsmith`.
|
||
Mallory signs in as `jsmith` with her own password:
|
||
1. AD resolves the UPN bind to Mallory's account and accepts it.
|
||
2. The search `(sAMAccountName=jsmith)` returns the admin's entry.
|
||
3. `LdapAuthResult.Success("jsmith", "Admin Smith", ["UG_Admins"])` is returned.
|
||
|
||
That is privilege escalation with valid credentials of a different user. I reproduced this through the
|
||
`FakeLdapConnection` seam: the bind went to `jsmith@corp.test` and the result carried the other entry's groups.
|
||
|
||
**Recommendation**
|
||
|
||
After the bind, identify the entry by the bound identity, not the typed name:
|
||
- **UPN templates:** search on `userPrincipalName=<bindName>`.
|
||
- **Any template:** use the RFC 4532 Who-am-I extended operation (AD returns `u:DOMAIN\sam`) and check it against
|
||
the found entry's `sAMAccountName`.
|
||
- **DN templates:** require the found entry's DN to equal the bind DN, and DN-escape `{0}` (RFC 4514).
|
||
|
||
Fail with `GroupLookupFailed` on a mismatch. At minimum, reorder the validator example to lead with
|
||
`EXAMPLE\{0}`. Add a test where the bind name and the searched entry differ.
|
||
|
||
**Resolution**
|
||
|
||
Resolved 2026-11-09 (scadaproj#40, Auth `0.4.4`, unpublished). Re-verified first: through the fake seam, the pre-fix
|
||
service returned the searched entry's groups for a UPN bind that resolved to another account, and live against a
|
||
throwaway GLAuth (`tests/ZB.MOM.WW.Auth.Ldap.Tests/Integration/glauth-userbind/`), `mallory` bound as herself and signed
|
||
in with `boss`'s `Admins` group because `(mail=mallory)` found `boss`. `CompleteAsUser` now establishes the bound
|
||
identity and refuses any entry that is not it, with `GroupLookupFailed`:
|
||
- RFC 4532 Who-am-I (new `ILdapConnection.WhoAmI`, backed by Novell's `LdapWhoAmIOperation`). A `dn:` answer must
|
||
equal the entry's DN (`LdapDn.Equals`, `distinguishedNameMatch`). AD's `u:DOMAIN\name` answer must equal the
|
||
entry's `sAMAccountName`, which is requested automatically.
|
||
- With no Who-am-I answer (GLAuth answers protocol error 2), only a DN template is provable, and its bind DN must
|
||
equal the entry's DN. A UPN or down-level template fails closed.
|
||
- `{0}` is RFC 4514-escaped in DN templates. The validator message now leads with `EXAMPLE\{0}`.
|
||
|
||
Tests: 13 new cases in `LdapAuthServiceUserBindTests`, 11 of which fail against the pre-fix service, plus
|
||
`GLAuthUserBindIdentityTests` (live, `ZB_LDAP_USERBIND_IT=1`): the mismatch case fails pre-fix and passes after, and
|
||
the positive control passes both ways. Consumer note (README §0.4.4): a non-AD directory in `User` mode needs a DN
|
||
template that yields the entry's DN exactly as the search reports it.
|
||
|
||
### Auth-008 — SQL Server store silently truncates over-length values and matches key ids case-insensitively, diverging from SQLite
|
||
|
||
| | |
|
||
|--|--|
|
||
| Severity | Low |
|
||
| Priority | P3 |
|
||
| Category | Correctness & logic bugs |
|
||
| Status | Resolved |
|
||
| Location | `ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerApiKeyAdminStore.cs:45` (also `:46`, `:48`; `SqlServerApiKeyStore.cs:56,77`; `SqlServerApiKeyAuditStore.cs:30-32`; `SqliteApiKeyStoreImporter.cs:258-261,292-294`; columns in `SqlServerAuthSchema.cs:79-96`) |
|
||
|
||
**Description**
|
||
|
||
Every string parameter is added with an explicit size: `NVarChar, 64` for `key_id`, `event_type` and
|
||
`remote_address`, `16` for `key_prefix`, and `256` for `display_name`. `SqlParameter` truncates an input longer
|
||
than `Size` without raising an error, so over-length values are cut down rather than rejected. The columns also use
|
||
the database's default collation, which is case-insensitive on a stock SQL Server. The SQLite store this one claims
|
||
to port "one-for-one" has `TEXT` columns, no length limits and BINARY (case-sensitive) comparison. `CreateKeyAsync`
|
||
validates only that `keyId` is non-blank and has no `_`.
|
||
|
||
I verified this live against a SQL Server 2022 container:
|
||
- A 70-char `keyId` with a 20-char prefix and a 300-char display name was created without error, and the row was
|
||
stored as 64/16/256 characters.
|
||
- `FindByKeyIdAsync` with the full 70-char id, and with a different 70-char id sharing the first 64 characters,
|
||
both found the row.
|
||
- `FindByKeyIdAsync("LINE1")` found the row stored as `line1`.
|
||
- Creating `Line1` beside `line1` failed with a PK violation.
|
||
- An 80-char audit `event_type`/`remote_address` was stored as 64 characters.
|
||
|
||
**Failure scenario**
|
||
|
||
1. **Over-length key id.** An admin mints a key with a long, descriptive key id (more than 64 characters) on a host
|
||
using `AddZbApiKeyAuthSqlServer`. Creation succeeds and the returned token embeds the full id. Verification
|
||
succeeds only because the lookup parameter is truncated the same way. The authenticated `ApiKeyIdentity.KeyId`
|
||
is the 64-character stored value, not the id the admin assigned. Any host ACL or audit correlation keyed on the
|
||
assigned id never matches.
|
||
2. **Case-variant ids in the importer.** Two SQLite keys `Line1` and `line1` (legal on SQLite) migrate through
|
||
`SqliteApiKeyStoreImporter`. The second is counted as "skipped" because the `IF NOT EXISTS` check matches
|
||
case-insensitively. After cut-over its token resolves to the other key's row, fails `SecretMismatch`, and that
|
||
client is locked out with no error at import time.
|
||
|
||
**Recommendation**
|
||
|
||
- Pass `-1`/no explicit size, or validate lengths up front and throw `ArgumentException`, in both `CreateKeyAsync`
|
||
and the importer.
|
||
- Declare `key_id` with a binary or case-sensitive collation (e.g. `COLLATE Latin1_General_100_BIN2`) to match
|
||
SQLite. Alternatively, document and enforce a case-folded, `[A-Za-z0-9-]{1,64}` key-id alphabet in
|
||
`ApiKeyAdminCommands`.
|
||
- Make the importer report key-id collisions instead of counting them as plain skips.
|
||
|
||
**Resolution**
|
||
|
||
Resolved 2026-11-09 (scadaproj#46, Auth `0.4.4`, unpublished). Re-verified live against a throwaway SQL Server 2022
|
||
container (`RTM-CU22`): `FindByKeyIdAsync("LINE1")` found the row stored as `line1`, and the importer counted
|
||
`Line1` beside `line1` as a plain skip. The offline tests reproduced the truncation: an over-length create was sent
|
||
to the server. Fixed without a schema change, so the live shared store needs no migration. A new internal helper,
|
||
`SqlServerColumnLimits`, carries the rules:
|
||
- **Lengths.** `SqlServerApiKeyAdminStore.CreateAsync` throws `ArgumentException` before connecting when `KeyId` is
|
||
over 64, `KeyPrefix` over 16 or `DisplayName` over 256 characters. Every key-id lookup and mutation (`Find*`,
|
||
`MarkUsed`, `Revoke`, `Rotate`, `SetScopes`, `SetEnabled`, `Delete`) returns not-found or `false` for an id over
|
||
64 characters without querying. Before, the truncated parameter could match a different key that shares the
|
||
first 64 characters.
|
||
- **Case.** Every `WHERE key_id = @key_id` also requires `CAST(key_id AS VARBINARY(128)) = CAST(@key_id AS VARBINARY(128))`.
|
||
The collation-aware `=` still drives the primary-key seek, and the binary compare makes the match exact, like
|
||
SQLite's `BINARY`. The column collation is deliberately not changed: that would be a primary-key migration on a
|
||
live shared store. Consequence: on a case-insensitive database, `Line1` and `line1` still cannot both exist.
|
||
`CreateAsync` already fails loudly with a PK violation in that case, and the importer now reports it.
|
||
- **Importer.** The new `init` properties `ApiKeyImportResult.KeyIdCollisions` (case-only clashes with a key
|
||
already in SQL Server) and `KeysRejected` (over-length rows) list what was not imported and why. Before, both kinds
|
||
were folded into `KeysSkipped`, or imported truncated. `KeysSkipped` now counts only exact duplicates. The
|
||
insert-if-absent statement returns a three-way outcome from inside one statement, so it stays race-safe.
|
||
- **Audit text.** `key_id`, `event_type` and `remote_address` are still truncated to 64 characters. This is now
|
||
deliberate and documented on `SqlServerApiKeyAuditStore.AppendAsync`: an audit append runs after the action it
|
||
records, so refusing it would lose the record of a change that has already happened.
|
||
|
||
Tests:
|
||
- `SqlServerColumnLimitTests` (offline, refused loopback port). Over-length create is rejected for each of the three
|
||
fields, and all 8 key-id operations answer "not found" for an over-length id. These fail pre-fix with
|
||
`SqlException`. A positive control at exactly the limits still reaches the server.
|
||
- `Live/KeyIdFidelityLiveTests` (`AUTH_SQLSERVER_CONNSTR`). Case- and trailing-space-exact lookups, an importer
|
||
case-collision report, and an importer over-length rejection. All 3 fail pre-fix.
|
||
- The full SqlServer suite passes live: 101/101.
|
||
|
||
### Auth-009 — Canonical username is the first value of a possibly multi-valued attribute, not the value the user matched
|
||
|
||
| | |
|
||
|--|--|
|
||
| Severity | Low |
|
||
| Priority | P3 |
|
||
| Category | Correctness & logic bugs |
|
||
| Status | Resolved |
|
||
| Location | `ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs:437` |
|
||
|
||
**Description**
|
||
|
||
0.4.1's `ExtractUserName` returns `values[0]` of `UserNameAttribute` whenever that value is non-blank (lines
|
||
437–441). The default `UserNameAttribute` is `cn` (`LdapContracts.cs:30`), which is multi-valued in standard LDAP
|
||
schemas: OpenLDAP and 389-DS entries routinely carry both `cn: John Smith` and `cn: jsmith`. The search matched on
|
||
one of those values, but the code returns whichever value the server lists first, which need not be the one the
|
||
user typed. This is the same person and the same entry, so there is no privilege effect. However, the identity
|
||
string handed to the host changes with the 0.4.1 upgrade and depends on server attribute ordering.
|
||
|
||
**Failure scenario**
|
||
|
||
A host on the default `UserNameAttribute=cn` runs against an OpenLDAP directory where users have two `cn` values.
|
||
Before 0.4.1 `jsmith` signed in as `jsmith`. After the upgrade the result's `Username` is `John Smith`. Per-user
|
||
records, audit trails and any username-keyed role overrides in the host split into two identities, and a username
|
||
containing a space can break downstream consumers that treat it as an account name. I reproduced this through the
|
||
fake connection: typed `jsmith` returned `Username == "John Smith"`.
|
||
|
||
**Recommendation**
|
||
|
||
Prefer the attribute value that equals the typed (normalised) name under `OrdinalIgnoreCase`, which still
|
||
canonicalises case. Fall back to `values[0]` only when no value matches. Add a multi-valued-attribute test.
|
||
|
||
**Resolution**
|
||
|
||
Resolved 2026-11-09 (scadaproj#46, Auth `0.4.4`, unpublished). Re-verified through the fake connection: with
|
||
`cn: ["John Smith", "jsmith"]`, typed `jsmith` returned `Username == "John Smith"`. `ExtractUserName` now returns the
|
||
attribute value that equals the typed (normalised) name under `OrdinalIgnoreCase`. That keeps 0.4.1's case
|
||
canonicalisation, so typed `jsmith` against `JSmith` still returns `JSmith`. Only when no value matches does it fall
|
||
back to the first non-blank value, and then to the typed name, as 0.4.1 did. Both bind modes go through
|
||
`SucceedFromEntry`, so both are covered.
|
||
|
||
Tests (`LdapAuthServiceTests`):
|
||
- `Username_FromAMultiValuedAttribute_IsTheValueTheUserMatched` fails pre-fix.
|
||
- `..._StillTakesTheDirectorysCase` fails pre-fix.
|
||
- `Username_FallsBackToTheFirstValue_WhenNoValueMatchesTheTypedName` is a guard that passes both ways.
|
||
|
||
### Auth-010 — `ApiKeyVerifier` lets store exceptions escape despite its "only exception path is cancellation" contract
|
||
|
||
| | |
|
||
|--|--|
|
||
| Severity | Low |
|
||
| Priority | P3 |
|
||
| Category | Error handling & resilience |
|
||
| Status | Resolved |
|
||
| Location | `ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/ApiKeyVerifier.cs:47` (contract stated at `:13`) |
|
||
|
||
**Description**
|
||
|
||
The type remark (line 13) promises "The only exception path is cancellation". Auth-002 fixed `MarkUsedAsync` to
|
||
honour that promise, but the lookup `await store.FindByKeyIdAsync(...)` at line 47 is unguarded. Its failures
|
||
propagate unchanged. With the new `SqlServerApiKeyStore` the store is a network database shared by a cluster, so
|
||
lookup failures are an expected operating condition, not a rare local-disk fault. A misconfigured
|
||
`SqlServerApiKeyOptions` also escapes: it is validated lazily in the `AuthSqlServerConnectionFactory` constructor.
|
||
With `RunMigrationsOnStartup=false` that throws `InvalidOperationException` out of the first verifier resolution or
|
||
call.
|
||
|
||
**Failure scenario**
|
||
|
||
The shared SQL Server is briefly unreachable during a failover, or the central pair loses its route to it. Every
|
||
inbound API request's `VerifyAsync` throws `Microsoft.Data.SqlClient.SqlException`; I verified this against a closed
|
||
port. Consumers wrote their middleware and interceptors against the documented contract and map
|
||
`Succeeded == false` to 401, so they do not catch this. The request surfaces as an unhandled 500, or a gRPC
|
||
`Unknown`, instead of a clean auth failure or 503, and the failure goes unaudited.
|
||
|
||
**Recommendation**
|
||
|
||
Wrap the lookup and map non-cancellation exceptions to a structured failure, for example a new
|
||
`ApiKeyFailure.StoreUnavailable`. Otherwise, correct the remark to say store exceptions propagate. Add a test
|
||
where the store throws. Optionally add `SqlServerApiKeyOptions` startup validation
|
||
(`AddOptions().Validate(...).ValidateOnStart()`), mirroring Auth-001.
|
||
|
||
**Resolution**
|
||
|
||
Resolved 2026-11-09 (scadaproj#46, Auth `0.4.4`, unpublished). Re-verified offline: an `ApiKeyVerifier` over a real
|
||
`SqlServerApiKeyStore` that dials a refused loopback port threw `Microsoft.Data.SqlClient.SqlException` out of
|
||
`VerifyAsync`. The fix takes the review's first option. `ApiKeyFailure` gains **`StoreUnavailable`**, appended last so
|
||
the existing numeric values are unchanged. `VerifyAsync` now wraps the `FindByKeyIdAsync` lookup: an
|
||
`OperationCanceledException` is rethrown, and any other exception returns `Fail(StoreUnavailable)`. The class remark
|
||
("the only exception path is cancellation") is now true, and it names the new failure.
|
||
|
||
Consumers already map `Succeeded == false` to their auth-failure path and audit it, so a store outage becomes an
|
||
audited auth failure instead of an unaudited 500 or gRPC `Unknown`. A host that wants 503 can switch on the new value.
|
||
MxAccessGateway's `CachingApiKeyVerifier` caches only successes, so a transient `StoreUnavailable` is never cached.
|
||
|
||
The optional `SqlServerApiKeyOptions` `ValidateOnStart` was **not** added. Option validation still happens in the
|
||
`AuthSqlServerConnectionFactory` constructor, so a misconfigured section still throws when the store is first
|
||
resolved, which is outside `VerifyAsync`. Validating at start could stop an existing host that registers the package
|
||
without configuring the section from booting. Left as a follow-up candidate.
|
||
|
||
Tests:
|
||
- `ApiKeyVerifierTests.VerifyAsync_StoreLookupThrows_ReturnsStoreUnavailable_InsteadOfThrowing` is a theory over a
|
||
`DataException`, an `InvalidOperationException` and an `IOException`. All three cases fail pre-fix.
|
||
- `VerifyAsync_StoreLookupCancelled_Propagates` is a guard.
|
||
- `SqlServerUnreachableVerifierTests` (offline, refused loopback port) fails pre-fix with the `SqlException`.
|