Skip to content

Commit e37c421

Browse files
robgrameCopilot
andcommitted
Upgrade to .NET 10, security fixes, tests, and docs reorganization
- Upgrade all projects to .NET 10.0 (LTS) with aligned NuGet packages - Fix JSON injection in EntraADHelper (use JsonObject instead of string interpolation) - Fix index out of bounds with extension attribute name whitelist validation - Fix thread-unsafe token cache with SemaphoreSlim double-check locking - Fix OData filter injection in IntuneHelper (escape single quotes) - Fix URL parameter injection in EntraADHelper (Uri.EscapeDataString) - Fix resource disposal for DirectoryEntry in ADHelper - Replace Console.WriteLine with ILogger in library code - Fix deprecated ConfigureHttpMessageHandlerBuilder API - Target net10.0-windows for Worker/Tests (eliminates CA1416 warnings) - Remove redundant System.Text.Json and old SignalR packages - Add SupportedOSPlatform attributes for Windows-only code paths - Add unit test project with 28 tests (DN parsing, config, attribute validation) - Move documentation files to docs/ folder - Update README with .NET 10 badges, correct paths, and docs links - Remove legacy empty folders from previous rename Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent b999391 commit e37c421

29 files changed

Lines changed: 708 additions & 381 deletions

Nimbus.ExtensionAttributes.AD.Helper/ADHelper.cs

Lines changed: 156 additions & 282 deletions
Large diffs are not rendered by default.

Nimbus.ExtensionAttributes.AD.Helper/Nimbus.ExtensionAttributes.AD.Helper.csproj

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@
22

33
<PropertyGroup>
44
<OutputType>Library</OutputType>
5-
<TargetFramework>net9.0</TargetFramework>
5+
<TargetFramework>net10.0</TargetFramework>
66
<ImplicitUsings>enable</ImplicitUsings>
77
<Nullable>enable</Nullable>
88
</PropertyGroup>
99

1010
<ItemGroup>
11-
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.4" />
12-
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.4" />
13-
<PackageReference Include="System.DirectoryServices" Version="9.0.4" />
11+
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
12+
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
13+
<PackageReference Include="System.DirectoryServices" Version="10.0.0" />
1414
</ItemGroup>
1515

1616
</Project>

Nimbus.ExtensionAttributes.EntraAD.Helper/Authentication/AuthenticationHandler.cs

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@
88

