Skip to content

Commit 65e0b60

Browse files
security: AI agent hardening — rate limiter keys, trusted proxy config, captcha cleanup (#1102)
This pull request adds invisible hCaptcha support to the chat widget and strengthens security around trusted proxy handling and rate limiting. The most important changes include server- and client-side hCaptcha integration, improved trusted proxy CIDR configuration for forwarded headers, and several security and code quality improvements. **hCaptcha Integration:** * Added invisible hCaptcha validation to chat endpoints in `ChatController`, including a new `IsCaptchaValidAsync` method, dependency injection of `ICaptchaService` and options, and logging for captcha failures and service outages. The endpoints now require a valid hCaptcha token when configured, failing open only if the hCaptcha service is unavailable. [[1]](diffhunk://#diff-4a94a1b44a0792e8b86260b0a32f20ffa07e3df954a2686e75fd2f2e439d61c3R4-R9) [[2]](diffhunk://#diff-4a94a1b44a0792e8b86260b0a32f20ffa07e3df954a2686e75fd2f2e439d61c3R22-R64) [[3]](diffhunk://#diff-4a94a1b44a0792e8b86260b0a32f20ffa07e3df954a2686e75fd2f2e439d61c3R76-R78) [[4]](diffhunk://#diff-4a94a1b44a0792e8b86260b0a32f20ffa07e3df954a2686e75fd2f2e439d61c3R129-R135) [[5]](diffhunk://#diff-4a94a1b44a0792e8b86260b0a32f20ffa07e3df954a2686e75fd2f2e439d61c3R233-R238) * Updated `ChatMessageRequest` to require and document the `CaptchaResponse` field, with validation and a larger maximum length. * Passed the hCaptcha site key from server to client via `_Layout.cshtml`, and rendered the invisible widget container and legal disclosure in the chat UI only when captcha is enabled. [[1]](diffhunk://#diff-99124c9dc22814eb65ae9a3d958e2657465c737eb30cbdeb58316c2adeef74c0R9-R13) [[2]](diffhunk://#diff-99124c9dc22814eb65ae9a3d958e2657465c737eb30cbdeb58316c2adeef74c0R197) [[3]](diffhunk://#diff-a3f79adcd2637b729b61fe3c2d59b4dc5b693f3603862fc546b993b65487cfa4R14) [[4]](diffhunk://#diff-a3f79adcd2637b729b61fe3c2d59b4dc5b693f3603862fc546b993b65487cfa4R27-R38) [[5]](diffhunk://#diff-a3f79adcd2637b729b61fe3c2d59b4dc5b693f3603862fc546b993b65487cfa4R205-R211) * Implemented client-side hCaptcha integration in `chat-module.js`, including widget initialization, token retrieval with timeout and error handling, and reset logic. **Trusted Proxy and Rate Limiting Security:** * Enhanced trusted proxy CIDR configuration for forwarded headers in `Program.cs`, including parsing from configuration, warnings for missing/invalid configuration, and detailed comments about the security implications of not setting this value. [[1]](diffhunk://#diff-18732233c16eb367b49fa9d7a6f04dcffb924031136727c74868735c15885102L115-R133) [[2]](diffhunk://#diff-18732233c16eb367b49fa9d7a6f04dcffb924031136727c74868735c15885102R442-R451) [[3]](diffhunk://#diff-e47670b0d9a1e97097e60cb20a1dbf08655ba40a8196819edf43617bb0390973R5-R7) [[4]](diffhunk://#diff-18732233c16eb367b49fa9d7a6f04dcffb924031136727c74868735c15885102R638-R640) * Updated rate limiter partitioning to consistently use `ClaimTypes.NameIdentifier` for user partition keys, improving stability and reducing risk of key conflation. [[1]](diffhunk://#diff-18732233c16eb367b49fa9d7a6f04dcffb924031136727c74868735c15885102L311-R325) [[2]](diffhunk://#diff-18732233c16eb367b49fa9d7a6f04dcffb924031136727c74868735c15885102L329-R343) [[3]](diffhunk://#diff-841708f0e87c8eb4799aabe8afab12259bdb8ac1baaad89a240b48bf4de5cd93L29-R29) [[4]](diffhunk://#diff-844f40bab2fc81e7d7bbca30e8fdb1a9e954586ea54660ffadee57b6eb548f72L24) **Other Improvements:** * Removed the unused `requiresCaptcha` field from the rate limit error response. * Changed sitemap validation exception handling to only catch `InvalidOperationException`, making error handling more precise. These changes collectively add robust human verification to the chat feature, improve rate limiting and IP spoofing protections, and enhance code maintainability.
1 parent ca6b88a commit 65e0b60

8 files changed

Lines changed: 229 additions & 20 deletions

File tree

EssentialCSharp.Web/Controllers/ChatController.cs

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22
using System.Security.Claims;
33
using System.Text.Json;
44
using EssentialCSharp.Chat.Common.Services;
5+
using EssentialCSharp.Web.Models;
56
using EssentialCSharp.Web.Services;
67
using Microsoft.AspNetCore.Authorization;
78
using Microsoft.AspNetCore.Mvc;
89
using Microsoft.AspNetCore.RateLimiting;
10+
using Microsoft.Extensions.Options;
911

1012
namespace EssentialCSharp.Web.Controllers;
1113

@@ -18,15 +20,49 @@ public partial class ChatController : ControllerBase
1820
{
1921
private readonly AIChatService _AIChatService;
2022
private readonly ResponseIdValidationService _ResponseIdValidationService;
23+
private readonly ICaptchaService _CaptchaService;
24+
private readonly CaptchaOptions _CaptchaOptions;
2125
private readonly ILogger<ChatController> _Logger;
2226

23-
public ChatController(ILogger<ChatController> logger, AIChatService aiChatService, ResponseIdValidationService responseIdValidationService)
27+
public ChatController(ILogger<ChatController> logger, AIChatService aiChatService,
28+
ResponseIdValidationService responseIdValidationService,
29+
ICaptchaService captchaService, IOptions<CaptchaOptions> captchaOptions)
2430
{
2531
_AIChatService = aiChatService;
2632
_ResponseIdValidationService = responseIdValidationService;
33+
_CaptchaService = captchaService;
34+
_CaptchaOptions = captchaOptions.Value;
2735
_Logger = logger;
2836
}
2937

38+
/// <summary>
39+
/// Validates the hCaptcha token when captcha is configured.
40+
/// Returns <c>true</c> when captcha is not configured (dev mode) or when the token is valid.
41+
/// Returns <c>false</c> for missing or invalid tokens.
42+
/// Returns <c>null</c> when hCaptcha cannot be reached, so the caller can fail closed.
43+
/// </summary>
44+
private async Task<bool?> IsCaptchaValidAsync(string? token, string? remoteIp, CancellationToken ct)
45+
{
46+
if (string.IsNullOrWhiteSpace(_CaptchaOptions.SecretKey))
47+
return true; // captcha not configured — skip validation
48+
49+
if (string.IsNullOrWhiteSpace(token))
50+
return false; // token required when captcha is configured — reject without an outbound call
51+
52+
HCaptchaResult? result = await _CaptchaService.VerifyAsync(token, remoteIp, ct);
53+
if (result is null)
54+
{
55+
LogCaptchaServiceUnavailable(_Logger); // hCaptcha unreachable — fail closed
56+
return null;
57+
}
58+
59+
if (!result.Success)
60+
{
61+
LogCaptchaValidationFailed(_Logger, string.Join(',', result.ErrorCodes ?? []));
62+
}
63+
return result.Success;
64+
}
65+
3066
[HttpPost("message")]
3167
public async Task<IActionResult> SendMessage([FromBody] ChatMessageRequest request, CancellationToken cancellationToken = default)
3268
{
@@ -38,6 +74,12 @@ public async Task<IActionResult> SendMessage([FromBody] ChatMessageRequest reque
3874
if (string.IsNullOrEmpty(userId))
3975
return Unauthorized();
4076

77+
bool? captchaValid = await IsCaptchaValidAsync(request.CaptchaResponse, HttpContext.Connection.RemoteIpAddress?.ToString(), cancellationToken);
78+
if (captchaValid is null)
79+
return StatusCode(503, new { error = "Human verification is temporarily unavailable. Please try again later.", errorCode = "captcha_unavailable" });
80+
if (!captchaValid.Value)
81+
return StatusCode(403, new { error = "Human verification required.", errorCode = "captcha_failed" });
82+
4183
var previousResponseId = string.IsNullOrWhiteSpace(request.PreviousResponseId)
4284
? null
4385
: request.PreviousResponseId.Trim();
@@ -88,6 +130,20 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat
88130
return;
89131
}
90132

133+
bool? captchaValid = await IsCaptchaValidAsync(request.CaptchaResponse, HttpContext.Connection.RemoteIpAddress?.ToString(), cancellationToken);
134+
if (captchaValid is null)
135+
{
136+
Response.StatusCode = 503;
137+
await Response.WriteAsJsonAsync(new { error = "Human verification is temporarily unavailable. Please try again later.", errorCode = "captcha_unavailable" }, cancellationToken);
138+
return;
139+
}
140+
if (!captchaValid.Value)
141+
{
142+
Response.StatusCode = 403;
143+
await Response.WriteAsJsonAsync(new { error = "Human verification required.", errorCode = "captcha_failed" }, cancellationToken);
144+
return;
145+
}
146+
91147
var previousResponseId = string.IsNullOrWhiteSpace(request.PreviousResponseId)
92148
? null
93149
: request.PreviousResponseId.Trim();
@@ -234,6 +290,12 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat
234290
}
235291
}
236292

293+
[LoggerMessage(Level = LogLevel.Warning, Message = "hCaptcha service unavailable during chat request — failing closed (503)")]
294+
private static partial void LogCaptchaServiceUnavailable(ILogger<ChatController> logger);
295+
296+
[LoggerMessage(Level = LogLevel.Warning, Message = "hCaptcha validation failed for chat request — error codes: {ErrorCodes}")]
297+
private static partial void LogCaptchaValidationFailed(ILogger<ChatController> logger, string errorCodes);
298+
237299
[LoggerMessage(Level = LogLevel.Debug, Message = "Chat stream cancelled for user {User}")]
238300
private static partial void LogChatStreamCancelled(ILogger<ChatController> logger, string? user);
239301

EssentialCSharp.Web/Controllers/ChatMessageRequest.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,10 @@ public class ChatMessageRequest
1010
[StringLength(200)]
1111
public string? PreviousResponseId { get; set; }
1212
public bool EnableContextualSearch { get; set; } = true;
13-
public string? CaptchaResponse { get; set; } // For future captcha implementation
13+
/// <summary>
14+
/// hCaptcha token obtained from the client-side invisible widget.
15+
/// Required when <c>CaptchaOptions.SecretKey</c> is configured; ignored otherwise.
16+
/// </summary>
17+
[StringLength(2000)]
18+
public string? CaptchaResponse { get; set; }
1419
}

EssentialCSharp.Web/Program.cs

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -135,8 +135,6 @@ private static void Main(string[] args)
135135
c.Timeout = TimeSpan.FromSeconds(3);
136136
});
137137

