Repository navigation
fix(api): ignore forwarded headers when no proxy is trusted, and close MCP audit gaps - #1260
Merged
Merged
Conversation
…P audit gaps - Log RateLimiting.ForwardedHeadersIgnored at Warning, once per process, when X-Forwarded-For arrives from a peer outside RateLimiting:TrustedProxies. Every client behind that peer shares one address in the login limiter and the /mcp failure budget and in-flight cap, so one caller can spend it for all. - cluckwork_record_daily_entry refuses a null or blank flock with "flock is required." before the idempotency claim, instead of a NullReferenceException logged at Error. - cluckwork_record_daily_entry trims grade names, as the read tools do. - ToolSchemas_AreServedForEveryTool_WithoutNull asserts served == declared.
ASP.NET's ForwardedHeadersMiddleware trusts every peer when its trust lists are empty, so under RateLimiting:AllowNoTrustedProxies any client could set its own address for the per-IP limits and its own scheme. With an empty RateLimiting:TrustedProxies the forwarding middleware is now not added. RateLimiting.ForwardedHeadersIgnored follows the framework's trust decision: it fires only when the list is non-empty and the peer is in neither KnownIPNetworks nor KnownProxies. Tests: an opt-out host over Kestrel keeps the real peer and scheme and logs no warning; a KnownProxies peer does not warn; a null flock is refused before any idempotency claim.
ASPNETCORE_FORWARDEDHEADERS_ENABLED=true makes the default host add its own ForwardedHeadersMiddleware through a startup filter, so not adding the app's middleware left that path trusting every peer under an empty RateLimiting:TrustedProxies. An empty list now sets ForwardedHeaders.None, which also governs the host's middleware; the app's helper adds neither middleware when forwarding is off. A Kestrel host with ForwardedHeaders_Enabled=true and no trusted proxy keeps the socket address and the http scheme under spoofed X-Forwarded-For and X-Forwarded-Proto.
Owner
Author
|
Consent screen from the live run, captured at 1:1 from the isolated stack built at 6ef8d27. Later commits on this branch change only forwarded-header handling, so this screen is the same at the head. The TypeScript SDK client reached it through discovery, dynamic registration and PKCE, then completed the password step-up and got a token with |
This was referenced Oct 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.




