fix(security): resolve Security-004..007 — configurable user-id attribute, DN escaping, JWT issuer/audience validation, idle-timeout preservation

This commit is contained in:
Joseph Doherty
2026-05-16 21:22:01 -04:00
parent a702cb96a8
commit 30ebbdd183
5 changed files with 383 additions and 26 deletions

View File

@@ -18,6 +18,17 @@ public class JwtTokenService
public const string SiteIdClaimType = "SiteId";
public const string LastActivityClaimType = "LastActivity";
/// <summary>
/// Fixed issuer bound into every token and required on validation. Binding
/// issuer/audience is defence-in-depth: even though the HMAC key is shared only
/// between the two central nodes, accidental reuse of the same secret for an
/// unrelated internal token would otherwise be silently exploitable.
/// </summary>
public const string TokenIssuer = "scadalink-central";
/// <summary>Fixed audience bound into every token and required on validation.</summary>
public const string TokenAudience = "scadalink-central";
public JwtTokenService(IOptions<SecurityOptions> options, ILogger<JwtTokenService> logger)
{
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
@@ -37,11 +48,19 @@ public class JwtTokenService
}
}
/// <summary>
/// Issues a fresh JWT. <paramref name="lastActivity"/> sets the idle-timeout
/// anchor; when omitted (a brand-new login) it defaults to now. On a token
/// refresh the caller MUST pass the existing anchor forward so the idle window
/// continues to be measured from the user's last genuine activity rather than
/// from token issuance time.
/// </summary>
public string GenerateToken(
string displayName,
string username,
IReadOnlyList<string> roles,
IReadOnlyList<string>? permittedSiteIds)
IReadOnlyList<string>? permittedSiteIds,
DateTimeOffset? lastActivity = null)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.JwtSigningKey));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
@@ -50,7 +69,7 @@ public class JwtTokenService
{
new(DisplayNameClaimType, displayName),
new(UsernameClaimType, username),
new(LastActivityClaimType, DateTimeOffset.UtcNow.ToString("o"))
new(LastActivityClaimType, (lastActivity ?? DateTimeOffset.UtcNow).ToString("o"))
};
foreach (var role in roles)
@@ -67,6 +86,8 @@ public class JwtTokenService
}
var token = new JwtSecurityToken(
issuer: TokenIssuer,
audience: TokenAudience,
claims: claims,
expires: DateTime.UtcNow.AddMinutes(_options.JwtExpiryMinutes),
signingCredentials: credentials);
@@ -79,8 +100,10 @@ public class JwtTokenService
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.JwtSigningKey));
var validationParameters = new TokenValidationParameters
{
ValidateIssuer = false,
ValidateAudience = false,
ValidateIssuer = true,
ValidIssuer = TokenIssuer,
ValidateAudience = true,
ValidAudience = TokenAudience,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = key,
@@ -121,6 +144,15 @@ public class JwtTokenService
return (DateTimeOffset.UtcNow - lastActivity).TotalMinutes > _options.IdleTimeoutMinutes;
}
/// <summary>
/// Issues a fresh token (new expiry, re-queried roles) while <b>preserving</b> the
/// existing <see cref="LastActivityClaimType"/> anchor. A refresh is itself
/// triggered by a request, but it must not be treated as user activity — the
/// idle window must keep being measured from the user's last genuine interaction,
/// otherwise the documented 30-minute idle timeout could never fire for a client
/// that polls in the background. Call <see cref="RecordActivity"/> to advance the
/// anchor when handling a genuine user request.
/// </summary>
public string? RefreshToken(ClaimsPrincipal currentPrincipal, IReadOnlyList<string> currentRoles, IReadOnlyList<string>? permittedSiteIds)
{
var displayName = currentPrincipal.FindFirst(DisplayNameClaimType)?.Value;
@@ -132,6 +164,36 @@ public class JwtTokenService
return null;
}
return GenerateToken(displayName, username, currentRoles, permittedSiteIds);
return GenerateToken(displayName, username, currentRoles, permittedSiteIds,
ReadLastActivity(currentPrincipal));
}
/// <summary>
/// Issues a fresh token whose <see cref="LastActivityClaimType"/> anchor is
/// advanced to now. This is the explicit "user did something" path — distinct
/// from <see cref="RefreshToken"/> — to be called by the request pipeline when
/// handling a genuine user interaction.
/// </summary>
public string? RecordActivity(ClaimsPrincipal currentPrincipal, IReadOnlyList<string> currentRoles, IReadOnlyList<string>? permittedSiteIds)
{
var displayName = currentPrincipal.FindFirst(DisplayNameClaimType)?.Value;
var username = currentPrincipal.FindFirst(UsernameClaimType)?.Value;
if (displayName == null || username == null)
{
_logger.LogWarning("Cannot record activity: missing DisplayName or Username claims");
return null;
}
return GenerateToken(displayName, username, currentRoles, permittedSiteIds,
DateTimeOffset.UtcNow);
}
private static DateTimeOffset? ReadLastActivity(ClaimsPrincipal principal)
{
var claim = principal.FindFirst(LastActivityClaimType);
return claim != null && DateTimeOffset.TryParse(claim.Value, out var value)
? value
: null;
}
}