138-
139-
140138
builder.Services.AddTrustedForwardedHeaders(builder.Configuration, builder.Environment);
141139

142140
ConfigurationManager configuration = builder.Configuration;
@@ -330,7 +328,7 @@ private static void Main(string[] args)
330328
return RateLimitPartition.GetNoLimiter("mcp-transport");
331329

332330
var partitionKey = httpContext.User.Identity?.IsAuthenticated == true
333-
? httpContext.User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "unknown-user"
331+
? httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "unknown-user"
334332
: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip";
335333

336334
return RateLimitPartition.GetFixedWindowLimiter(
@@ -348,7 +346,7 @@ private static void Main(string[] args)
348346
{
349347
// Partitioned per-user (when authenticated) or per-IP (anonymous)
350348
var partitionKey = httpContext.User.Identity?.IsAuthenticated == true
351-
? $"chat-user:{httpContext.User.Identity.Name ?? "unknown-user"}"
349+
? $"chat-user:{httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "unknown-user"}"
352350
: $"chat-ip:{httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip"}";
353351

354352
return RateLimitPartition.GetFixedWindowLimiter(
@@ -385,7 +383,6 @@ private static void Main(string[] args)
385383
Dictionary<string, object> errorResponse = new()
386384
{
387385
["error"] = "Rate limit exceeded. Please wait before sending another message.",
388-
["requiresCaptcha"] = true,
389386
["statusCode"] = 429
390387
};
391388
if (retryAfterSeconds is int retryAfter)
@@ -637,16 +634,6 @@ await McpJsonRpcResponseWriter.WriteErrorAsync(
637634
LogSitemapValidationFailed(logger, ex);
638635
// Continue startup even if sitemap validation fails
639636
}
640-
catch (ArgumentException ex)
641-
{
642-
LogSitemapValidationFailed(logger, ex);
643-
// Continue startup even if sitemap validation fails
644-
}
645-
catch (FormatException ex)
646-
{
647-
LogSitemapValidationFailed(logger, ex);
648-
// Continue startup even if sitemap validation fails
649-
}
650637

651638
app.Run();
652639
}

EssentialCSharp.Web/Services/ContentRateLimiterPolicy.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ public RateLimitPartition<string> GetPartition(HttpContext httpContext)
2626
// Use stable user ID (GUID) for authenticated users so the bucket survives
2727
// username changes and doesn't conflate login/logout with scraping.
2828
string partitionKey = isAuthenticated
29-
? $"user:{httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? httpContext.User.Identity!.Name ?? "unknown"}"
29+
? $"user:{httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "unknown-user"}"
3030
: $"ip:{httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip"}";
3131