99
namespace Nimbus.ExtensionAttributes.EntraAD.Authentication
1010
{
11-
public class AuthenticationHandler
11+
public class AuthenticationHandler : IDisposable
1212
{
1313
private readonly EntraADHelperSettings _settings;
1414
private readonly ILogger<AuthenticationHandler> _logger;
15+
private readonly SemaphoreSlim _tokenLock = new(1, 1);
1516
private AccessToken? _accessToken;
1617
private DateTime _tokenExpiration;
1718

@@ -23,21 +24,27 @@ public AuthenticationHandler(IOptions<EntraADHelperSettings> settings, ILogger<A
2324

2425
public async Task<AccessToken> GetAccessTokenAsync()
2526
{
26-
try
27+
// Fast path: check cache without locking
28+
if (_accessToken != null && _accessToken.Value.ExpiresOn.UtcDateTime > DateTime.UtcNow.AddMinutes(_settings.TokenExpirationBuffer))
2729
{
30+
_logger.LogTrace("Using cached access token.");
31+
return _accessToken.Value;
32+
}
2833

29-
// Check if the access token is cached and not expired within the buffer time
34+
await _tokenLock.WaitAsync();
35+
try
36+
{
37+
// Double-check after acquiring lock (another thread may have refreshed)
3038
if (_accessToken != null && _accessToken.Value.ExpiresOn.UtcDateTime > DateTime.UtcNow.AddMinutes(_settings.TokenExpirationBuffer))
3139
{
32-
_logger.LogTrace("Using cached access token.");
40+
_logger.LogTrace("Using cached access token (refreshed by another thread).");
3341
return _accessToken.Value;
3442
}
3543

36-
// Check if ClientId and TenantId are set
3744
if (string.IsNullOrEmpty(_settings.ClientId) || string.IsNullOrEmpty(_settings.TenantId))
3845
{
3946
_logger.LogError("ClientId or TenantId is not set.");
40-
throw new Exception("ClientId or TenantId is not set.");
47+
throw new InvalidOperationException("ClientId or TenantId is not set.");
4148
}
4249

4350
_logger.LogTrace("Fetching new access token...");
@@ -52,13 +59,13 @@ public async Task<AccessToken> GetAccessTokenAsync()
5259
if (string.IsNullOrEmpty(_settings.CertificateThumbprint))
5360
{
5461
_logger.LogError("Certificate thumbprint is not set.");
55-
throw new Exception("Certificate thumbprint is not set.");
62+
throw new InvalidOperationException("Certificate thumbprint is not set.");
5663
}
5764
var certificate = FindCertificateByThumbprint(_settings.CertificateThumbprint);
5865
if (certificate == null)
5966
{
6067
_logger.LogError("Certificate with thumbprint {Thumbprint} not found", _settings.CertificateThumbprint);
61-
throw new Exception("Certificate not found");
68+
throw new InvalidOperationException($"Certificate with thumbprint {_settings.CertificateThumbprint} not found.");
6269
}
6370

6471
credential = new ClientCertificateCredential(_settings.TenantId, _settings.ClientId, certificate);
@@ -97,6 +104,15 @@ public async Task<AccessToken> GetAccessTokenAsync()
97104
_logger.LogError(ex, "Failed to get access token.");
98105
throw new InvalidOperationException("An unexpected error occurred while getting the access token.", ex);
99106
}
107+
finally
108+
{
109+
_tokenLock.Release();
110+
}
111+
}
112+
113+
public void Dispose()
114+
{
115+
_tokenLock.Dispose();
100116
}
101117

102118
private X509Certificate2? FindCertificateByThumbprint(string thumbprint)

Nimbus.ExtensionAttributes.EntraAD.Helper/EntraADHelper.cs

Lines changed: 54 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,16 @@ public class EntraADHelper : IEntraADHelper
2424
private readonly HttpClient _httpClient;
2525
private readonly AuthenticationHandler _authenticationHandler;
2626

27+
// Whitelist of valid extension attribute property names
28+
private static readonly HashSet<string> ValidExtensionAttributes = new(StringComparer.OrdinalIgnoreCase)
29+
{
30+
"ExtensionAttribute1", "ExtensionAttribute2", "ExtensionAttribute3",
31+
"ExtensionAttribute4", "ExtensionAttribute5", "ExtensionAttribute6",
32+
"ExtensionAttribute7", "ExtensionAttribute8", "ExtensionAttribute9",
33+
"ExtensionAttribute10", "ExtensionAttribute11", "ExtensionAttribute12",
34+
"ExtensionAttribute13", "ExtensionAttribute14", "ExtensionAttribute15"
35+
};
36+
2737
public EntraADHelper(ILogger<IEntraADHelper> logger, IHttpClientFactory httpClientFactory,IOptions<EntraADHelperSettings> settings,GraphServiceClient graphServiceClient, AuthenticationHandler authenticationHandler)
2838
{
2939
_graphServiceClient = graphServiceClient;
@@ -33,6 +43,29 @@ public EntraADHelper(ILogger<IEntraADHelper> logger, IHttpClientFactory httpClie
3343
_authenticationHandler = authenticationHandler;
3444
}
3545

46+
/// <summary>
47+
/// Normalizes extension attribute name to proper casing (e.g. "extensionattribute1" → "extensionAttribute1")
48+
/// and validates it against the whitelist.
49+
/// </summary>
50+
private string NormalizeExtensionAttributeName(string extensionAttribute)
51+
{
52+
ArgumentException.ThrowIfNullOrWhiteSpace(extensionAttribute);
53+
54+
// Find matching attribute from whitelist (case-insensitive)
55+
var match = ValidExtensionAttributes.FirstOrDefault(a =>
56+
string.Equals(a, extensionAttribute, StringComparison.OrdinalIgnoreCase));
57+
58+
if (match == null)
59+
{
60+
throw new ArgumentException(
61+
$"Invalid extension attribute name: '{extensionAttribute}'. Must be extensionAttribute1 through extensionAttribute15.",
62+
nameof(extensionAttribute));
63+
}
64+
65+
// Return with correct casing for Graph API: "extensionAttribute1" (lowercase 'e', uppercase 'A')
66+
return char.ToLowerInvariant(match[0]) + match[1..];
67+
}
68+
3669

3770
public async Task<IEnumerable<User>> GetUsers()
3871
{
@@ -110,7 +143,7 @@ public async Task<IEnumerable<User>> GetUsers(int pagingNum)
110143
}
111144
catch (ServiceException ex)
112145
{
113-
Console.WriteLine($"An error occurred: {ex.Message}");
146+
_logger.LogError(ex, "An error occurred retrieving user {UserId}", userId);
114147
return null;
115148
}
116149
}
@@ -124,7 +157,7 @@ public async Task<IEnumerable<User>> GetUsers(int pagingNum)
124157
}
125158
catch (ServiceException ex)
126159
{
127-
Console.WriteLine($"An error occurred retrieving Device directory object: {ex.Message}");
160+
_logger.LogError(ex, "An error occurred retrieving Device directory object for {DeviceId}", deviceId);
128161
return null;
129162
}
130163
}
@@ -139,7 +172,7 @@ public async Task<IEnumerable<User>> GetUsers(int pagingNum)
139172

140173
_logger.LogTrace("Building the request to the Microsoft Graph API (beta)");
141174
var request = new HttpRequestMessage(HttpMethod.Get,
142-
$"https://graph.microsoft.com/beta/devices?$filter=displayName eq '{deviceName}'&$select={string.Join(",", _settings.AttributesToLoad ?? new[] {"id","deviceId","accountEnabled","approximateLastSignInDateTime","displayName","trustType"})}");
175+
$"https://graph.microsoft.com/beta/devices?$filter=displayName eq '{Uri.EscapeDataString(deviceName)}'&$select={string.Join(",", _settings.AttributesToLoad ?? new[] {"id","deviceId","accountEnabled","approximateLastSignInDateTime","displayName","trustType"})}");
143176
_logger.LogTrace("Request built");
144177

145178
_logger.LogTrace("Adding the access token to the request headers");
@@ -215,9 +248,7 @@ public async Task<IEnumerable<User>> GetUsers(int pagingNum)
215248
{
216249
_logger.LogTrace("GetExtensionAttribute Called");
217250

218-
string lowerCasedExtensionAttribute = extensionAttribute.ToLowerInvariant();
219-
string correctCasingAttribute = char.ToUpperInvariant(lowerCasedExtensionAttribute[0]) + lowerCasedExtensionAttribute.Substring(1);
220-
correctCasingAttribute = correctCasingAttribute.Substring(0, 9) + char.ToUpperInvariant(extensionAttribute[9]) + extensionAttribute.Substring(10);
251+
string correctCasingAttribute = NormalizeExtensionAttributeName(extensionAttribute);
221252
_logger.LogTrace("Extension attribute name {extensionAttribute} converted to {correctCasingAttribute}", extensionAttribute, correctCasingAttribute);
222253

223254
try
@@ -255,7 +286,10 @@ public async Task<IEnumerable<User>> GetUsers(int pagingNum)
255286
_logger.LogError("Extension attributes are null for device {DeviceId}", deviceId);
256287
return null;
257288
}
258-
var extensionAttributeValue = device.ExtensionAttributes.GetType().GetProperty(correctCasingAttribute)?.GetValue(device.ExtensionAttributes, null);
289+
290+
// Use PascalCase for reflection on the model property
291+
var pascalCaseAttr = char.ToUpperInvariant(correctCasingAttribute[0]) + correctCasingAttribute[1..];
292+
var extensionAttributeValue = device.ExtensionAttributes.GetType().GetProperty(pascalCaseAttr)?.GetValue(device.ExtensionAttributes, null);
259293
if (extensionAttributeValue == null)
260294
{
261295
_logger.LogWarning("Extension attribute {ExtensionAttribute} is null for device {DeviceId}", extensionAttribute, deviceId);
@@ -295,9 +329,7 @@ public async Task<IEnumerable<User>> GetUsers(int pagingNum)
295329

296330
foreach (string extensionAttribute in extensionAttributes)
297331
{
298-
string lowerCasedExtensionAttribute = extensionAttribute.ToLowerInvariant();
299-
string correctCasingAttribute = char.ToUpperInvariant(lowerCasedExtensionAttribute[0]) + lowerCasedExtensionAttribute.Substring(1);
300-
correctCasingAttribute = correctCasingAttribute.Substring(0, 9) + char.ToUpperInvariant(extensionAttribute[9]) + extensionAttribute.Substring(10);
332+
string correctCasingAttribute = NormalizeExtensionAttributeName(extensionAttribute);
301333
_logger.LogTrace("Extension attribute name {extensionAttribute} converted to {correctCasingAttribute}", extensionAttribute, correctCasingAttribute);
302334
correctCasingAttributes.Add(correctCasingAttribute);
303335
}
@@ -339,7 +371,8 @@ public async Task<IEnumerable<User>> GetUsers(int pagingNum)
339371
}
340372

341373
var extensionAttributeValuesList = device.ExtensionAttributes.GetType().GetProperties()
342-
.Where(p => correctCasingAttributes.Contains(p.Name))
374+
.Where(p => correctCasingAttributes.Any(ca =>
375+
string.Equals(char.ToUpperInvariant(ca[0]) + ca[1..], p.Name, StringComparison.Ordinal)))
343376
.Select(p => p.GetValue(device.ExtensionAttributes, null)?.ToString())
344377
.ToList();
345378

@@ -374,7 +407,7 @@ public async Task<IEnumerable<User>> GetUsers(int pagingNum)
374407
_logger.LogTrace("SetExtensionAttributeValue Called");
375408

376409
var lowerCasedExtensionAttribute = extensionAttributeName.ToLowerInvariant();
377-
var correctCasingAttribute = lowerCasedExtensionAttribute.Substring(0, 9) + char.ToUpperInvariant(extensionAttributeName[9]) + extensionAttributeName.Substring(10);
410+
var correctCasingAttribute = NormalizeExtensionAttributeName(extensionAttributeName);
378411
_logger.LogTrace("Correct casing for extension attribute {extensionAttribute} is {correctCasingAttribute}", extensionAttributeName, correctCasingAttribute);
379412

380413
try
@@ -393,7 +426,14 @@ public async Task<IEnumerable<User>> GetUsers(int pagingNum)
393426
_logger.LogTrace("Access token added to the request headers");
394427

395428
_logger.LogTrace("Adding the request body");
396-
request.Content = new StringContent($"{{\"extensionAttributes\":{{\"{extensionAttributeName}\":\"{extensionAttributeValue}\"}}}}", System.Text.Encoding.UTF8, "application/json");
429+
var jsonBody = new System.Text.Json.Nodes.JsonObject
430+
{
431+
["extensionAttributes"] = new System.Text.Json.Nodes.JsonObject
432+
{
433+
[extensionAttributeName] = extensionAttributeValue
434+
}
435+
};
436+
request.Content = new StringContent(jsonBody.ToJsonString(), System.Text.Encoding.UTF8, "application/json");
397437
_logger.LogTrace("Request body added");
398438

399439
_logger.LogTrace("Sending the request");
@@ -815,7 +855,7 @@ public async Task<IEnumerable<string>> GetDeviceHWIdByComputerName(string comput
815855

816856
_logger.LogTrace("Building the request to the Microsoft Graph API (beta)");
817857
var request = new HttpRequestMessage(HttpMethod.Get,
818-
$"https://graph.microsoft.com/beta/devices?$filter=displayName eq '{computerName}'");
858+
$"https://graph.microsoft.com/beta/devices?$filter=displayName eq '{Uri.EscapeDataString(computerName)}'");
819859
_logger.LogTrace("Request built");
820860

821861
_logger.LogTrace("Adding the access token to the request headers");
Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,17 @@
11
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
4-
<TargetFramework>net9.0</TargetFramework>
4+
<TargetFramework>net10.0</TargetFramework>
55
<ImplicitUsings>enable</ImplicitUsings>
66
<Nullable>enable</Nullable>
77
</PropertyGroup>
88

99
<ItemGroup>
1010
<PackageReference Include="Azure.Identity" Version="1.13.2" />
11-
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.4" />
12-
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.4" />
11+
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
12+
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.0" />
1313
<PackageReference Include="Microsoft.Graph" Version="5.77.0" />
1414
<PackageReference Include="Microsoft.Identity.Client" Version="4.68.0" />
15-
<PackageReference Include="System.Text.Json" Version="9.0.4" />
1615
</ItemGroup>
1716

