fix(security): resolve Security-012..015 — fail login on partial LDAP outage, escape-aware DN parsing, idle check on refresh, username normalization

This commit is contained in:
Joseph Doherty
2026-05-17 03:18:33 -04:00
parent f5199e9da9
commit a58cec5776
4 changed files with 411 additions and 35 deletions

View File

@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-17 |
| Reviewer | claude-agent |
| Commit reviewed | `39d737e` |
| Open findings | 4 (1 deferred — Security-008) |
| Open findings | 0 (1 deferred — Security-008) |
## Summary
@@ -483,7 +483,7 @@ LDAP-timeout coverage (Security-009) plus extra no-service-account / DN-path edg
|--|--|
| Severity | Medium |
| Category | Error handling & resilience |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.Security/LdapAuthService.cs:78-118` |
**Description**
@@ -515,7 +515,19 @@ or be surfaced.
**Resolution**
_Unresolved._
Resolved 2026-05-17 (commit `pending`). Confirmed: the group/attribute `Search` was
wrapped in a `catch (LdapException)` that logged a warning and returned
`new LdapAuthResult(true, …, groups: [])` — a partial LDAP outage produced an
authenticated, zero-role session — and the inner `while`-loop `catch { break; }` masked
real mid-enumeration errors as end-of-results. `AuthenticateAsync` now tracks a
`groupLookupSucceeded` flag (false when any `LdapException` is thrown by the search or
`Next()`); the inner swallowing catch was removed so such errors propagate; the new
`BuildAuthResultFromGroupLookup` helper returns a FAILED `LdapAuthResult`
("directory temporarily unavailable, please try again") on lookup failure while still
treating a genuine empty-groups result as a successful login. Regression tests
`BuildAuthResultFromGroupLookup_LookupFailed_FailsTheLogin`,
`BuildAuthResultFromGroupLookup_LookupSucceededNoGroups_IsAuthenticated`,
`BuildAuthResultFromGroupLookup_LookupSucceededWithGroups_CarriesGroups`.
### Security-013 — `ExtractFirstRdnValue` mis-parses group DNs containing escaped commas
@@ -523,7 +535,7 @@ _Unresolved._
|--|--|
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.Security/LdapAuthService.cs:258-269` |
**Description**
@@ -545,7 +557,17 @@ library's DN-parsing API (`LdapDN` / RDN accessors) rather than hand-rolled `Ind
**Resolution**
_Unresolved._
Resolved 2026-05-17 (commit `pending`). Confirmed: `ExtractFirstRdnValue` took the
substring between the first `=` and the first `,`, splitting on an RFC 4514
backslash-escaped comma so a CN like `Acme\, Inc Operators` became `Acme\`. Replaced the
naive `IndexOf` scan with an escape-aware character scan that ignores a backslash-escaped
`,`, unescapes single-character and `\XX` hex escape sequences, and only terminates the
value on an unescaped comma — so a comma-containing group CN is returned intact and
matches its configured `LdapGroupName`. Method was also made `public` for direct
testing. Regression tests `ExtractFirstRdnValue_EscapedComma_KeepsWholeGroupName`,
`ExtractFirstRdnValue_PlainDn_ReturnsFirstRdnValue`,
`ExtractFirstRdnValue_SingleRdn_ReturnsValue`,
`ExtractFirstRdnValue_EscapedSpecials_AreUnescaped`.
### Security-014 — `RefreshToken` re-issues a token without checking the idle timeout
@@ -553,7 +575,7 @@ _Unresolved._
|--|--|
| Severity | Medium |
| Category | Security |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.Security/JwtTokenService.cs:156-169` |
**Description**
@@ -580,7 +602,16 @@ regression test covering refresh of a 31-minute-idle token.
**Resolution**
_Unresolved._
Resolved 2026-05-17 (commit `pending`). Confirmed: `RefreshToken` carried `LastActivity`
forward and minted a fresh 15-minute token without ever calling `IsIdleTimedOut`, so the
idle policy depended entirely on caller discipline and a background refresh could keep an
idle-expired session alive indefinitely. `RefreshToken` now calls
`IsIdleTimedOut(currentPrincipal)` after the missing-claims check and returns `null`
(its existing "cannot refresh" signal) when the session is past the idle window, so the
30-minute idle policy holds regardless of caller order; XML doc updated to state the
invariant. Regression tests `RefreshToken_IdleExpiredPrincipal_ReturnsNull`,
`RefreshToken_ActiveSessionWithinIdleWindow_StillRefreshes`,
`RefreshToken_MissingLastActivityClaim_ReturnsNull`.
### Security-015 — Username is not trimmed before use in the LDAP filter, fallback DN, and JWT claims
@@ -588,7 +619,7 @@ _Unresolved._
|--|--|
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.Security/LdapAuthService.cs:20-21`, `:80`, `:122`, `:169`, `:193` |
**Description**
@@ -613,4 +644,13 @@ trail.
**Resolution**
_Unresolved._
Resolved 2026-05-17 (commit `pending`). Confirmed: an otherwise-valid username with
leading/trailing whitespace passed the `IsNullOrWhiteSpace` guard and flowed verbatim
into the LDAP filter, the fallback DN, and the returned `LdapAuthResult` (and thus the
JWT `Username` claim). Added a `NormalizeUsername` helper (trims whitespace) called once
at the top of `AuthenticateAsync` immediately after the guard; the local `username`
variable is reassigned to the trimmed value so the filter, fallback DN, and result all
use the single canonical identity. Regression tests
`NormalizeUsername_TrimsLeadingAndTrailingWhitespace`,
`BuildFallbackUserDn_TrimmedUsername_NoLeadingTrailingSpace`,
`AuthenticateAsync_UsernameWithSurroundingWhitespace_StillRejectedForInsecure`.