From 07e287f3c4560b5d6a4fcf960b7296c285fc6e2a Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Wed, 13 May 2026 21:13:11 -0700 Subject: [PATCH 1/9] security: fix rate limiter partition key, trusted proxy config, and misleading 429 captcha signal F3: Replace Identity.Name (mutable display name) with ClaimTypes.NameIdentifier (stable user ID) as the ChatEndpoint rate limiter partition key, matching the partition strategy used by the global and MCP rate limiters. F4: Replace unconditional KnownProxies/KnownIPNetworks.Clear() with a config-driven trusted-proxy allowlist (ForwardedHeaders:TrustedProxyCidrs). When no CIDRs are configured the behaviour is unchanged (all forwarded headers trusted), but production deployments can now lock this down to their Azure Container Apps ingress CIDR to prevent X-Forwarded-For spoofing and IP-based rate-limit bypass. F7: Remove requiresCaptcha=true from the 429 rate-limit response body. The hCaptcha infrastructure exists (ICaptchaService, CaptchaOptions) but chat requests do not yet validate the CaptchaResponse token server-side, so advertising the field as a gate control was misleading. Updated the CaptchaResponse comment to clarify its pending integration status. --- .../Controllers/ChatMessageRequest.cs | 2 +- EssentialCSharp.Web/Program.cs | 55 +++++++++++-------- EssentialCSharp.Web/appsettings.json | 3 + 3 files changed, 37 insertions(+), 23 deletions(-) diff --git a/EssentialCSharp.Web/Controllers/ChatMessageRequest.cs b/EssentialCSharp.Web/Controllers/ChatMessageRequest.cs index c797febd..817cbecc 100644 --- a/EssentialCSharp.Web/Controllers/ChatMessageRequest.cs +++ b/EssentialCSharp.Web/Controllers/ChatMessageRequest.cs @@ -10,5 +10,5 @@ public class ChatMessageRequest [StringLength(200)] public string? PreviousResponseId { get; set; } public bool EnableContextualSearch { get; set; } = true; - public string? CaptchaResponse { get; set; } // For future captcha implementation + public string? CaptchaResponse { get; set; } // hCaptcha token; validated server-side when chat captcha enforcement is wired up } diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs index 10829c7a..9855f83d 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -112,11 +112,23 @@ private static void Main(string[] args) options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; - // Only loopback proxies are allowed by default. - // Clear that restriction because forwarders are enabled by explicit - // configuration. options.KnownIPNetworks.Clear(); options.KnownProxies.Clear(); + + // Restrict trusted proxy sources to configured CIDRs. + // SECURITY: Set ForwardedHeaders:TrustedProxyCidrs in production to your + // load-balancer IP range (e.g., the Azure Container Apps ingress CIDR) to + // prevent X-Forwarded-For spoofing. Without this, any client can fabricate + // a forwarded IP and bypass IP-partitioned rate limits. + var trustedCidrs = builder.Configuration + .GetSection("ForwardedHeaders:TrustedProxyCidrs") + .Get() ?? []; + + foreach (var cidr in trustedCidrs) + { + if (System.Net.IPNetwork.TryParse(cidr, out var network)) + options.KnownIPNetworks.Add(network); + } }); ConfigurationManager configuration = builder.Configuration; @@ -326,7 +338,7 @@ private static void Main(string[] args) { // Partitioned per-user (when authenticated) or per-IP (anonymous) var partitionKey = httpContext.User.Identity?.IsAuthenticated == true - ? $"chat-user:{httpContext.User.Identity.Name ?? "unknown-user"}" + ? $"chat-user:{httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "unknown-user"}" : $"chat-ip:{httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip"}"; return RateLimitPartition.GetFixedWindowLimiter( @@ -363,7 +375,6 @@ private static void Main(string[] args) Dictionary errorResponse = new() { ["error"] = "Rate limit exceeded. Please wait before sending another message.", - ["requiresCaptcha"] = true, ["statusCode"] = 429 }; if (retryAfterSeconds is int retryAfter) @@ -511,7 +522,7 @@ await McpJsonRpcResponseWriter.WriteErrorAsync( } app.UseStaticFiles(); - app.UseRouting(); + app.UseRouting(); app.UseWhen( context => context.Request.Path.StartsWithSegments("/mcp"), @@ -542,10 +553,10 @@ await McpJsonRpcResponseWriter.WriteErrorAsync( await next(context); })); - app.UseRateLimiter(); - - app.UseAuthorization(); - app.UseOutputCache(); + app.UseRateLimiter(); + + app.UseAuthorization(); + app.UseOutputCache(); app.UseMiddleware(); @@ -584,13 +595,13 @@ await McpJsonRpcResponseWriter.WriteErrorAsync( try { SitemapXmlHelpers.EnsureSitemapHealthy(siteMappingService.SiteMappings.ToList()); - LogSitemapValidationSucceeded(logger); - } - catch (Exception ex) - { - LogSitemapValidationFailed(logger, ex); - // Continue startup even if sitemap validation fails - } + LogSitemapValidationSucceeded(logger); + } + catch (Exception ex) + { + LogSitemapValidationFailed(logger, ex); + // Continue startup even if sitemap validation fails + } app.Run(); } @@ -604,11 +615,11 @@ private static bool IsMcpTransportRequest(HttpRequest request) => [LoggerMessage(Level = LogLevel.Error, Message = "Unhandled exception on {Path}")] private static partial void LogUnhandledException(ILogger logger, Exception? exception, PathString path); - [LoggerMessage(Level = LogLevel.Information, Message = "Sitemap validation completed successfully during application startup")] - private static partial void LogSitemapValidationSucceeded(ILogger logger); - - [LoggerMessage(Level = LogLevel.Error, Message = "Failed to validate sitemap during application startup")] - private static partial void LogSitemapValidationFailed(ILogger logger, Exception exception); + [LoggerMessage(Level = LogLevel.Information, Message = "Sitemap validation completed successfully during application startup")] + private static partial void LogSitemapValidationSucceeded(ILogger logger); + + [LoggerMessage(Level = LogLevel.Error, Message = "Failed to validate sitemap during application startup")] + private static partial void LogSitemapValidationFailed(ILogger logger, Exception exception); [LoggerMessage(Level = LogLevel.Warning, Message = "Ignoring invalid TryDotNet origin in CSP: {Origin}")] private static partial void LogIgnoringInvalidTryDotNetOrigin(ILogger logger, string origin); diff --git a/EssentialCSharp.Web/appsettings.json b/EssentialCSharp.Web/appsettings.json index fe9a4e35..3e73c768 100644 --- a/EssentialCSharp.Web/appsettings.json +++ b/EssentialCSharp.Web/appsettings.json @@ -2,6 +2,9 @@ "ConnectionStrings": { "PostgresVectorStore": "your-postgres-connection-string-here" }, + "ForwardedHeaders": { + "TrustedProxyCidrs": [] + }, "Logging": { "LogLevel": { "Default": "Warning", From fa37d5f47cb263d9497b9a69d7a9496ac375f018 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Wed, 13 May 2026 21:28:59 -0700 Subject: [PATCH 2/9] security: improve proxy warning to check resolved IOptions, unify unknown-user keys - Replace raw config-length check with IOptions post-Build DI resolution so warning fires for both 'not configured' and 'all CIDRs failed to parse' - Normalize ContentRateLimiterPolicy fallback to 'unknown-user' for consistency --- .../Controllers/ChatMessageRequest.cs | 1 - EssentialCSharp.Web/Program.cs | 17 ++++++++++++++++- .../Services/ContentRateLimiterPolicy.cs | 2 +- .../Services/McpRateLimiterPolicy.cs | 1 - 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/EssentialCSharp.Web/Controllers/ChatMessageRequest.cs b/EssentialCSharp.Web/Controllers/ChatMessageRequest.cs index 817cbecc..7cec84eb 100644 --- a/EssentialCSharp.Web/Controllers/ChatMessageRequest.cs +++ b/EssentialCSharp.Web/Controllers/ChatMessageRequest.cs @@ -10,5 +10,4 @@ public class ChatMessageRequest [StringLength(200)] public string? PreviousResponseId { get; set; } public bool EnableContextualSearch { get; set; } = true; - public string? CaptchaResponse { get; set; } // hCaptcha token; validated server-side when chat captcha enforcement is wired up } diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs index 9855f83d..9c1237df 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -128,6 +128,8 @@ private static void Main(string[] args) { if (System.Net.IPNetwork.TryParse(cidr, out var network)) options.KnownIPNetworks.Add(network); + else + Console.Error.WriteLine($"[WARN] ForwardedHeaders:TrustedProxyCidrs: could not parse '{cidr}' as an IP network — entry skipped. Check your configuration."); } }); @@ -320,7 +322,7 @@ private static void Main(string[] args) return RateLimitPartition.GetNoLimiter("mcp-transport"); var partitionKey = httpContext.User.Identity?.IsAuthenticated == true - ? httpContext.User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "unknown-user" + ? httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "unknown-user" : httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip"; return RateLimitPartition.GetFixedWindowLimiter( @@ -437,6 +439,16 @@ await context.HttpContext.Response.WriteAsync( WebApplication app = builder.Build(); + // Warn if the effective trusted-proxy set is empty in non-Development — this fires for + // both "no CIDRs configured" and "all configured CIDRs failed to parse" cases, ensuring + // X-Forwarded-For spoofing protection (F4) is visibly inactive until properly configured. + if (!app.Environment.IsDevelopment()) + { + var fwdOpts = app.Services.GetRequiredService>().Value; + if (fwdOpts.KnownIPNetworks.Count == 0 && fwdOpts.KnownProxies.Count == 0) + LogTrustedProxyCidrsNotConfigured(app.Logger); + } + // Configure the HTTP request pipeline. if (!app.Environment.IsDevelopment()) { @@ -623,4 +635,7 @@ private static bool IsMcpTransportRequest(HttpRequest request) => [LoggerMessage(Level = LogLevel.Warning, Message = "Ignoring invalid TryDotNet origin in CSP: {Origin}")] private static partial void LogIgnoringInvalidTryDotNetOrigin(ILogger logger, string origin); + + [LoggerMessage(Level = LogLevel.Warning, Message = "SECURITY: ForwardedHeaders:TrustedProxyCidrs is not configured. All X-Forwarded-For values are trusted, enabling IP spoofing against rate limits. Set this to your load-balancer CIDR (e.g., Azure Container Apps ingress range) to harden this endpoint.")] + private static partial void LogTrustedProxyCidrsNotConfigured(ILogger logger); } diff --git a/EssentialCSharp.Web/Services/ContentRateLimiterPolicy.cs b/EssentialCSharp.Web/Services/ContentRateLimiterPolicy.cs index a9b4d21e..0e125c56 100644 --- a/EssentialCSharp.Web/Services/ContentRateLimiterPolicy.cs +++ b/EssentialCSharp.Web/Services/ContentRateLimiterPolicy.cs @@ -26,7 +26,7 @@ public RateLimitPartition GetPartition(HttpContext httpContext) // Use stable user ID (GUID) for authenticated users so the bucket survives // username changes and doesn't conflate login/logout with scraping. string partitionKey = isAuthenticated - ? $"user:{httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? httpContext.User.Identity!.Name ?? "unknown"}" + ? $"user:{httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "unknown-user"}" : $"ip:{httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip"}"; int perMinuteLimit = isAuthenticated ? AuthenticatedPerMinute : AnonymousPerMinute; diff --git a/EssentialCSharp.Web/Services/McpRateLimiterPolicy.cs b/EssentialCSharp.Web/Services/McpRateLimiterPolicy.cs index 76438d0b..86eaa4e7 100644 --- a/EssentialCSharp.Web/Services/McpRateLimiterPolicy.cs +++ b/EssentialCSharp.Web/Services/McpRateLimiterPolicy.cs @@ -21,7 +21,6 @@ public RateLimitPartition GetPartition(HttpContext httpContext) if (httpContext.User.Identity?.IsAuthenticated == true) { string userId = httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) - ?? httpContext.User.Identity?.Name ?? "unknown-user"; return RateLimitPartition.GetTokenBucketLimiter( From 653055fd6693c2c24e6a4d142cfbd3e7e0af4ffa Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Wed, 13 May 2026 21:59:40 -0700 Subject: [PATCH 3/9] refactor: narrow sitemap catch clause to InvalidOperationException EnsureSitemapHealthy only throws InvalidOperationException; using a generic catch was overly broad. Addresses GitHub code quality feedback. --- EssentialCSharp.Web/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs index 9c1237df..183d778c 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -609,7 +609,7 @@ await McpJsonRpcResponseWriter.WriteErrorAsync( SitemapXmlHelpers.EnsureSitemapHealthy(siteMappingService.SiteMappings.ToList()); LogSitemapValidationSucceeded(logger); } - catch (Exception ex) + catch (InvalidOperationException ex) { LogSitemapValidationFailed(logger, ex); // Continue startup even if sitemap validation fails From 914fb94859a03459fd600d498d1ff07a0399d441 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Wed, 13 May 2026 22:16:26 -0700 Subject: [PATCH 4/9] feat: implement hCaptcha validation on chat endpoints Replaces the dead CaptchaResponse comment with a working implementation: Server-side (ChatController): - Inject ICaptchaService + IOptions - Validate hCaptcha token before processing each message - Graceful degradation: skipped when SecretKey is not configured (dev) - Fail-open on hCaptcha outages to avoid blocking legitimate users - Returns 403 + { errorCode: 'captcha_failed' } on invalid tokens Client-side (chat-module.js / ChatWidget.vue): - Invisible hCaptcha widget rendered outside v-if dialog so it persists across open/close cycles and only initializes once - getCaptchaToken() wraps execute() in a Promise; resolves instantly for non-suspicious users (invisible mode) - Token included as captchaResponse in every request body - Widget reset after each send (success or failure) for a fresh token - 403 responses mapped to captcha-error type in the UI - hCaptcha legal disclosure shown in footer when captcha is configured Layout: - Expose window.HCAPTCHA_SITE_KEY from CaptchaOptions for the JS layer --- .../Controllers/ChatController.cs | 42 +++++++++- .../Controllers/ChatMessageRequest.cs | 6 ++ .../Views/Shared/_Layout.cshtml | 3 + .../src/components/ChatWidget.vue | 20 +++++ EssentialCSharp.Web/wwwroot/js/chat-module.js | 77 ++++++++++++++++++- 5 files changed, 146 insertions(+), 2 deletions(-) diff --git a/EssentialCSharp.Web/Controllers/ChatController.cs b/EssentialCSharp.Web/Controllers/ChatController.cs index 0fcd8577..d1ae20bf 100644 --- a/EssentialCSharp.Web/Controllers/ChatController.cs +++ b/EssentialCSharp.Web/Controllers/ChatController.cs @@ -1,10 +1,12 @@ using System.Security.Claims; using System.Text.Json; using EssentialCSharp.Chat.Common.Services; +using EssentialCSharp.Web.Models; using EssentialCSharp.Web.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.Options; namespace EssentialCSharp.Web.Controllers; @@ -17,15 +19,40 @@ public partial class ChatController : ControllerBase { private readonly AIChatService _AiChatService; private readonly ResponseIdValidationService _ResponseIdValidationService; + private readonly ICaptchaService _CaptchaService; + private readonly CaptchaOptions _CaptchaOptions; private readonly ILogger _Logger; - public ChatController(ILogger logger, AIChatService aiChatService, ResponseIdValidationService responseIdValidationService) + public ChatController(ILogger logger, AIChatService aiChatService, + ResponseIdValidationService responseIdValidationService, + ICaptchaService captchaService, IOptions captchaOptions) { _AiChatService = aiChatService; _ResponseIdValidationService = responseIdValidationService; + _CaptchaService = captchaService; + _CaptchaOptions = captchaOptions.Value; _Logger = logger; } + /// + /// Validates the hCaptcha token when captcha is configured. + /// Returns true when captcha is not configured (dev mode) or when the token is valid. + /// Fails open on hCaptcha service outages to avoid blocking legitimate users. + /// + private async Task IsCaptchaValidAsync(string? token, string? remoteIp, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(_CaptchaOptions.SecretKey)) + return true; // captcha not configured — skip validation + + HCaptchaResult? result = await _CaptchaService.VerifyAsync(token, remoteIp, ct); + if (result is null) + { + LogCaptchaServiceUnavailable(_Logger); // hCaptcha unreachable — fail open + return true; + } + return result.Success; + } + [HttpPost("message")] public async Task SendMessage([FromBody] ChatMessageRequest request, CancellationToken cancellationToken = default) { @@ -37,6 +64,9 @@ public async Task SendMessage([FromBody] ChatMessageRequest reque if (string.IsNullOrEmpty(userId)) return Unauthorized(); + if (!await IsCaptchaValidAsync(request.CaptchaResponse, HttpContext.Connection.RemoteIpAddress?.ToString(), cancellationToken)) + return StatusCode(403, new { error = "Human verification required.", errorCode = "captcha_failed" }); + var previousResponseId = string.IsNullOrWhiteSpace(request.PreviousResponseId) ? null : request.PreviousResponseId.Trim(); @@ -87,6 +117,13 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat return; } + if (!await IsCaptchaValidAsync(request.CaptchaResponse, HttpContext.Connection.RemoteIpAddress?.ToString(), cancellationToken)) + { + Response.StatusCode = 403; + await Response.WriteAsJsonAsync(new { error = "Human verification required.", errorCode = "captcha_failed" }, CancellationToken.None); + return; + } + var previousResponseId = string.IsNullOrWhiteSpace(request.PreviousResponseId) ? null : request.PreviousResponseId.Trim(); @@ -184,6 +221,9 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat } } + [LoggerMessage(Level = LogLevel.Warning, Message = "hCaptcha service unavailable during chat request — failing open")] + private static partial void LogCaptchaServiceUnavailable(ILogger logger); + [LoggerMessage(Level = LogLevel.Debug, Message = "Chat stream cancelled for user {User}")] private static partial void LogChatStreamCancelled(ILogger logger, string? user); diff --git a/EssentialCSharp.Web/Controllers/ChatMessageRequest.cs b/EssentialCSharp.Web/Controllers/ChatMessageRequest.cs index 7cec84eb..ca05d5ee 100644 --- a/EssentialCSharp.Web/Controllers/ChatMessageRequest.cs +++ b/EssentialCSharp.Web/Controllers/ChatMessageRequest.cs @@ -10,4 +10,10 @@ public class ChatMessageRequest [StringLength(200)] public string? PreviousResponseId { get; set; } public bool EnableContextualSearch { get; set; } = true; + /// + /// hCaptcha token obtained from the client-side invisible widget. + /// Required when CaptchaOptions.SecretKey is configured; ignored otherwise. + /// + [StringLength(2000)] + public string? CaptchaResponse { get; set; } } diff --git a/EssentialCSharp.Web/Views/Shared/_Layout.cshtml b/EssentialCSharp.Web/Views/Shared/_Layout.cshtml index 3f19ac18..02e649a9 100644 --- a/EssentialCSharp.Web/Views/Shared/_Layout.cshtml +++ b/EssentialCSharp.Web/Views/Shared/_Layout.cshtml @@ -6,9 +6,11 @@ @using Microsoft.AspNetCore.Identity @using EssentialCSharp.Web.Areas.Identity.Data @using Microsoft.Extensions.Configuration +@using Microsoft.Extensions.Options @inject ISiteMappingService _SiteMappings @inject SignInManager SignInManager @inject IConfiguration Configuration +@inject IOptions _CaptchaOptions @@ -192,6 +194,7 @@ window.TRYDOTNET_ORIGIN = @Json.Serialize(Configuration["TryDotNet:Origin"]); window.BUILD_LABEL = @Json.Serialize(buildLabel); window.ENABLE_CHAT_WIDGET = @Json.Serialize(!Context.Request.Path.StartsWithSegments("/Identity")); + window.HCAPTCHA_SITE_KEY = @Json.Serialize(_CaptchaOptions.Value.SiteKey); diff --git a/EssentialCSharp.Web/src/components/ChatWidget.vue b/EssentialCSharp.Web/src/components/ChatWidget.vue index b7b0820f..ff74fc7a 100644 --- a/EssentialCSharp.Web/src/components/ChatWidget.vue +++ b/EssentialCSharp.Web/src/components/ChatWidget.vue @@ -11,6 +11,7 @@ const { isTyping, chatMessagesEl, chatInputField, + captchaSiteKey, openChatDialog, closeChatDialog, clearChatHistory, @@ -23,6 +24,18 @@ const {