Skip to content

Commit 74fce2b

Browse files
Merge origin/main into security/ai-agent-hardening
2 parents bf8390a + c90b5b7 commit 74fce2b

8 files changed

Lines changed: 79 additions & 27 deletions

File tree

Directory.Packages.props

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<PropertyGroup>
33
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
44
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
5-
<ToolingPackagesVersion>1.1.1.19044</ToolingPackagesVersion>
5+
<ToolingPackagesVersion>1.1.1.19071</ToolingPackagesVersion>
66
<AccessToNugetFeed Condition="'$(AccessToNugetFeed)' == ''">false</AccessToNugetFeed>
77
<!-- Disable NuGet vulnerability audit when the private feed is unavailable (e.g. CI without credentials).
88
NuGet audit queries all sources in nuget.config regardless of RestoreSources, causing NU1900 which is
@@ -17,20 +17,22 @@
1717
</ItemGroup>
1818
<ItemGroup>
1919
<PackageVersion Include="Azure.Extensions.AspNetCore.DataProtection.Keys" Version="1.6.3" />
20-
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.7" />
20+
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.8" />
2121
<PackageVersion Include="AspNet.Security.OAuth.GitHub" Version="10.0.0" />
2222
<PackageVersion Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.5.1" />
2323
<PackageVersion Include="Azure.Identity" Version="1.21.0" />
2424
<PackageVersion Include="Azure.Monitor.OpenTelemetry.AspNetCore" Version="1.5.0" />
25+
<!-- TODO: update to stable release when Azure.Monitor.OpenTelemetry.Profiler reaches GA -->
26+
<PackageVersion Include="Azure.Monitor.OpenTelemetry.Profiler" Version="1.0.0-beta9" />
2527
<PackageVersion Include="TUnit" Version="1.40.5" />
2628
<PackageVersion Include="EssentialCSharp.Shared.Models" Version="$(ToolingPackagesVersion)" />
2729
<PackageVersion Include="HtmlAgilityPack" Version="1.12.4" />
2830
<PackageVersion Include="IntelliTect.Multitool" Version="2.1.0" />
2931
<PackageVersion Include="Mailjet.Api" Version="4.0.0" />
3032
<PackageVersion Include="Microsoft.AspNetCore.Authentication.MicrosoftAccount" Version="10.0.8" />
31-
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.7" />
33+
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.8" />
3234
<PackageVersion Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.7" />
33-
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="10.0.7" />
35+
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="10.0.8" />
3436
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.7" />
3537
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.0.0" />
3638
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.0.0" />

EssentialCSharp.Web.Tests/FunctionalTests.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,5 +51,10 @@ public async Task WhenTheApplicationStarts_NonExistingPage_GivesCorrectStatusCod
5151
using HttpResponseMessage response = await client.GetAsync("/non-existing-page1234");
5252

5353
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
54+
55+
string content = await response.Content.ReadAsStringAsync();
56+
await Assert.That(content).Contains("Go Home");
57+
await Assert.That(content).Contains("Go Back");
58+
await Assert.That(content).Contains("Browse Chapters");
5459
}
55-
}
60+
}

