docs(code-reviews): re-review batch 3 at 39d737e — Host, InboundAPI, ManagementService, NotificationService, Security
21 new findings: Host-012..015, InboundAPI-014..017, ManagementService-014..017, NotificationService-014..018, Security-012..015.
This commit is contained in:
@@ -5,10 +5,10 @@
|
||||
| Module | `src/ScadaLink.Security` |
|
||||
| Design doc | `docs/requirements/Component-Security.md` |
|
||||
| Status | Reviewed |
|
||||
| Last reviewed | 2026-05-16 |
|
||||
| Last reviewed | 2026-05-17 |
|
||||
| Reviewer | claude-agent |
|
||||
| Commit reviewed | `9c60592` |
|
||||
| Open findings | 0 (1 deferred — Security-008) |
|
||||
| Commit reviewed | `39d737e` |
|
||||
| Open findings | 4 (1 deferred — Security-008) |
|
||||
|
||||
## Summary
|
||||
|
||||
@@ -28,6 +28,26 @@ on every refresh, weakening the documented 30-minute idle policy. None of these
|
||||
crash/data-loss bugs, but the TLS, cookie, and key-validation items are security
|
||||
defects that should be fixed before any production deployment.
|
||||
|
||||
#### Re-review 2026-05-17 (commit `39d737e`)
|
||||
|
||||
Re-reviewed the module after the batch-resolution of Security-001..007, 009, 010, 011.
|
||||
Those fixes were verified in place and remain Resolved: the `LdapTransport` enum makes
|
||||
StartTLS reachable, the auth cookie is `SecurePolicy.Always` with a sliding window, the
|
||||
JWT signing key is length-validated at construction, issuer/audience are bound and
|
||||
checked, the DN-injection fallback is RFC 4514-escaped, and the idle-timeout anchor is
|
||||
carried across refreshes. Security-008 (N+1 scope-rule query) remains correctly
|
||||
**Deferred** — `ISecurityRepository` still exposes no per-set scope-rule query, so the
|
||||
N+1 cannot be removed from within `RoleMapper.cs` alone; the deferral condition is
|
||||
unchanged. This pass surfaced **4 new findings** (Security-012..015): two Medium and
|
||||
two Low. The most notable is that a *partial* LDAP outage during login (bind succeeds,
|
||||
group search fails) silently returns an authenticated session with **zero roles**
|
||||
instead of failing the login as the design's LDAP-failure rule requires
|
||||
(Security-012); and that `RefreshToken` re-issues a token without ever checking
|
||||
`IsIdleTimedOut`, so an idle-expired session can be renewed indefinitely if the caller
|
||||
omits the separate idle check (Security-014). The two Low findings concern fragile
|
||||
DN parsing of group names containing escaped commas and an un-trimmed username flowing
|
||||
into the LDAP filter, fallback DN, and JWT claims.
|
||||
|
||||
## Checklist coverage
|
||||
|
||||
| # | Category | Examined | Notes |
|
||||
@@ -456,3 +476,141 @@ suite. Remaining gaps closed here: renamed the test file `UnitTest1.cs` →
|
||||
LDAP-timeout coverage (Security-009) plus extra no-service-account / DN-path edge cases
|
||||
(`BuildFallbackUserDn_NoSearchBase_ReturnsBareRdn`, `EscapeLdapDn_LeadingHash_IsEscaped`,
|
||||
`EscapeLdapDn_NullOrEmpty_ReturnedUnchanged`). Full module suite: 54 tests, all green.
|
||||
|
||||
### Security-012 — Partial LDAP failure during login yields a roleless authenticated session
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| Severity | Medium |
|
||||
| Category | Error handling & resilience |
|
||||
| Status | Open |
|
||||
| Location | `src/ScadaLink.Security/LdapAuthService.cs:78-118` |
|
||||
|
||||
**Description**
|
||||
|
||||
In `AuthenticateAsync`, after the user bind succeeds, the group/attribute `Search` is
|
||||
wrapped in a `try`/`catch (LdapException)` that logs a warning and continues — the
|
||||
comment states "Auth succeeded even if attribute lookup failed". The method then returns
|
||||
`new LdapAuthResult(true, displayName, username, groups, null)` with an **empty
|
||||
`groups`** list. Because `RoleMapper.MapGroupsToRolesAsync` derives all roles from that
|
||||
group list, a login during a *partial* LDAP outage (bind reachable, search/group server
|
||||
unavailable, or a transient `LdapException` mid-search) produces a fully authenticated
|
||||
session with **zero roles** — the user is logged in but silently stripped of all
|
||||
permissions. The design's LDAP Connection Failure rule states new logins must **fail**
|
||||
when LDAP is unavailable; this path instead admits the user. The caller cannot
|
||||
distinguish "user genuinely belongs to no mapped groups" from "the group query failed",
|
||||
so it cannot make the documented fail-the-login decision. The `while` loop's inner
|
||||
`catch (LdapException) { break; }` (line 107-111) compounds this: a real error mid-enumeration
|
||||
is indistinguishable from end-of-results and silently truncates the group list.
|
||||
|
||||
**Recommendation**
|
||||
|
||||
Treat a failed group/attribute lookup on the *initial login* as an authentication
|
||||
failure: return `Success = false` with a "directory unavailable, try again" message, or
|
||||
add an explicit flag on `LdapAuthResult` (e.g. `GroupLookupSucceeded`) so the caller can
|
||||
apply the design's fail-the-login rule. The inner `while`-loop `catch` should not treat
|
||||
an arbitrary `LdapException` as a benign end-of-results sentinel — only the specific
|
||||
"no more results" condition should break the loop; any other exception should propagate
|
||||
or be surfaced.
|
||||
|
||||
**Resolution**
|
||||
|
||||
_Unresolved._
|
||||
|
||||
### Security-013 — `ExtractFirstRdnValue` mis-parses group DNs containing escaped commas
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| Severity | Low |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Status | Open |
|
||||
| Location | `src/ScadaLink.Security/LdapAuthService.cs:258-269` |
|
||||
|
||||
**Description**
|
||||
|
||||
`ExtractFirstRdnValue` extracts a group name from a `memberOf` DN by taking the substring
|
||||
between the first `=` and the first `,`. RFC 4514 permits a `,` inside an RDN value when
|
||||
it is backslash-escaped — e.g. `cn=Acme\, Inc Operators,ou=groups,dc=example,dc=com`. The
|
||||
method splits on the *escaped* comma and returns `Acme\` as the group name. `RoleMapper`
|
||||
then matches that mangled name verbatim against configured `LdapGroupMapping.LdapGroupName`
|
||||
values, so a group whose CN legitimately contains a comma silently fails to map to its
|
||||
role — the user loses that role with no error. The same naive parsing also does not strip
|
||||
the trailing backslash escape.
|
||||
|
||||
**Recommendation**
|
||||
|
||||
Parse the first RDN with an escape-aware scan: ignore a `,` preceded by an unescaped
|
||||
`\`, and unescape RFC 4514 sequences in the extracted value. Alternatively use the LDAP
|
||||
library's DN-parsing API (`LdapDN` / RDN accessors) rather than hand-rolled `IndexOf`.
|
||||
|
||||
**Resolution**
|
||||
|
||||
_Unresolved._
|
||||
|
||||
### Security-014 — `RefreshToken` re-issues a token without checking the idle timeout
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| Severity | Medium |
|
||||
| Category | Security |
|
||||
| Status | Open |
|
||||
| Location | `src/ScadaLink.Security/JwtTokenService.cs:156-169` |
|
||||
|
||||
**Description**
|
||||
|
||||
`RefreshToken` carries the existing `LastActivity` claim forward (correct, per the
|
||||
Security-007 fix) and unconditionally issues a fresh token with a new 15-minute expiry.
|
||||
It never calls `IsIdleTimedOut`. The documented 30-minute idle policy therefore depends
|
||||
entirely on the CentralUI request pipeline calling `IsIdleTimedOut` *before* calling
|
||||
`RefreshToken` — two independent, order-dependent checks with no enforcement that they
|
||||
are used together. If a caller refreshes without first checking idle state (an easy
|
||||
mistake, and there is no compiler or API signal preventing it), an idle-expired session
|
||||
is silently renewed: each refresh resets the JWT expiry while `LastActivity` keeps
|
||||
ageing, so the session can be kept alive indefinitely by background refreshes even
|
||||
though the user has been idle for hours. `RefreshToken` is the natural place to enforce
|
||||
the invariant but provides no safety net.
|
||||
|
||||
**Recommendation**
|
||||
|
||||
Have `RefreshToken` itself reject a principal that is already past the idle timeout —
|
||||
return `null` (the same "cannot refresh" signal it already uses for missing claims) when
|
||||
`IsIdleTimedOut(currentPrincipal)` is true — so the idle policy holds regardless of
|
||||
caller discipline. Document that an idle-expired principal cannot be refreshed and add a
|
||||
regression test covering refresh of a 31-minute-idle token.
|
||||
|
||||
**Resolution**
|
||||
|
||||
_Unresolved._
|
||||
|
||||
### Security-015 — Username is not trimmed before use in the LDAP filter, fallback DN, and JWT claims
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| Severity | Low |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Status | Open |
|
||||
| Location | `src/ScadaLink.Security/LdapAuthService.cs:20-21`, `:80`, `:122`, `:169`, `:193` |
|
||||
|
||||
**Description**
|
||||
|
||||
`AuthenticateAsync` rejects null/whitespace-only usernames via `IsNullOrWhiteSpace`, but
|
||||
an otherwise-valid username with leading or trailing whitespace (`" alice"`, copy-paste
|
||||
artefacts, mobile keyboards) is passed through verbatim. It flows into the LDAP search
|
||||
filter `({attr}={EscapeLdapFilter(username)})`, into the fallback bind DN via
|
||||
`BuildFallbackUserDn`, and — most consequentially — into the returned `LdapAuthResult`
|
||||
and from there into the JWT `Username` claim. Most LDAP directories ignore surrounding
|
||||
whitespace on a search term, so `" alice"` and `"alice"` may both authenticate but yield
|
||||
JWT `Username` claims that differ by whitespace. Any downstream identity comparison
|
||||
(audit-log `who`, site-scope lookups keyed by username, session de-duplication) then
|
||||
sees two distinct identities for the same person.
|
||||
|
||||
**Recommendation**
|
||||
|
||||
Trim the username once at the top of `AuthenticateAsync` (after the `IsNullOrWhiteSpace`
|
||||
guard) and use the trimmed value consistently for the filter, the DN, and the
|
||||
`LdapAuthResult`. This normalises the identity that ends up in the JWT claim and audit
|
||||
trail.
|
||||
|
||||
**Resolution**
|
||||
|
||||
_Unresolved._
|
||||
|
||||
Reference in New Issue
Block a user