3232
int perMinuteLimit = isAuthenticated ? AuthenticatedPerMinute : AnonymousPerMinute;

EssentialCSharp.Web/Services/McpRateLimiterPolicy.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ public RateLimitPartition<string> GetPartition(HttpContext httpContext)
2121
if (httpContext.User.Identity?.IsAuthenticated == true)
2222
{
2323
string userId = httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier)
24-
?? httpContext.User.Identity?.Name
2524
?? "unknown-user";
2625

2726
return RateLimitPartition.GetTokenBucketLimiter(

EssentialCSharp.Web/Views/Shared/_Layout.cshtml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@
77
@using Microsoft.AspNetCore.Identity
88
@using EssentialCSharp.Web.Areas.Identity.Data
99
@using Microsoft.Extensions.Configuration
10+
@using Microsoft.Extensions.Options
1011
@inject ISiteMappingService _SiteMappings
1112
@inject SignInManager<EssentialCSharpWebUser> SignInManager
1213
@inject IConfiguration Configuration
14+
@inject IOptions<CaptchaOptions> _CaptchaOptions
1315
<!DOCTYPE html>
1416
<html lang="en">
1517
<head>
@@ -193,6 +195,10 @@
193195
var buildLabel = ReleaseDateAttribute.GetReleaseDate() is DateTime date
194196
? TimeZoneInfo.ConvertTimeFromUtc(date, TimeZoneInfo.FindSystemTimeZoneById("Pacific Standard Time")).ToString("d MMM, yyyy h:mm:ss tt", CultureInfo.InvariantCulture)
195197
: null;
198+
var chatCaptchaSiteKey = !string.IsNullOrWhiteSpace(_CaptchaOptions.Value.SecretKey)
199+
&& !string.IsNullOrWhiteSpace(_CaptchaOptions.Value.SiteKey)
200+
? _CaptchaOptions.Value.SiteKey
201+
: null;
196202
}
197203
window.PERCENT_COMPLETE = @Json.Serialize(percentComplete);
198204
window.PREVIOUS_PAGE = @Json.Serialize(ViewBag.PreviousPage);
@@ -204,6 +210,7 @@
204210
window.APPLICATIONINSIGHTS_CONNECTION_STRING = @Json.Serialize(Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]);
205211
window.BUILD_LABEL = @Json.Serialize(buildLabel);
206212
window.ENABLE_CHAT_WIDGET = @Json.Serialize(!Context.Request.Path.StartsWithSegments("/Identity"));
213+
window.HCAPTCHA_SITE_KEY = @Json.Serialize(chatCaptchaSiteKey);
207214
</script>
208215
<script src="~/dist/assets/site-shell.js" type="module" asp-append-version="true"></script>
209216
</body>

