Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion EssentialCSharp.Web/Controllers/ChatController.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -17,15 +19,49 @@ public partial class ChatController : ControllerBase
{
private readonly AIChatService _AiChatService;
private readonly ResponseIdValidationService _ResponseIdValidationService;
private readonly ICaptchaService _CaptchaService;
private readonly CaptchaOptions _CaptchaOptions;
private readonly ILogger<ChatController> _Logger;

public ChatController(ILogger<ChatController> logger, AIChatService aiChatService, ResponseIdValidationService responseIdValidationService)
public ChatController(ILogger<ChatController> logger, AIChatService aiChatService,
ResponseIdValidationService responseIdValidationService,
ICaptchaService captchaService, IOptions<CaptchaOptions> captchaOptions)
{
_AiChatService = aiChatService;
_ResponseIdValidationService = responseIdValidationService;
_CaptchaService = captchaService;
_CaptchaOptions = captchaOptions.Value;
_Logger = logger;
}

/// <summary>
/// Validates the hCaptcha token when captcha is configured.
/// Returns <c>true</c> when captcha is not configured (dev mode) or when the token is valid.
/// Fails open on hCaptcha service outages (null result) to avoid blocking legitimate users —
/// this is intentional given the existing [Authorize] + rate-limiting backstop.
/// </summary>
private async Task<bool> IsCaptchaValidAsync(string? token, string? remoteIp, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(_CaptchaOptions.SecretKey))
return true; // captcha not configured — skip validation

if (string.IsNullOrWhiteSpace(token))
return false; // token required when captcha is configured — reject without an outbound call

HCaptchaResult? result = await _CaptchaService.VerifyAsync(token, remoteIp, ct);
if (result is null)
{
LogCaptchaServiceUnavailable(_Logger); // hCaptcha unreachable — fail open (intentional)
return true;
}

if (!result.Success)
{
LogCaptchaValidationFailed(_Logger, string.Join(',', result.ErrorCodes ?? []));
}
return result.Success;
}

[HttpPost("message")]
public async Task<IActionResult> SendMessage([FromBody] ChatMessageRequest request, CancellationToken cancellationToken = default)
{
Expand All @@ -37,6 +73,9 @@ public async Task<IActionResult> 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();
Expand Down Expand Up @@ -87,6 +126,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();
Expand Down Expand Up @@ -184,6 +230,12 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat
}
}

[LoggerMessage(Level = LogLevel.Warning, Message = "hCaptcha service unavailable during chat request — failing open")]
Comment thread
BenjaminMichaelis marked this conversation as resolved.
Outdated
private static partial void LogCaptchaServiceUnavailable(ILogger<ChatController> logger);

[LoggerMessage(Level = LogLevel.Warning, Message = "hCaptcha validation failed for chat request — error codes: {ErrorCodes}")]
private static partial void LogCaptchaValidationFailed(ILogger<ChatController> logger, string errorCodes);

[LoggerMessage(Level = LogLevel.Debug, Message = "Chat stream cancelled for user {User}")]
private static partial void LogChatStreamCancelled(ILogger<ChatController> logger, string? user);

Expand Down
7 changes: 6 additions & 1 deletion EssentialCSharp.Web/Controllers/ChatMessageRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,10 @@ 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
/// <summary>
/// hCaptcha token obtained from the client-side invisible widget.
/// Required when <c>CaptchaOptions.SecretKey</c> is configured; ignored otherwise.
/// </summary>
[StringLength(2000)]
public string? CaptchaResponse { get; set; }
Comment thread
BenjaminMichaelis marked this conversation as resolved.
}
72 changes: 49 additions & 23 deletions EssentialCSharp.Web/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -112,11 +112,25 @@ 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();
Comment thread
BenjaminMichaelis marked this conversation as resolved.
Outdated

// 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<string[]>() ?? [];

foreach (var cidr in trustedCidrs)
{
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.");
}
});

ConfigurationManager configuration = builder.Configuration;
Expand Down Expand Up @@ -308,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(
Expand All @@ -326,7 +340,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(
Expand Down Expand Up @@ -363,7 +377,6 @@ private static void Main(string[] args)
Dictionary<string, object> errorResponse = new()
{
["error"] = "Rate limit exceeded. Please wait before sending another message.",
["requiresCaptcha"] = true,
["statusCode"] = 429
Comment thread
BenjaminMichaelis marked this conversation as resolved.
};
if (retryAfterSeconds is int retryAfter)
Expand Down Expand Up @@ -426,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<IOptions<ForwardedHeadersOptions>>().Value;
if (fwdOpts.KnownIPNetworks.Count == 0 && fwdOpts.KnownProxies.Count == 0)
LogTrustedProxyCidrsNotConfigured(app.Logger);
Comment thread
BenjaminMichaelis marked this conversation as resolved.
Outdated
}

// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
Expand Down Expand Up @@ -511,7 +534,7 @@ await McpJsonRpcResponseWriter.WriteErrorAsync(
}
app.UseStaticFiles();

app.UseRouting();
app.UseRouting();

app.UseWhen(
context => context.Request.Path.StartsWithSegments("/mcp"),
Expand Down Expand Up @@ -542,10 +565,10 @@ await McpJsonRpcResponseWriter.WriteErrorAsync(
await next(context);
}));