View File

@@ -71,7 +71,7 @@ public class LdapAuthService
try
{
var searchFilter = $"(uid={EscapeLdapFilter(username)})";
var searchFilter = $"({_options.LdapUserIdAttribute}={EscapeLdapFilter(username)})";
var searchResults = await Task.Run(() =>
connection.Search(
_options.LdapSearchBase,
@@ -133,17 +133,13 @@ public class LdapAuthService
/// </summary>
private async Task<string> ResolveUserDnAsync(LdapConnection connection, string username, CancellationToken ct)
{
// If username already looks like a DN, use it as-is
if (username.Contains('='))
return username;
// If a service account is configured, search for the user's actual DN
if (!string.IsNullOrWhiteSpace(_options.LdapServiceAccountDn))
{
await Task.Run(() =>
connection.Bind(_options.LdapServiceAccountDn, _options.LdapServiceAccountPassword), ct);
var searchFilter = $"(uid={EscapeLdapFilter(username)})";
var searchFilter = $"({_options.LdapUserIdAttribute}={EscapeLdapFilter(username)})";
var searchResults = await Task.Run(() =>
connection.Search(
_options.LdapSearchBase,
@@ -158,13 +154,68 @@ public class LdapAuthService
return entry.Dn;
}
throw new LdapException("User not found", LdapException.NoSuchObject, $"No entry found for uid={username}");
throw new LdapException("User not found", LdapException.NoSuchObject,
$"No entry found for {_options.LdapUserIdAttribute}={username}");
}
// Fallback: construct DN directly
return string.IsNullOrWhiteSpace(_options.LdapSearchBase)
? $"cn={username}"
: $"cn={username},{_options.LdapSearchBase}";
// Fallback: construct the bind DN directly from the configured user-id
// attribute. The username is RFC 4514 DN-escaped so it cannot alter the
// DN structure (Security-005). The previous Contains('=') shortcut that
// accepted a raw caller-supplied DN has been removed — accepting an
// arbitrary DN from untrusted input let a client choose the bind identity.
return BuildFallbackUserDn(username, _options.LdapSearchBase, _options.LdapUserIdAttribute);
}
/// <summary>
/// Builds the no-service-account fallback bind DN as
/// <c>{userIdAttribute}={escaped-username}[,{searchBase}]</c>. The username is
/// escaped per RFC 4514 so DN metacharacters in untrusted input cannot inject
/// additional RDN components or change the bind identity.
/// </summary>
public static string BuildFallbackUserDn(string username, string searchBase, string userIdAttribute)
{
var rdn = $"{userIdAttribute}={EscapeLdapDn(username)}";
return string.IsNullOrWhiteSpace(searchBase) ? rdn : $"{rdn},{searchBase}";
}
/// <summary>
/// Escapes a string for use as an RFC 4514 DN attribute value: the special
/// characters <c>, + " \ &lt; &gt; ;</c> are backslash-escaped, as are a leading
/// or trailing space and a leading <c>#</c>.
/// </summary>
public static string EscapeLdapDn(string input)
{
if (string.IsNullOrEmpty(input))
return input;
var sb = new System.Text.StringBuilder(input.Length + 8);
for (var i = 0; i < input.Length; i++)
{
var c = input[i];
var isEdgeSpace = c == ' ' && (i == 0 || i == input.Length - 1);
var isLeadingHash = c == '#' && i == 0;
switch (c)
{
case ',':
case '+':
case '"':
case '\\':
case '<':
case '>':
case ';':
sb.Append('\\').Append(c);
break;
case '\0':
sb.Append("\\00");
break;
default:
if (isEdgeSpace || isLeadingHash)
sb.Append('\\');
sb.Append(c);
break;
}
}
return sb.ToString();
}
private static string EscapeLdapFilter(string input)

View File

@@ -37,10 +37,19 @@ public class SecurityOptions
/// <summary>
/// Service account DN for LDAP user searches (e.g., "cn=admin,dc=example,dc=com").
/// Required for search-then-bind authentication. If empty, direct bind with
/// cn={username},{LdapSearchBase} is attempted instead.
/// {LdapUserIdAttribute}={username},{LdapSearchBase} is attempted instead.
/// </summary>
public string LdapServiceAccountDn { get; set; } = string.Empty;
/// <summary>
/// LDAP attribute that identifies a user. Used both for the search-then-bind
/// filter (<c>({LdapUserIdAttribute}={username})</c>) and for constructing the
/// fallback bind DN when no service account is configured, so the two
/// authentication modes are interchangeable. Common values: <c>uid</c> (OpenLDAP),
/// <c>sAMAccountName</c> (Active Directory).
/// </summary>
public string LdapUserIdAttribute { get; set; } = "uid";
/// <summary>
/// Service account password for LDAP user searches.
/// </summary>