Skip to content

feat(relay): add local auth-failure throttle#273

Closed
jeremi wants to merge 3 commits into
mainfrom
feat/relay-auth-failure-throttle
Closed

feat(relay): add local auth-failure throttle#273
jeremi wants to merge 3 commits into
mainfrom
feat/relay-auth-failure-throttle

Conversation

@jeremi

@jeremi jeremi commented Jul 6, 2026

Copy link
Copy Markdown
Member

Summary

Adds a local, in-process, coarse throttle on repeated authentication
failures from one client address to Registry Relay (auth.failure_throttle).
Disabled by default. When enabled: once a client address accumulates
max_failures authentication failures within window_seconds, further
requests from that address get a stable 429 / auth.rate_limited
response (with a Retry-After header) before the auth provider runs,
until the window rolls over. Successful authentication neither counts
toward the limit nor resets it.

Part of #78.

Design rationale

  • Default-off, byte-for-byte unaffected when disabled. AuthFailureThrottle::new returns None unless auth.failure_throttle.enabled is true, and every branch of the middleware that touches the throttle is gated on Option::is_some(). Local/demo configs that don't opt in see no behavior change.
  • Trust-proxy-aware keying, shared with audit. The throttle keys on the same resolved client address the audit record's remote_addr field reports. That resolution logic (X-Forwarded-For walk against server.trust_proxy.trusted_proxies) was extracted from audit/mod.rs into a new crate::net::resolve_remote_addr, used by both subsystems, so the two agree on "who is this request from" and an untrusted proxy hop can't be used to spoof a key.
  • Bounded map. The fixed-window counter is a Mutex<HashMap<String, Counter>> capped at 10,000 tracked addresses, evicting the oldest window on overflow, so a flood of spoofed source addresses can't grow it without bound.
  • Ingress rate limiting stays primary. This is a backstop, not a replacement for a gateway/load-balancer rate limit; the existing deployment.evidence.ingress_rate_limit gate and relay.ingress.rate_limit_missing finding are unchanged. Expensive-route throttling for authenticated callers is explicitly out of scope and deferred.