EssentialCSharp.Web/Controllers/ChatController.cs

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,10 @@ public ChatController(ILogger<ChatController> logger, AIChatService aiChatServic
3737
/// <summary>
3838
/// Validates the hCaptcha token when captcha is configured.
3939
/// Returns <c>true</c> when captcha is not configured (dev mode) or when the token is valid.
40-
/// Fails open on hCaptcha service outages (null result) to avoid blocking legitimate users —
41-
/// this is intentional given the existing [Authorize] + rate-limiting backstop.
40+
/// Returns <c>false</c> for missing or invalid tokens.
41+
/// Returns <c>null</c> when hCaptcha cannot be reached, so the caller can fail closed.
4242
/// </summary>
43-
private async Task<bool> IsCaptchaValidAsync(string? token, string? remoteIp, CancellationToken ct)
43+
private async Task<bool?> IsCaptchaValidAsync(string? token, string? remoteIp, CancellationToken ct)
4444
{
4545
if (string.IsNullOrWhiteSpace(_CaptchaOptions.SecretKey))
4646
return true; // captcha not configured — skip validation
@@ -51,8 +51,8 @@ private async Task<bool> IsCaptchaValidAsync(string? token, string? remoteIp, Ca
5151
HCaptchaResult? result = await _CaptchaService.VerifyAsync(token, remoteIp, ct);
5252
if (result is null)
5353
{
54-
LogCaptchaServiceUnavailable(_Logger); // hCaptcha unreachable — fail open (intentional)
55-
return true;
54+
LogCaptchaServiceUnavailable(_Logger); // hCaptcha unreachable — fail closed
55+
return null;
5656
}
5757

5858
if (!result.Success)
@@ -73,7 +73,10 @@ public async Task<IActionResult> SendMessage([FromBody] ChatMessageRequest reque
7373
if (string.IsNullOrEmpty(userId))
7474
return Unauthorized();
7575

76-
if (!await IsCaptchaValidAsync(request.CaptchaResponse, HttpContext.Connection.RemoteIpAddress?.ToString(), cancellationToken))
76+
bool? captchaValid = await IsCaptchaValidAsync(request.CaptchaResponse, HttpContext.Connection.RemoteIpAddress?.ToString(), cancellationToken);
77+
if (captchaValid is null)
78+
return StatusCode(503, new { error = "Human verification is temporarily unavailable. Please try again later.", errorCode = "captcha_unavailable" });
79+
if (!captchaValid.Value)
7780
return StatusCode(403, new { error = "Human verification required.", errorCode = "captcha_failed" });
7881

7982
var previousResponseId = string.IsNullOrWhiteSpace(request.PreviousResponseId)
@@ -126,7 +129,14 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat
126129
return;
127130
}
128131

129-
if (!await IsCaptchaValidAsync(request.CaptchaResponse, HttpContext.Connection.RemoteIpAddress?.ToString(), cancellationToken))
132+
bool? captchaValid = await IsCaptchaValidAsync(request.CaptchaResponse, HttpContext.Connection.RemoteIpAddress?.ToString(), cancellationToken);
133+
if (captchaValid is null)
134+
{
135+
Response.StatusCode = 503;
136+
await Response.WriteAsJsonAsync(new { error = "Human verification is temporarily unavailable. Please try again later.", errorCode = "captcha_unavailable" }, CancellationToken.None);
137+
return;
138+
}
139+
if (!captchaValid.Value)
130140
{
131141
Response.StatusCode = 403;
132142
await Response.WriteAsJsonAsync(new { error = "Human verification required.", errorCode = "captcha_failed" }, CancellationToken.None);

EssentialCSharp.Web/EssentialCSharp.Web.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
<PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Keys" />
3939
<PackageReference Include="Azure.Identity" />
4040
<PackageReference Include="Azure.Monitor.OpenTelemetry.AspNetCore" />
41+
<PackageReference Include="Azure.Monitor.OpenTelemetry.Profiler" />
4142
<PackageReference Include="EssentialCSharp.Shared.Models" />
4243
<PackageReference Include="HtmlAgilityPack" />
4344
<PackageReference Include="IntelliTect.Multitool" />

EssentialCSharp.Web/Program.cs

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
using Microsoft.AspNetCore.Identity.UI.Services;
2020
using Microsoft.AspNetCore.RateLimiting;
2121
using Azure.Monitor.OpenTelemetry.AspNetCore;
22+
using Azure.Monitor.OpenTelemetry.Profiler;
2223
using Microsoft.AspNetCore.DataProtection;
2324
using Microsoft.AspNetCore.Diagnostics;
2425
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
@@ -90,7 +91,7 @@ private static void Main(string[] args)
9091
});
9192

9293
if (useAzureMonitor)
93-
otel.UseAzureMonitor();
94+
otel.UseAzureMonitor().AddAzureMonitorProfiler();
9495
else if (useOtlp)
9596
otel.UseOtlpExporter();
9697

@@ -105,32 +106,40 @@ private static void Main(string[] args)
105106
c.Timeout = TimeSpan.FromSeconds(3);
106107
});
107108

108-
109+
bool hasTrustedProxyConfig = false;
109110

110111
builder.Services.Configure<ForwardedHeadersOptions>(options =>
111112
{
112113
options.ForwardedHeaders =
113114
ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
114115

115-
options.KnownIPNetworks.Clear();
116-
options.KnownProxies.Clear();
117-
118-
// Restrict trusted proxy sources to configured CIDRs.
119-
// SECURITY: Set ForwardedHeaders:TrustedProxyCidrs in production to your
120-
// load-balancer IP range (e.g., the Azure Container Apps ingress CIDR) to
121-
// prevent X-Forwarded-For spoofing. Without this, any client can fabricate
122-
// a forwarded IP and bypass IP-partitioned rate limits.
123116
var trustedCidrs = builder.Configuration
124117
.GetSection("ForwardedHeaders:TrustedProxyCidrs")
125118
.Get<string[]>() ?? [];
126119

120+
List<System.Net.IPNetwork> trustedNetworks = [];
127121
foreach (var cidr in trustedCidrs)
128122
{
129123
if (System.Net.IPNetwork.TryParse(cidr, out var network))
130-
options.KnownIPNetworks.Add(network);
124+
{
125+
trustedNetworks.Add(network);
126+
hasTrustedProxyConfig = true;
127+
}
131128
else
132129
Console.Error.WriteLine($"[WARN] ForwardedHeaders:TrustedProxyCidrs: could not parse '{cidr}' as an IP network — entry skipped. Check your configuration.");
133130
}
131+
132+
// Only replace the default loopback trust set when at least one valid
133+
// proxy CIDR is configured. Otherwise forwarded headers remain limited to
134+
// the default safe behavior instead of trusting arbitrary client-supplied
135+
// X-Forwarded-* values.
136+
if (hasTrustedProxyConfig)
137+
{
138+
options.KnownIPNetworks.Clear();
139+
options.KnownProxies.Clear();
140+
foreach (System.Net.IPNetwork network in trustedNetworks)
141+
options.KnownIPNetworks.Add(network);
142+
}
134143
});
135144

136145
ConfigurationManager configuration = builder.Configuration;
@@ -444,8 +453,7 @@ await context.HttpContext.Response.WriteAsync(
444453
// X-Forwarded-For spoofing protection (F4) is visibly inactive until properly configured.
445454
if (!app.Environment.IsDevelopment())
446455
{
447-
var fwdOpts = app.Services.GetRequiredService<IOptions<ForwardedHeadersOptions>>().Value;
448-
if (fwdOpts.KnownIPNetworks.Count == 0 && fwdOpts.KnownProxies.Count == 0)
456+
if (!hasTrustedProxyConfig)
449457
LogTrustedProxyCidrsNotConfigured(app.Logger);
450458
}
451459

@@ -614,6 +622,16 @@ await McpJsonRpcResponseWriter.WriteErrorAsync(
614622
LogSitemapValidationFailed(logger, ex);
615623
// Continue startup even if sitemap validation fails
616624
}
625+
catch (ArgumentException ex)
626+
{
627+
LogSitemapValidationFailed(logger, ex);
628+
// Continue startup even if sitemap validation fails
629+
}
630+
catch (FormatException ex)
631+
{
632+
LogSitemapValidationFailed(logger, ex);
633+
// Continue startup even if sitemap validation fails
634+
}
617635

618636
app.Run();
619637
}

EssentialCSharp.Web/Views/Home/Error.cshtml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
}
1717
<div class="d-flex gap-2">
1818
<a href="/home" class="btn btn-primary">Go Home</a>
19+
<a href="/hello-world" class="btn btn-outline-primary">Browse Chapters</a>
1920
<button type="button" class="btn btn-secondary"
2021
onclick="if(history.length > 1){ history.back(); } else { window.location='/home'; }">
2122
Go Back

EssentialCSharp.Web/appsettings.Development.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
"Logging": {
44
"LogLevel": {
55
"Default": "Information",
6-
"Microsoft.AspNetCore": "Warning"
6+
"Microsoft.AspNetCore": "Warning",
7+
"Microsoft.ServiceProfiler": "Debug",
8+
"Azure.Monitor.OpenTelemetry.Profiler": "Debug"
79
}
810
},
911
"ConnectionStrings": {

EssentialCSharp.Web/wwwroot/js/chat-module.js

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ let captchaWidgetId = null;
1919
let captchaTokenResolve = null;
2020
let captchaTokenReject = null;
2121
let captchaPending = false; // prevents concurrent token requests overwriting promise callbacks
22+
let captchaRequestGeneration = 0;
2223
const CAPTCHA_TIMEOUT_MS = 15_000;
2324

2425
// Resolves once the widget has rendered. getCaptchaToken() awaits this so a user who
@@ -73,12 +74,17 @@ function getCaptchaToken() {
7374
if (!captchaSiteKey) return Promise.resolve(null);
7475
if (captchaPending) return Promise.reject(new Error('captcha-concurrent'));
7576
captchaPending = true;
77+
const requestGeneration = ++captchaRequestGeneration;
7678

7779
let timeoutId;
7880

7981
// Chain onto captchaWidgetReady so calls made before the widget finishes loading
8082
// wait rather than immediately returning null and causing a 403.
8183
const tokenPromise = captchaWidgetReady.then(() => new Promise((resolve, reject) => {
84+
if (requestGeneration !== captchaRequestGeneration) {
85+
reject(new Error('captcha-stale'));
86+
return;
87+
}
8288
captchaTokenResolve = (token) => {
8389
captchaPending = false;
8490
clearTimeout(timeoutId); // cancel lingering timer so it can't corrupt the next call
@@ -95,6 +101,8 @@ function getCaptchaToken() {
95101
const timeoutPromise = new Promise((_, reject) => {
96102
// timeoutId is assigned synchronously here, before captchaTokenResolve can fire
97103
timeoutId = setTimeout(() => {
104+
if (requestGeneration !== captchaRequestGeneration) return;
105+
captchaRequestGeneration++;
98106
captchaPending = false;
99107
captchaTokenResolve = null;
100108
captchaTokenReject = null;
@@ -106,6 +114,7 @@ function getCaptchaToken() {
106114
}
107115

108116
function resetCaptchaWidget() {
117+
captchaRequestGeneration++;
109118
captchaPending = false;
110119
captchaTokenResolve = null;
111120
captchaTokenReject = null;
@@ -339,6 +348,8 @@ export function useChatWidget() {
339348
throw new Error('Authentication required');
340349
} else if (response.status === 403) {
341350
throw new Error('captcha-failed: Human verification failed. Please try again.');
351+
} else if (response.status === 503) {
352+
throw new Error('captcha-unavailable: Human verification is temporarily unavailable. Please try again later.');
342353
} else if (response.status === 429) {
343354
// Handle rate limiting - simple error message without captcha
344355
let errorData;
@@ -441,7 +452,9 @@ export function useChatWidget() {
441452
errorType = 'auth-error';
442453
isAuthenticated.value = false; // Update auth state
443454
} else if (error.message?.startsWith('captcha-')) {
444-
errorMessage = 'Human verification failed. Please try again.';
455+
errorMessage = error.message.includes('captcha-unavailable')
456+
? 'Human verification is temporarily unavailable. Please try again later.'
457+
: 'Human verification failed. Please try again.';
445458
errorType = 'captcha-error';
446459
} else if (error.message?.includes('Rate limit exceeded')) {
447460
errorMessage = error.message; // Use the specific rate limit message with timing

0 commit comments

Comments
 (0)