Skip to content

Stop trusting client-supplied proxy headers for rate-limit IP by default#3238

Merged
pfefferle merged 5 commits intotrunkfrom
fix/rate-limit-untrusted-proxy-headers
Apr 27, 2026
Merged

Stop trusting client-supplied proxy headers for rate-limit IP by default#3238
pfefferle merged 5 commits intotrunkfrom
fix/rate-limit-untrusted-proxy-headers

Conversation

@pfefferle
Copy link
Copy Markdown
Member

@pfefferle pfefferle commented Apr 27, 2026

Summary

  • `get_client_ip()` now drives entirely off a single ordered list of `$_SERVER` keys, defaulting to `['REMOTE_ADDR']`.
  • New filter `activitypub_client_ip_sources` lets operators behind a trusted reverse proxy redefine the priority order.
  • Adds tests covering: default-deny of unsanctioned headers, opt-in via filter, fallback within the configured source list, X-Forwarded-For comma-list, and tolerant non-array filter return.

Why

Per-IP rate limits guard OAuth dynamic client registration (10/min), the OAuth token endpoint (20/min), and CIMD discovery (10/min). Previously, on any site that isn't behind a reverse proxy that strips and rewrites the proxy headers, an attacker controls all of `X-Forwarded-For`, `CF-Connecting-IP`, etc. directly. Rotating the spoofed value gives them effectively unlimited buckets and the cap is bypassed.

Defaulting to `REMOTE_ADDR` makes the rate limit honest on the largest fraction of installs (PHP-FPM directly facing the internet). Sites behind Cloudflare, Akamai, or nginx-with-stripping can redefine the priority order:

```php
add_filter( 'activitypub_client_ip_sources', static function () {
return array( 'HTTP_CF_CONNECTING_IP' );
} );
```

Cloudflare operators specifically should NOT fall back to `REMOTE_ADDR` (which is Cloudflare's edge IP — every legitimate request would land in the same bucket).

The filter docblock also calls out the X-Forwarded-For prepend pitfall: even with a trusted proxy, an attacker can prepend a value before the proxy appends the real client. Operators who need correct end-to-end `X-Forwarded-For` handling should resolve from the right via the existing `activitypub_client_ip` filter.

Test plan

  • `composer lint` passes.
  • `vendor/bin/phpunit --filter=get_client_ip` — 10 tests pass.
  • `vendor/bin/phpunit --filter=rate_limit` — OAuth rate-limit tests still pass.
  • On a Cloudflare-fronted site, verify the documented filter restores `CF-Connecting-IP` as the rate-limit key.
  • On a directly-exposed site, verify `curl -H 'X-Forwarded-For: 1.2.3.4'` no longer changes the rate-limit bucket.

get_client_ip() previously walked CF-Connecting-IP, X-Forwarded-For,
X-Real-IP, and friends before falling back to REMOTE_ADDR. On any
site that isn't behind a reverse proxy that strips and rewrites those
headers, an attacker controls every one of them and can rotate the
spoofed value to bypass the per-IP rate limits on OAuth client
registration, OAuth token, and CIMD discovery.

Default to REMOTE_ADDR only. Add an activitypub_trusted_proxy_headers
filter so operators behind Cloudflare / Akamai / nginx can opt the
relevant header back in. Document the X-Forwarded-For prepend pitfall
and point at the existing activitypub_client_ip filter for cases where
the operator needs to resolve from the right by a known proxy count.
Copilot AI review requested due to automatic review settings April 27, 2026 12:11
Make activitypub_client_ip_sources the single ordered list of $_SERVER
keys to consult, defaulting to ['REMOTE_ADDR']. One loop replaces the
foreach + separate REMOTE_ADDR fallback. Cloudflare operators can now
swap in ['HTTP_CF_CONNECTING_IP'] without REMOTE_ADDR (which would be
the Cloudflare edge IP, not the client) — previously the safety net
fallback would have re-added it. Renamed activitypub_trusted_proxy_headers
to activitypub_client_ip_sources to match the new semantic; the filter
hadn't shipped yet.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR hardens per-IP rate limiting by changing how Activitypub\get_client_ip() determines the client IP: proxy-supplied headers are no longer trusted by default, and operators must explicitly opt-in to trusted proxy headers via a new filter.

Changes:

  • Introduces activitypub_trusted_proxy_headers (empty by default) to opt-in to specific trusted proxy headers for IP detection.
  • Updates get_client_ip() behavior to consult only opted-in headers, otherwise falling back to REMOTE_ADDR.
  • Adds PHPUnit coverage for the new default-deny and opt-in behaviors, including invalid header fallback and XFF list handling.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
includes/functions.php Adds the opt-in trusted-proxy-headers filter and updates header selection logic for get_client_ip().
tests/phpunit/tests/includes/class-test-functions.php Adds tests validating default behavior and the new opt-in filter behavior.
.github/changelog/fix-rate-limit-untrusted-proxy-headers Documents the security change and the new filter for operators behind reverse proxies.

Comment thread includes/functions.php
Comment thread tests/phpunit/tests/includes/class-test-functions.php Outdated
@pfefferle pfefferle self-assigned this Apr 27, 2026
The function-level docblock still described 'Checks common proxy headers before falling back to REMOTE_ADDR, similar to Jetpack's approach' — accurate before this branch but no longer true. Replace it with a description of the activitypub_client_ip_sources filter, the REMOTE_ADDR default, and the trust constraint.
@pfefferle pfefferle requested a review from Copilot April 27, 2026 12:43
@pfefferle pfefferle requested a review from a team April 27, 2026 12:46
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comment thread tests/phpunit/tests/includes/class-test-functions.php
test_get_client_ip_ignores_non_ip_header is dead weight under the new source-list semantics — HTTP_CF_CONNECTING_IP isn't consulted unless the operator opts it in. The case is covered by test_get_client_ip_ignores_proxy_headers_by_default (default doesn't read it) and test_get_client_ip_falls_back_when_trusted_header_invalid (opted in but invalid value).

Also replace 'doesn't accidentally fail closed' in the fall-through test docstring with 'doesn't lock all callers out' — 'fail closed' is a security term that suggests denial on uncertainty, which isn't what graceful degradation through the source list looks like.
@pfefferle pfefferle merged commit 92ae922 into trunk Apr 27, 2026
10 checks passed
@pfefferle pfefferle deleted the fix/rate-limit-untrusted-proxy-headers branch April 27, 2026 12:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants