Spend MCP pre-auth IP budget only on authentication failures - #1381
Conversation
Rework the pre-auth client-address limiter into a FAILURE budget: reject an exhausted address before the API-key lookup via non-consuming GetStatistics inspection, let requests proceed without consuming, and AttemptAcquire one permit only on a 401. Successful auth never spends IP budget, so multiple keys behind one egress address keep independent per-key budgets (#1368).
The standalone MCP host (--mcp --transport http) never applied UseForwardedHeaders, so behind a reverse proxy every client collapsed to the proxy address. Reuse the existing ForwardedHeaders:KnownProxies/KnownNetworks convention (default OFF; X-Forwarded-For never trusted from an unknown peer) before the failure-budget limiter, mirroring the co-hosted API pipeline. Refresh the limiter comments for the failure-budget semantics (#1368).
Add unit tests for the failure-budget middleware (exhausted budget rejects before the auth/lookup layer via a counting fake; successful requests never decrement; XFF ignored by default; forwarded client keyed when a known proxy is wired) and an integration test at the production default (120/60s) proving two keys behind one address are not starved by the shared IP bucket (#1368).
Update CONFIGURATION_REFERENCE for the McpAuthenticationPerIp failure-budget behavior and note that ForwardedHeaders now applies to the standalone MCP host, with the never-trust-XFF-by-default security caveat (#1368).
There was a problem hiding this comment.
Code Review
This pull request refactors the MCP pre-authentication rate limiter to implement a "failure budget" model, where rate limit permits are only consumed upon authentication failure (401 Unauthorized). This change prevents multiple valid clients sharing a single egress IP address from starving each other's rate limits. Additionally, the PR integrates forwarded headers support for the standalone MCP host and adds comprehensive tests and documentation updates. The feedback suggests chaining .Dispose() directly on the result of AttemptAcquire to avoid potential compiler warnings regarding the unused lease variable.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
Adversarial Code ReviewSelf-review of this PR (author). No prior human/bot comments on the thread yet. CRITICAL
HIGH
MEDIUM
LOW
Bot Comments Addressed
Summary0 CRITICAL, 0 HIGH, 1 MEDIUM, 2 LOW. Not merge-blocking on correctness. Fixing M1 (add standalone-host e2e coverage); L1/L2 are documented, accepted design characteristics. Fixes and evidence in a follow-up comment. |
Boots the real --mcp --transport http entry point with a failure budget of 1 and a trusted loopback proxy, proving the pre-auth budget keys on the X-Forwarded-For client (independent buckets per client) rather than the shared proxy socket. Closes review finding M1.
Addresses gemini-code-assist review on McpAuthenticationAttemptLimiter.RecordFailedAttempt: acquire-and-dispose in one expression instead of an unused 'using var lease'.
Adversarial Review — Fixes Applied
Test evidence (Release,
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5300ff143f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Consolidated Adversarial Review — Two Independent Lenses (Coordinator Adjudication)Two independent adversarial reviews (security lens + test lens) of this PR completed and were adjudicated. Consolidated findings below; fixes follow in one batch. Confirmed solid (traced, no action)
MEDIUM
LOW
Summary0 CRITICAL, 1 MEDIUM (M2 — concurrency admission control), 6 LOW. M2 is the only merge-relevant gap; all seven items will be fixed or explicitly documented in the next push, with fix-evidence mapped per finding. |
…ntrol The failure-budget pre-check consumes post-response, so N concurrent invalid-key requests could all pass while permits remained and reach the key lookup - the bound was attacker-chosen concurrency. Add a per-address ConcurrencyLimiter (releasable leases, QueueLimit 0, new setting RateLimiting:McpAuthenticationPerIpConcurrency default 16, validated 1-10000) acquired at /mcp pre-auth entry and released on completion. Invariant: in-flight pre-auth work per IP <= cap at any instant; failed-auth lookups per window <= PermitLimit + cap. Also corrects the GetStatistics lazily-creates comment and rewrites the overshoot docstring honestly.
…tests Concurrency test: cap 2 with a blocking auth fake, 3 over-cap requests 429 immediately without reaching auth, released slots re-admit - converts the former TOCTOU claim into a pinned bound. Replenishment test: 1s window through the pre-check path (exhaust -> 429 -> wait -> admitted), noting the production 60s window differs only in duration. Adds validator range coverage for McpAuthenticationPerIpConcurrency.
New e2e case: forwarded headers ENABLED but the connecting peer is not in KnownProxies -> XFF ignored, budget keys on the socket address, rotated XFF cannot mint fresh buckets. Extracts the host-boot boilerplate into RunPreAuthLimiterHostAsync shared by both forwarded-header cases.
Only the pre-auth IP budget is at its production default (the setting under test); McpPerApiKey is deliberately raised to 500 so valid traffic can exceed the 120-permit bucket. Not a full production-defaults configuration.
Consolidated Review — Fixes Applied (head
|
| Finding | Severity | Fix Commit | Verified |
|---|---|---|---|
| M2 — pre-check is not admission control: N concurrent invalid-key requests could all pass while permits remained (bound = attacker-chosen concurrency) | MEDIUM | 1cfebb66 |
Per-address ConcurrencyLimiter gate (releasable leases, QueueLimit 0 → immediate 429), new setting RateLimiting:McpAuthenticationPerIpConcurrency (default 16, validated 1–10000), acquired at /mcp pre-auth entry, released on request completion. Combined invariant documented in code and docs: in-flight pre-auth work per IP ≤ cap at any instant; failed-auth lookups per window ≤ PermitLimit + cap. The "bounded overshoot" docstring rewritten honestly (previously undersold the unbounded-concurrency case). Pinned by the new concurrency test (below). |
L3 — pre-check 429 reports the full window as Retry-After |
LOW | ed5e7434 (docs), 1cfebb66 (docstring) |
Accepted as the documented safe over-estimate — no behavior change. The non-consuming inspection does not expose the replenishment instant. Now stated plainly in the WriteFailureBudgetRejectedAsync docstring, CONFIGURATION_REFERENCE.md, and the PR body. The concurrency-gate 429 uses Retry-After: 1 (slots free as soon as any in-flight request completes). |
L4 — comment wrongly claimed GetStatistics may return "null before creation" |
LOW | 1cfebb66 |
Comment corrected: GetStatistics lazily creates the partition and reports full permits for a first-time address; the null branch is retained and explicitly labeled as defensive coding. |
| L5 — spoof resistance not pinned on the real MCP path with forwarding ENABLED | LOW | 0b27b580 |
New standalone e2e StandaloneMcpHttpHost_ForwardedHeaders_IgnoreXffFromUnknownPeer: forwarding enabled, connecting peer NOT in KnownProxies → XFF ignored, budget keys on the socket address, rotated XFF 429s on the same bucket. Host-boot boilerplate extracted to a shared helper. Also resolves the OFF-default tautology critique (the middleware is in the pipeline and actively refuses the untrusted header). |
| L6 — no window-replenishment coverage through the pre-check | LOW | 301ef362 |
New unit test FailureWindowReplenishment_AdmitsRequestsAgain_ThroughPreCheck (1s window: exhaust → 429 → wait → admitted, auth layer reached again). Test comment notes the production 60s window differs only in duration — the pre-check logic is window-length-agnostic. |
| L7 — TOCTOU claim previously untestable | LOW | 301ef362 |
New unit test ConcurrencyGate_CapsInFlightPreAuthWork_AndRejectsExcessImmediately: cap=2, blocking auth fake, 3 over-cap requests 429 immediately without reaching auth (counter pinned at 2), released slots re-admit. Stable 3× consecutive runs. |
| L8 — "production defaults" claim imprecise | LOW | 6dd3a4cf + PR body edit |
Test comment and PR body now state precisely: only the pre-auth IP budget (120/60s — the setting under test, genuinely exceeded at 130 requests) is at its production default; McpPerApiKey is deliberately raised to 500. |
Verification (Release, -m:1, head ed5e7434)
- Full solution build: 0 errors
McpAuthenticationRateLimitingMiddlewareTests— 7 passed (was 5; +replenishment, +concurrency bound)McpHttpTransportApiKeyTests— 48 passedStandaloneMcpHostFilteringTests— 3 passed (was 2; +unknown-peer spoof resistance)OptionsValidationTests— 93 passed (was 90; +3 concurrency range cases)- New concurrency test: 3× consecutive passes
- No EF model change in this batch (settings class only;
has-pending-model-changespreviously verified "No changes")
All adjudicated findings addressed — fixed, pinned by tests, or documented-and-accepted with rationale. CI: pending on the new push; will not merge (gate decides).
Addendum — Codex review round (post-push)Two P2 threads from chatgpt-codex-connector on head
No code changes required beyond what is already pushed. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ed5e74349d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…disabled Remove the [Range] data annotation from McpAuthenticationPerIpConcurrency: ValidateDataAnnotations()/ValidateOnStart() applied it even with RateLimiting:Enabled=false, so a stale out-of-range value could block startup although the limiter is never constructed. The range (1-10000) is enforced solely by RateLimitingSettingsValidator, which skips when disabled - the same semantics as the nested policy settings. Adds a boot-level test (disabled + out-of-range boots, /health/live OK) and extends the validator skip test.
… limiter The standalone --mcp --transport http host binds RateLimitingSettings manually and never ran the co-hosted AddOptionsValidation/ValidateOnStart pipeline, while the limiter constructor only lower-clamps - so an over-maximum concurrency (>10000) was silently accepted. Run RateLimitingSettingsValidator explicitly before AddTaskdeckRateLimiting: skipped when disabled, otherwise fail fast to stderr with the validation message and exit 1 (the standalone convention). Adds an e2e test asserting exit code 1, the message names the setting, and the app-built seam never fires.
Fix Evidence — Final Cycle (head
|
| Finding | Severity | Fix Commit | Verified |
|---|---|---|---|
Codex — [Range] on McpAuthenticationPerIpConcurrency validated even with RateLimiting:Enabled=false (disabled config could fail startup for a limiter that is never constructed) |
P2 | d55ed6cb |
Annotation removed; range (1–10000) enforced solely by RateLimitingSettingsValidator, which skips when disabled — same semantics as the nested policy settings. New boot-level test App_StartsSuccessfully_WhenRateLimitingDisabled_WithOutOfRangeMcpConcurrency (Enabled=false + concurrency 0 → host boots, /health/live OK) + extended validator skip test. Enabled fail-fast unchanged (RateLimitingValidator_Fails_WhenMcpAuthenticationConcurrencyIsOutOfRange, 3 range cases). |
Codex — standalone --mcp --transport http never validated RateLimitingSettings; the limiter constructor only lower-clamps, so >10000 was silently accepted |
P2 | 5db6c6dc |
RateLimitingSettingsValidator now runs in standalone Program.cs BEFORE AddTaskdeckRateLimiting constructs the limiter: skipped when disabled, otherwise stderr message + exit 1 (mirroring co-hosted ValidateOnStart semantics and the standalone invalid---port convention). New e2e test StandaloneMcpHttpHost_OutOfRangeConcurrency_FailsFastWithValidationMessage: concurrency 10001 → exit 1, stderr names the setting, app-built seam never fires. |
Thread state
| Thread | Disposition |
|---|---|
Gemini — unused using var lease |
Fixed 9707322e; replied; resolved |
| Codex — cap concurrent failed auth before lookup | Pre-fixed by 1cfebb66 (concurrency gate); replied with evidence; resolved |
| Codex — valid over-quota key auth-stage DB work | Real, out of scope; tracked as #1384; replied; resolved |
| Codex — concurrency validated when disabled | Fixed d55ed6cb; replied; resolved |
| Codex — standalone validation before limiter construction | Fixed 5db6c6dc; replied; resolved |
Verification (Release, -m:1, head 5db6c6dc)
- Full solution build: 0 errors
OptionsValidationTests— 94 passed (+1 boot-level disabled/out-of-range test)StandaloneMcpHostFilteringTests— 4 passed (+1 fail-fast validation test)McpAuthenticationRateLimitingMiddlewareTests— 7 passed (unchanged, re-run)- No EF model change (settings class only)
CI was fully green on ed5e7434; re-running on this head. Not merging — the gate decides.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5db6c6dc31
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
A client disconnecting while the 401 body was written made the response write throw and unwind past the post-_next consume, so an abort-storm repeated invalid-key lookups without ever spending McpAuthenticationPerIp (the concurrency gate caps in-flight work, not the per-window lookup count). ApiKeyMiddleware now sets an auth-failed marker in HttpContext.Items BEFORE writing the rejection, and the limiter middleware consumes in a finally block on marker-or-401 - abort-proof. Test: aborted 401 (marker + status + OperationCanceledException) still decrements the budget; the follow-up request 429s at the pre-check without reaching the auth layer.
Fix Evidence — Abort-Proof Failure Consumption (head
|
| Finding | Severity | Fix Commit | Verified |
|---|---|---|---|
Codex — aborted MCP auth failures evaded the IP budget: a client disconnecting while the 401 body was written made the response write throw and unwind past the post-_next consume, so an abort-storm repeated invalid-key lookups for free (the concurrency gate caps in-flight work, not per-window lookup count) |
P2 | 4a91899e |
ApiKeyMiddleware.WriteErrorResponse sets AuthenticationFailedItemKey in HttpContext.Items BEFORE any response write; the limiter middleware consumes the failure budget in a finally block on marker-or-401, so the charge survives client aborts (OperationCanceledException from the write). New test AbortedFailureResponseWrite_StillConsumesFailureBudget: fake marks failure + sets 401 + throws mid-write → budget decremented, follow-up request 429s at the pre-check without reaching the auth layer. |
Verification (Release, -m:1, head 4a91899e)
- Full solution build: 0 errors
McpAuthenticationRateLimitingMiddlewareTests— 8 passed (+1 abort test)McpHttpTransportApiKeyTests— 48 passed
Failure-budget invariants as now shipped
- Valid requests never spend IP budget (per-key isolation, the MCP /mcp pre-auth IP rate limiter throttles valid traffic and defeats per-key isolation (post-merge finding from #1364) #1368 fix).
- In-flight pre-auth work per address ≤ concurrency cap at any instant.
- Every completed-or-aborted authentication failure spends exactly one window permit — abort-proof via the pre-write marker +
finally. - Failed-auth key lookups per address per window ≤
PermitLimit + concurrency cap, regardless of client disconnect behavior.
CI re-running on 4a91899e (was fully green on the two prior heads). Not merging — the gate decides.
* docs: record 2026-07-17 overnight delivery wave in STATUS + masterplan * docs(failure-ledger): mark #1282/#1347/#1348 resolved and re-render * docs: fold late-landing #1385/#1381/#1390 into the 2026-07-17 overnight entry * docs: separate Apply-considerations copy from the Operation-safety confidence label (Gemini M1/M2)
Closes #1368
Problem
McpAuthenticationRateLimitingMiddlewareacquired an IP-scoped lease on every/mcprequest — including fully authenticated ones. The pre-auth IP window default is 120/60s per client address while each key's own budget is 60/min, so multiple keys behind one egress IP (NAT/proxy/CI) shared the 120/min bucket and429d each other before their independent per-key budgets applied — defeating the per-key isolation #1364 built. Compounding it, the standalone MCP host never wiredUseForwardedHeaders, so behind a reverse proxy every client collapsed to the proxy address → one bucket for the world.Fix
The pre-auth IP budget is now a FAILURE budget with a concurrency gate (
McpAuthenticationAttemptLimiter+ middleware):429before any API-key lookup (preserves the brute-force/lookup-cost protection). Uses non-consumingPartitionedRateLimiter.GetStatistics(...).CurrentAvailablePermits <= 0inspection.ConcurrencyLimiter, releasable leases, QueueLimit 0 → immediate 429) held for the request's duration. New settingRateLimiting:McpAuthenticationPerIpConcurrency(default 16, validated 1–10000).AttemptAcquire(1), no refund — window permits don't refund) only when authentication fails (401).ApiKeyMiddlewareis the sole 401 source on/mcp, so a 401 unambiguously marks a failed attempt.Successful authentications never spend IP budget → per-key isolation holds for any number of keys behind one address. Combined invariant: in-flight pre-auth work per address ≤ concurrency cap at any instant; once the failure window (120) is spent the pre-check rejects everything → failed-auth key lookups per address per window ≤
PermitLimit + concurrency cap. The existing429contract (application/jsonApiErrorResponse,TooManyRequests,Retry-After,X-RateLimit-Policy) is unchanged; the per-key limiter (60/min, opaque key-ID partitioning from #1364) is untouched.Retry-After note: the failure-budget pre-check 429 reports the FULL window as
Retry-After— a deliberate safe over-estimate (the non-consuming inspection does not expose the exact replenishment instant). The concurrency-gate 429 reportsRetry-After: 1(a slot frees as soon as any in-flight request completes).Forwarded headers: config-gated, default OFF. Reuses the existing
ForwardedHeaders:KnownProxies/KnownNetworksconvention (the co-hosted API already wires it before the limiter); this PR adds the sameUseForwardedHeaderswiring to the standalone MCP host before the limiter.X-Forwarded-Foris never trusted from an unknown peer, so a spoofed XFF cannot rotate buckets (pinned e2e). (Deviation from the suggestedRateLimiting:TrustForwardedHeadersbool: reusing the establishedForwardedHeaderssection is the smaller, consistent change and is strictly more secure than a bare bool, which would trust all XFF. Reversible.)Residual DoS tradeoff (intentional, documented): the pre-check runs before the auth outcome is known, so a valid key sharing a hostile NAT that already drained the budget still pays the
429until the window resets — the accepted cost of shielding the key lookup from a brute-force flood. Similarly, a valid client multiplexing more than the concurrency cap of simultaneous requests through one address gets brief 429s. Operators behind a trusted proxy setForwardedHeadersto give each real client its own bucket.Test evidence (Release,
-m:1)Unit —
McpAuthenticationRateLimitingMiddlewareTests(7 passed):429before the auth/lookup layer (counting fake proves_nextnot reached) + full429contract429s)Integration —
McpHttpTransportApiKeyTests(48 passed): two keys behind one address with the pre-auth IP budget at its production default (120/60s — the setting under test;McpPerApiKeydeliberately raised to 500 so 130 valid requests exceed the 120-permit bucket) sustain traffic without starving each other; existing...RejectedCredentials_AreRateLimitedBeforeDatabaseLookupand...RateLimit_IsPartitionedByApiKeystill green.Standalone e2e —
StandaloneMcpHostFilteringTests(3 passed): real--mcp --transport httphost — forwarded-client keying with a known proxy, XFF-spoof resistance from an unknown peer while forwarding is enabled, and the #1367 host-filtering regression.Options validation —
OptionsValidationTests(93 passed, incl. range coverage for the new concurrency setting).dotnet ef migrations has-pending-model-changes: No changes (no model change).Docs
docs/platform/CONFIGURATION_REFERENCE.md—McpAuthenticationPerIpfailure-budget semantics, the newMcpAuthenticationPerIpConcurrencycap, the plain Retry-After over-estimate statement, andForwardedHeaderscoverage of the standalone MCP host with the never-trust-XFF-by-default caveat.