app.UseRateLimiter();

app.UseAuthorization();
app.UseOutputCache();
app.UseRateLimiter();
app.UseAuthorization();
app.UseOutputCache();

app.UseMiddleware<ReferralMiddleware>();

Expand Down Expand Up @@ -584,13 +607,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 (InvalidOperationException ex)
{
LogSitemapValidationFailed(logger, ex);
// Continue startup even if sitemap validation fails
}

app.Run();
}
Expand All @@ -604,12 +627,15 @@ private static bool IsMcpTransportRequest(HttpRequest request) =>
[LoggerMessage(Level = LogLevel.Error, Message = "Unhandled exception on {Path}")]
private static partial void LogUnhandledException(ILogger<Program> logger, Exception? exception, PathString path);

[LoggerMessage(Level = LogLevel.Information, Message = "Sitemap validation completed successfully during application startup")]
private static partial void LogSitemapValidationSucceeded(ILogger<Program> logger);

[LoggerMessage(Level = LogLevel.Error, Message = "Failed to validate sitemap during application startup")]
private static partial void LogSitemapValidationFailed(ILogger<Program> logger, Exception exception);
[LoggerMessage(Level = LogLevel.Information, Message = "Sitemap validation completed successfully during application startup")]
private static partial void LogSitemapValidationSucceeded(ILogger<Program> logger);
[LoggerMessage(Level = LogLevel.Error, Message = "Failed to validate sitemap during application startup")]
private static partial void LogSitemapValidationFailed(ILogger<Program> logger, Exception exception);

[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);
}
2 changes: 1 addition & 1 deletion EssentialCSharp.Web/Services/ContentRateLimiterPolicy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public RateLimitPartition<string> 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;
Expand Down
1 change: 0 additions & 1 deletion EssentialCSharp.Web/Services/McpRateLimiterPolicy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ public RateLimitPartition<string> 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(
Expand Down
3 changes: 3 additions & 0 deletions EssentialCSharp.Web/Views/Shared/_Layout.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -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<EssentialCSharpWebUser> SignInManager
@inject IConfiguration Configuration
@inject IOptions<CaptchaOptions> _CaptchaOptions
<!DOCTYPE html>
<html lang="en">
<head>
Expand Down Expand Up @@ -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);
Comment thread
BenjaminMichaelis marked this conversation as resolved.
Outdated
</script>
<script src="~/dist/assets/site-shell.js" type="module" asp-append-version="true"></script>
</body>
Expand Down
3 changes: 3 additions & 0 deletions EssentialCSharp.Web/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
"ConnectionStrings": {
"PostgresVectorStore": "your-postgres-connection-string-here"
},
"ForwardedHeaders": {
"TrustedProxyCidrs": []
},
Comment thread
BenjaminMichaelis marked this conversation as resolved.
Outdated
"Logging": {
"LogLevel": {
"Default": "Warning",
Expand Down
20 changes: 20 additions & 0 deletions EssentialCSharp.Web/src/components/ChatWidget.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const {
isTyping,
chatMessagesEl,
chatInputField,
captchaSiteKey,
openChatDialog,
closeChatDialog,
clearChatHistory,
Expand All @@ -23,6 +24,18 @@ const {

<template>
<div class="chat-widget">
<!--
Invisible hCaptcha container: lives outside the v-if dialog so the widget
persists across open/close cycles and only needs to be initialized once.
Renders only when captcha is configured (HCAPTCHA_SITE_KEY is non-null).
-->
<div
v-if="captchaSiteKey"
id="chat-captcha-container"
class="visually-hidden"
aria-hidden="true"
/>

<button
class="chat-button elevation-6"
:class="{ 'chat-button--active': showChatDialog }"
Expand Down Expand Up @@ -189,6 +202,13 @@ const {
Type your question and press Enter or click send. Maximum 500 characters.
</div>
</form>
<!-- hCaptcha legal disclosure required for invisible mode -->
<p v-if="captchaSiteKey" class="captcha-notice small text-muted mt-1">
Protected by hCaptcha —
<a href="https://www.hcaptcha.com/privacy" target="_blank" rel="noopener noreferrer">Privacy</a>
&amp;
<a href="https://www.hcaptcha.com/terms" target="_blank" rel="noopener noreferrer">Terms</a>
</p>
</div>
</div>
</div>
Expand Down
Loading
Loading