Review notes (security-sensitive: authentication path + audit)

  • New deny surface. A new AuthError::RateLimited variant maps to a stable 429 / auth.rate_limited, added to the error taxonomy table and error_taxonomy.rs's exhaustive variant/status snapshot tests.
  • Spoofing consideration. Keying uses the identical trust-proxy resolution the audit middleware already uses to populate remote_addr; an untrusted X-Forwarded-For value is ignored in favor of the actual socket peer, exactly as it is for audit. This means an attacker behind an untrusted hop can't pick an arbitrary throttle key to frame another address, and (per the design) can't evade the throttle by rotating a forwarded-for value through a trusted proxy either, since the resolved client is what's keyed on, not the raw header.
  • Throttled short-circuit is itself audited with error_code: auth.rate_limited / status_code: 429, so a sustained lockout is visible in the audit stream, not silent.
  • Pre-existing stale doc comment left untouched. auth::middleware::remote_addr()'s doc comment claims upstream tower-http layers resolve trust-proxy addressing before this function runs, which isn't accurate (that resolution actually happens in the new crate::net::resolve_remote_addr, used separately by the throttle and by audit; remote_addr() itself is unrelated, feeding the auth provider's remote_addr argument, not throttle/audit keying). Left as-is per scope discipline; flagging here rather than fixing inline since it's unrelated to this change.
  • Separate throttle instances per app builder. The main data-plane app and the admin app each get an independently-constructed throttle (the admin app's metrics router and protected router share one instance). This matches the fact that they're bound to independent listeners/processes-of-concern in server.rs; flagging in case reviewers want a single shared instance instead.

Verification

  • cargo fmt --check — clean
  • cargo check --locked -p registry-relay --all-targets — clean
  • cargo clippy --locked -p registry-relay --all-targets -- -D warnings — clean
  • cargo test --locked -p registry-relay — all green (311 tests in the lib crate's own suite plus integration test binaries; no failures)
  • just openapi-contract (from crates/registry-relay) — exits 0, no diff. The new auth.rate_limited (429) is a cross-cutting auth-middleware error, like the pre-existing auth.jwks_unavailable (503) from the same choke point, and per existing precedent those are not enumerated per-route in the committed OpenAPI document, so no regeneration was needed.

New tests

  • 8 unit tests for the fixed-window counter itself (auth::failure_throttle), including window expiry, per-address isolation, and the bounded-map eviction behavior under a flood of distinct addresses.
  • 6 integration tests in tests/auth_flow.rs: disabled config never trips 429; failures under the limit pass through as 401 and the failure that reaches the limit returns 429 with auth.rate_limited and a numeric Retry-After; an over-limit address is throttled even with a valid credential; a different address is unaffected; successful auth doesn't count toward the throttle.
  • 2 integration tests in tests/audit_record.rs: the throttle keys on the resolved X-Forwarded-For client rather than the proxy peer; the audit record captures error_code: auth.rate_limited / status_code: 429 for the throttled short-circuit.
  • 4 config-validation unit tests in config/validate.rs mirroring the existing BreakGlassRateLimit validation pattern.

Add a disabled-by-default, in-process, coarse throttle on repeated
authentication failures from one client address (auth.failure_throttle).
When enabled, an address that reaches max_failures failed auth attempts
within window_seconds gets a stable 429 (auth.rate_limited, with
Retry-After) before the auth provider runs, for every request from that
address until the window rolls over; successful auth neither counts nor
resets. Ingress rate limiting remains the primary control against
high-volume abuse; this is a local backstop for deployments without a
gateway in front of them, or defense in depth behind one. It does not
throttle other expensive routes for authenticated callers, which is
deliberately deferred.

The throttle keys on the same trust-proxy-aware client address the audit
record's remote_addr reports, extracted from audit/mod.rs into a shared
crate::net::resolve_remote_addr so both subsystems agree on which address
a request came from and untrusted X-Forwarded-For values cannot be used
to evade the throttle or split it across fake keys.

The map is bounded (capped entries, oldest-window eviction) so it cannot
grow without bound under a flood of spoofed source addresses.

Disabled by default: when auth.failure_throttle.enabled is false (the
default), no throttle is constructed and the auth middleware's behavior
is unchanged from before this feature.

Part of #78

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1ef8e150a3

ℹ️ 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".

Err(e) => return Error::from(e).into_response(),
Err(e) => {
if let (Some(throttle), Some(key)) = (&state.throttle, &throttle_key) {
throttle.record_failure(key);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not count JWKS outages as client failures

This records every provider error as a per-client failure. In OIDC deployments, AuthError::JwksUnavailable is the IdP/JWKS outage path that returns 503, not a bad credential; with the throttle enabled, max_failures requests during an outage poison that address and subsequent valid tokens get 429 until the window expires even after JWKS recovers. Please skip non-client-caused provider errors when incrementing the throttle.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. Fixed in 775b6b8: AuthError::JwksUnavailable and already-rate-limited requests no longer increment the client failure throttle. Added an OIDC/JWKS outage flow test showing a recovered valid token is not poisoned by the outage.

Comment on lines +242 to +244
let failure_throttle =
crate::auth::failure_throttle::AuthFailureThrottle::new(&config.auth.failure_throttle)
.map(Arc::new);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Hot-swap or reject throttle config changes

Because this AuthFailureThrottle is captured when the router is built, it is not refreshed by governed config apply. I checked classify_live_config_change: a signed client_access_change/client_credential_rotation can be accepted when auth.api_keys changes, and equivalent_except_auth ignores the rest of the auth block, so a bundle that also toggles auth.failure_throttle updates the active config snapshot while requests keep using the old throttle state until restart. Please either reject throttle edits on live auth applies or move the throttle into swappable runtime state.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. Fixed in 775b6b8: live auth applies now require the candidate auth.failure_throttle config to match the active one, so throttle edits are restart-required instead of silently updating config state while the router keeps the old throttle. Added api_key_auth_changed_requires_same_failure_throttle_config.

// instead of taking down the auth path.
poisoned.into_inner()
});
prune_expired(&mut counters, now, self.window);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid full-map pruning on every auth request

With the throttle enabled, every protected request calls check() before authentication, and this line scans/retains the entire counter map under the single mutex. Once a burst of distinct client addresses fills the bounded map, even valid requests from unrelated clients pay an O(10k) scan and serialize behind the mutex, so the backstop can become a CPU/latency amplifier under exactly the spoofed/distributed-address scenario it is meant to survive. Please prune opportunistically or on a schedule instead of on each check.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. Fixed in 775b6b8: throttle checks remove only the current expired key on the hot path, and full-map pruning happens only under capacity pressure before oldest-entry eviction. Added focused failure-throttle coverage; reviewer reran the eviction test.

jeremi added 2 commits July 7, 2026 10:34
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Behind a proxy or load balancer with server.trust_proxy left disabled
(the default), the throttle keys on the proxy's own socket address, so
every client shares one bucket and combined failures from anyone can
429 all clients until the window rolls. Emit a config.validation_warning
finding at boot so operators catch the misconfiguration instead of
discovering it as a self-inflicted outage.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d0ddd69f01

ℹ️ 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".

);
return Err(ConfigError::ValidationError);
}
if !config.server.trust_proxy.enabled {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Warn on empty trusted_proxies with the throttle

When auth.failure_throttle.enabled is true and an operator sets server.trust_proxy.enabled: true but leaves trusted_proxies empty, this warning is skipped even though runtime address resolution still falls back to the proxy peer: resolve_remote_addr only honors X-Forwarded-For when trusted_proxy_contains(peer, trusted_proxies) matches, and an empty list never matches. In proxied deployments that means all clients still share one throttle bucket and can 429 each other after combined failures, the same scenario this validation warning is meant to catch.

Useful? React with 👍 / 👎.

/// `auth.failure_throttle.enabled` is true. `retry_after_seconds` is
/// surfaced as the `Retry-After` response header.
#[error("authentication rate limited")]
RateLimited { retry_after_seconds: u64 },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Document 429 responses in Relay OpenAPI

Adding auth.rate_limited makes protected Relay operations return 429 when the throttle is enabled, but the OpenAPI generator and committed crates/registry-relay/openapi/registry-relay.openapi.json still advertise no 429 responses (I checked the generated artifact: 57 operations have 401 and 0 have 429). Clients generated from the contract will not know to handle this new Problem Details response, so the generator and regenerated artifact should include the rate-limit response on protected operations.

Useful? React with 👍 / 👎.

@jeremi

jeremi commented Jul 7, 2026

Copy link
Copy Markdown
Member Author

Superseded by the integration meta-PR #292 — this branch is merged there unchanged (with cross-PR conflicts resolved once). Branch retained.

@jeremi jeremi closed this Jul 7, 2026
@jeremi jeremi deleted the feat/relay-auth-failure-throttle branch July 11, 2026 09:55
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.

1 participant