1817
</Project>

Nimbus.ExtensionAttributes.Intune.Helper/IntuneHelper.cs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@ public IntuneHelper(ILogger<IIntuneHelper> logger, GraphServiceClient graphClien
3232
_logger.LogInformation("IntuneHelper initialized with authenticated REST API support.");
3333
}
3434

35+
/// <summary>
36+
/// Escapes single quotes in OData filter values to prevent filter injection
37+
/// </summary>
38+
private static string EscapeODataFilterValue(string value)
39+
{
40+
return value.Replace("'", "''");
41+
}
42+
3543
#region Basic Device Information
3644
public async Task<ManagedDeviceCollectionResponse> GetIntuneDevices()
3745
{
@@ -93,7 +101,7 @@ public async Task<ManagedDeviceCollectionResponse> GetIntuneDevices()
93101
_logger.LogDebug("Getting device details for device name: {DeviceName}", deviceName);
94102
var devices = await _graphServiceClient.DeviceManagement.ManagedDevices.GetAsync(requestConfiguration =>
95103
{
96-
requestConfiguration.QueryParameters.Filter = $"deviceName eq '{deviceName}'";
104+
requestConfiguration.QueryParameters.Filter = $"deviceName eq '{EscapeODataFilterValue(deviceName)}'";
97105
});
98106

99107
return devices?.Value?.FirstOrDefault();
@@ -112,7 +120,7 @@ public async Task<ManagedDeviceCollectionResponse> GetIntuneDevices()
112120
_logger.LogDebug("Getting device details for Entra device ID: {EntraDeviceId}", entraDeviceId);
113121
var devices = await _graphServiceClient.DeviceManagement.ManagedDevices.GetAsync(requestConfiguration =>
114122
{
115-
requestConfiguration.QueryParameters.Filter = $"azureADDeviceId eq '{entraDeviceId}'";
123+
requestConfiguration.QueryParameters.Filter = $"azureADDeviceId eq '{EscapeODataFilterValue(entraDeviceId)}'";
116124
});
117125

118126
return devices?.Value?.FirstOrDefault();
@@ -360,7 +368,7 @@ public async Task<bool> IsBitLockerEnabled(string deviceId)
360368
_logger.LogDebug("Finding device by serial number: {SerialNumber}", serialNumber);
361369
var devices = await _graphServiceClient.DeviceManagement.ManagedDevices.GetAsync(requestConfiguration =>
362370
{
363-
requestConfiguration.QueryParameters.Filter = $"serialNumber eq '{serialNumber}'";
371+
requestConfiguration.QueryParameters.Filter = $"serialNumber eq '{EscapeODataFilterValue(serialNumber)}'";
364372
});
365373

366374
return devices?.Value?.FirstOrDefault();
@@ -379,7 +387,7 @@ public async Task<IEnumerable<ManagedDevice>> FindDevicesByUser(string userPrinc
379387
_logger.LogDebug("Finding devices by user: {UserPrincipalName}", userPrincipalName);
380388
var devices = await _graphServiceClient.DeviceManagement.ManagedDevices.GetAsync(requestConfiguration =>
381389
{
382-
requestConfiguration.QueryParameters.Filter = $"userPrincipalName eq '{userPrincipalName}'";
390+
requestConfiguration.QueryParameters.Filter = $"userPrincipalName eq '{EscapeODataFilterValue(userPrincipalName)}'";
383391
});
384392

385393
return devices?.Value ?? new List<ManagedDevice>();

Nimbus.ExtensionAttributes.Intune.Helper/Nimbus.ExtensionAttributes.Intune.Helper.csproj

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
4-
<TargetFramework>net9.0</TargetFramework>
4+
<TargetFramework>net10.0</TargetFramework>
55
<ImplicitUsings>enable</ImplicitUsings>
66
<Nullable>enable</Nullable>
77
</PropertyGroup>
88

99
<ItemGroup>
10-
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.9" />
11-
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.9" />
10+
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
11+
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.0" />
1212
<PackageReference Include="Microsoft.Graph" Version="5.77.0" />
1313
<PackageReference Include="Microsoft.Identity.Client" Version="4.68.0" />
1414
<PackageReference Include="Serilog" Version="4.2.0" />

0 commit comments

Comments
 (0)