Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
1 change: 0 additions & 1 deletion EssentialCSharp.Web/Controllers/ChatMessageRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; } // For future captcha implementation
}
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/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
Loading