EssentialCSharp.Web/src/components/ChatWidget.vue

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const {
1111
isTyping,
1212
chatMessagesEl,
1313
chatInputField,
14+
captchaSiteKey,
1415
openChatDialog,
1516
closeChatDialog,
1617
clearChatHistory,
@@ -23,6 +24,18 @@ const {
2324

2425
<template>
2526
<div class="chat-widget">
27+
<!--
28+
Invisible hCaptcha container: lives outside the v-if dialog so the widget
29+
persists across open/close cycles and only needs to be initialized once.
30+
Renders only when captcha is configured (HCAPTCHA_SITE_KEY is non-null).
31+
-->
32+
<div
33+
v-if="captchaSiteKey"
34+
id="chat-captcha-container"
35+
class="visually-hidden"
36+
aria-hidden="true"
37+
/>
38+
2639
<button
2740
class="chat-button elevation-6"
2841
:class="{ 'chat-button--active': showChatDialog }"
@@ -189,6 +202,13 @@ const {
189202
Type your question and press Enter or click send. Maximum 500 characters.
190203
</div>
191204
</form>
205+
<!-- hCaptcha legal disclosure required for invisible mode -->
206+
<p v-if="captchaSiteKey" class="captcha-notice small text-muted mt-1">
207+
Protected by hCaptcha —
208+
<a href="https://www.hcaptcha.com/privacy" target="_blank" rel="noopener noreferrer">Privacy</a>
209+
&amp;
210+
<a href="https://www.hcaptcha.com/terms" target="_blank" rel="noopener noreferrer">Terms</a>
211+
</p>
192212
</div>
193213
</div>
194214
</div>

0 commit comments

Comments
 (0)