Skip to content

test(ci): isolate the login rate-limit bucket per test host - #876

Merged
mforce merged 2 commits into
mainfrom
chore/840-isolate-login-rate-limit-key
Sep 15, 2026
Merged

mforce merged 2 commits into
mainfrom
chore/840-isolate-login-rate-limit-key

Conversation

@mforce

@mforce mforce commented Sep 14, 2026

Copy link
Copy Markdown
Owner

What

Gives the login rate-limit counter a bucket the probe can measure without the suite's own traffic in it. Follow-up to #866, which proved the collision but did not remove it from the way.

IsolatedLoginBucket makes loopback a trusted proxy for one test host, so ForwardedHeadersMiddleware honours X-Forwarded-For and the limiter keys on an address the test names instead of on the socket peer.

Two findings from building it

A first cut minted buckets as 198.51.100.<a>.<b>. Five octets is not a valid IPv4 literal. ForwardedHeadersMiddleware rejects it and falls back to the socket peer, so every "independent" bucket silently collapsed onto loopback — where the test's own exhaustion turned the next client's first request into a 429. It looked like isolation not working, not like a malformed address. Buckets are TEST-NET-3 /32s now, and the helper says why that matters.

The probe's own fixture spends against the probe's own bucket. The burst moved the counter by 18 on 14 requests, in a Redis container private to that class — nothing external could have written it. CluckworkWebApplicationFactory builds its host in the constructor, before any test body runs, and the suite logs in constantly from loopback. So #840's collision reproduces one level down, inside a single class. That is why the assertion is >= and not ==: an exact assertion here would have made the file red on a working mechanism.

Why configuration and not PostConfigure<ForwardedHeadersOptions>

Program.cs:63 reads RateLimiting:TrustedProxies eagerly into a registration record and hands that snapshot to AddCluckworkEdgeSecurity, which clears KnownIPNetworks and rebuilds them from it. A PostConfigure runs afterwards and is silently overwritten — a helper written that way looks correct and does nothing at runtime.

Guard

A host that already sets RateLimiting:TrustedProxies:0 is refused, not merged. Such a host is exercising #143's anti-spoof boundary; overwriting its proxy list would delete the thing under test while leaving the test green.

Verification

Executed, 32/32 across every class that touches the login limiter or the forwarded-header trust decision:

  • RateLimitingTests, AuthRateLimitLoggingTests, DistributedRateLimiterWiringTests, MultiInstanceRateLimitTests, LoginCounterKeyProbeTests, SchemaDocsTests — 20
  • ForwardedHeaderTrustKestrelTests, SecurityHeadersTests, KestrelRequestBodyLimitTests — 12

The second group matters most: those are the anti-spoof tests, and they still pass because they configure their own proxies and therefore never enter this helper.

Not in this PR

The collision is still there for the ~102 classes that log in through the shared factory. This isolates the probe. Extending it to the suite is the next step and should be its own PR, because it means every factory opting in and each one is a place to get the trust decision wrong.

Does not close #840. No flake has been shown to stop flaking yet — that needs repetition, not reasoning.

#840's collision is now measurable without being in the way. The probe's
child trusts loopback as a proxy and the burst carries an X-Forwarded-For
of its own, so the counter it reads is the one its own fourteen requests
wrote instead of one shared with every other class on the machine.

IsolatedLoginBucket is the helper. It goes through configuration, not
PostConfigure<ForwardedHeadersOptions>, because Program.cs reads
RateLimiting:TrustedProxies eagerly into a registration record and hands
that snapshot to AddCluckworkEdgeSecurity - a PostConfigure runs after and
is silently overwritten. It refuses a host that already declares its own
trusted proxies: such a host is exercising #143's anti-spoof boundary, and
overwriting its proxy list would delete the thing under test.

Two things this turned up that are worth the commit message:

A first cut minted buckets as 198.51.100.<a>.<b>. Five octets is not a
valid IPv4 literal, ForwardedHeadersMiddleware rejects it and falls back
to the socket peer, and every supposedly independent bucket silently
collapsed onto loopback. Buckets are now TEST-NET-3 /32s.

The burst bucket moved by 18 on 14 requests, in a Redis container private
to this class. Nothing external could have written it, so the probe's own
fixture spent in its own bucket before the burst ran - the same collision,
one level down. That is why the assertion is >= and not ==.
@coderabbitai

coderabbitai Bot commented Sep 14, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: f9effc10-6988-4b7d-8dbc-2a389f4fdb2f


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

ConfigureIsolatedLoginBucket, CreateIsolatedClient, DefaultClientIp and
SynthesizedProxy had zero callers: the probe is a serving subprocess, so it
sets RateLimiting__TrustedProxies__0 in its own environment and adds the
header itself. Only ClientIpFor is used.

Kept the guard and the two findings that cost the debugging cycles - the
five-octet address that silently collapsed every bucket onto loopback, and
the PostConfigure that Program.cs's eager snapshot overwrites - and dropped
the comments restating them.
@mforce
mforce merged commit 56ac5a4 into main Sep 15, 2026
16 checks passed
@mforce
mforce deleted the chore/840-isolate-login-rate-limit-key branch September 15, 2026 02:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CI: root-cause the four remaining integration flakes

1 participant