Part of #789
Fixes four findings from the second MCP audit, plus a forwarded-header spoofing defect that was already on
main.What changes for a deployment
Forwarded headers are ignored when no proxy is trusted. ASP.NET's
ForwardedHeadersMiddlewaretrusts every peer when its trust lists are empty (checkKnownIpsin aspnetcore v10.0.0). Under the documented direct-TLS opt-out (RateLimiting:AllowNoTrustedProxies=true, emptyRateLimiting:TrustedProxies), any client could pick its own address for the per-IP login limiter (#143) and the/mcpfailure budget and in-flight cap (#1225). It could also pick its own scheme. With an empty list,AddCluckworkEdgeSecuritynow setsForwardedHeaders.None, and the app does not add the middleware.Nonealso coversASPNETCORE_FORWARDEDHEADERS_ENABLED=true. That setting makes the default host add its own forwarding middleware through a startup filter, so only skipping the app's middleware would have left that path open. Deployments with a non-empty list behave as before.R2-01 (P2). A misconfigured proxy list is now reported. With a non-empty list,
X-Forwarded-Forfrom a peer outside it is ignored, so every client behind that peer shares one address in each per-address limit. One caller can then spend the/mcpbudget for everyone. The first such request in a process logsRateLimiting.ForwardedHeadersIgnoredat Warning and names the peer. The trust check copies the framework'sCheckKnownAddress, coveringKnownProxies,KnownIPNetworksand mapped IPv4. The check runs once for all routes, just beforeUseForwardedHeaders, because only there isRemoteIpAddressstill the socket peer, and the login limiter collapses the same way/mcpdoes. The budget itself is unchanged.R2-05 (P3).
cluckwork_record_daily_entrynow trims grade names, as the read tools do." Large"used to be refused.COD2-02 (P3).
cluckwork_record_daily_entrywith a null or blankflocknow returnsflock is required.before the idempotency claim. It used to throwNullReferenceException, which the SDK logged at Error and reported to the model as a generic error.idempotencyKeyandflockare the only required text parameters in any tool, andidempotencyKeyalready had this check, so this is one line beside it rather than a general null check in the bad-argument filter.R2-03 (P3), with a corrected premise. The audit said
Assert.Subset(served, declared)missed a declared tool that is not served. That was wrong. xUnit checks that its second argument is a subset of its first, so the old assertion already caught a hidden tool. What it missed was an extra tool that is served but not declared. The test now usesAssert.Equalon the sorted names, and the declared set includes the test host'sProbeTools.Where to start reading
src/Cluckwork.Api/Hosting/CluckworkEdgeSecurityServiceCollectionExtensions.csholds theNoneselection andUseTrustedForwardedHeaders(the warning and the trust check).Program.csswaps one call.src/Cluckwork.Api/Mcp/DailyEntryTools.csholds the flock check and the grade trim.ForwardedHeaderTrustKestrelTests.cs(two new no-trust hosts over real Kestrel),ForwardedHeadersIgnoredLoggingTests.cs,RecordDailyEntryToolFaultTests.cs(claim counter),McpToolErrorTests.cs,RecordDailyEntryToolTests.csandMcpEndpointTests.cs.SecurityEvents, and its row indocs/security/log-redaction-policy.md.The branch has three fix commits and one merge of
main. The second and third commits narrow the forwarded-header fix after local review rounds.Tests
main. They cover MCP, OAuth, forwarded headers, the no-trust hosts, rate limits, trusted proxies, security events, the login-counter probe, security headers, auth cookies, HSTS over TLS and step-up.ForwardedHeaders_Enabled=true. Both keep the loopback address andhttpunder a spoofedX-Forwarded-For: 203.0.113.10andX-Forwarded-Proto: https, and log no warning.NullFlock_IsRefusedBeforeTheKeyIsClaimedcountsINSERT INTO idempotency_records. It sees 0 for a null flock and 1 for an unknown flock, which is the positive control.Mutation checks
Each mutation was applied, the named tests were run, and the file was restored.
KnownIPNetworksEqualSubsetEqualSubsetEqualcloses.KnownProxiesNone, early return on an empty list)ForwardedHeaders_Enabledtest only:RemoteIpAddress was 203.0.113.10M9b reproduces the
ASPNETCORE_FORWARDEDHEADERS_ENABLEDpath. In that case, only the host's own startup filter could have applied the header.Live run
Each run used an isolated sim stack built from this branch, with the TLS proxy from
tools/verify/mcp/.grades: [{"grade": " Large", "quantity": 90}]. Reading it back showed a Draft withLargeat 90.flock: nulland gotAn error occurred invoking 'cluckwork_record_daily_entry': flock is required.The API log had no Error entries for these calls; the refusals were at Information.RateLimiting__TrustedProxies__0=10.255.0.0/24, two/mcprequests through the proxy logged exactly one Warning:RateLimiting.ForwardedHeadersIgnored,Peer=::ffff:172.20.0.1. With172.16.0.0/12, before and after, there were none.TrustedProxies,RateLimiting__AllowNoTrustedProxies=true, and a login limit of 3. Five wrong-password logins went straight to the app port, each with a different spoofedX-Forwarded-For(203.0.113.1 to .5) andX-Forwarded-Proto: https. They returned 401, 401, 401, 429, 429, so all five landed in one bucket. EveryAuth.LoginFailedandAuth.RateLimitRejectedevent named the real peer172.20.0.1, and there was noForwardedHeadersIgnoredwarning.The consent screenshots are in a comment below.