# Br Rate Limiting

> Throttle better-route endpoints via RateLimitMiddleware

- **Type:** Skill
- **Install:** `agentstack add skill-lonsdale201-wp-agent-skills-br-rate-limiting`
- **Verified:** Yes — security-reviewed for prompt injection and unsafe behavior
- **Seller:** [Lonsdale201](https://agentstack.voostack.com/s/lonsdale201)
- **Installs:** 0
- **Category:** [Agent Skills](https://agentstack.voostack.com/c/agent-skills)
- **Latest version:** 0.1.0
- **License:** MIT
- **Upstream author:** [Lonsdale201](https://github.com/Lonsdale201)
- **Source:** https://github.com/Lonsdale201/wp-agent-skills/tree/main/better-route/br-rate-limiting

## Install

```sh
agentstack add skill-lonsdale201-wp-agent-skills-br-rate-limiting
```

Requires the [AgentStack CLI](https://agentstack.voostack.com/docs/cli). Works with Claude Code, Cursor, and any MCP-compatible agent.

## About

# better-route: Rate limiting and client IP resolution

For developers protecting endpoints with rate limits — N requests per M seconds per identity. Two backend implementations cover the deployment matrix (in-memory object cache, transient fallback). For client IPs, 0.6.0 adds `TrustedProxyClientIpResolver`; the legacy `Http\ClientIpResolver` remains BC-safe.

## Misconception this skill corrects

> "I'll trust `$_SERVER['HTTP_X_FORWARDED_FOR']` directly to identify the client IP behind Cloudflare."

That's IP spoofing waiting to happen. Verified at [src/Http/ClientIpResolver.php:30-32](ClientIpResolver.php) — `ClientIpResolver` intentionally requires the immediate `REMOTE_ADDR` to be in `$trustedProxies` before consulting any forwarded-IP header. If `REMOTE_ADDR` isn't a trusted proxy, the resolver returns `REMOTE_ADDR` unchanged — ignoring `X-Forwarded-For` entirely.

The reason: any client can send any `X-Forwarded-For` header. If your code reads it without checking who's connecting, attackers spoof their IP to evade rate limits, fake audit logs, or bypass IP allowlists. The proxy chain is only trustworthy when the IMMEDIATE connection is from a known proxy.

```php
// WRONG — trust unauthenticated header
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'];

// RIGHT — trustedProxies gate
$resolver = new ClientIpResolver(
    trustedProxies: ['127.0.0.1', '10.0.0.5'],   // your reverse proxy / load balancer addresses
    trustedHeaders: ['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR'],
);
$ip = $resolver->resolve();
```

Other AI-prone misconceptions:

- "I'll use `WpObjectCacheRateLimiter` everywhere — it's the recommended option." Half-true. It THROWS at construction if `wp_cache_*` functions are unavailable ([WpObjectCacheRateLimiter.php:21-25](WpObjectCacheRateLimiter.php)) — which is true for vanilla WP installs WITHOUT a persistent object cache plugin (Redis Object Cache, Memcached). On those installs, fall back to `TransientRateLimiter`.
- "Default v0.3.0 rate limit key is per-IP." Wrong (post-v0.3.0) — the default key is identity-aware: authenticated user's ID first, then `sub`, then `guest`. To preserve pre-v0.3 IP-based keys, pass an explicit `keyResolver`.
- "I'll combine `JwtAuthMiddleware` after `RateLimitMiddleware` so unauthenticated requests still get rate limited." Wrong order — when JWT runs AFTER rate limit, the rate-limit `keyResolver` doesn't see the authenticated user yet, so it falls back to `'guest'` (every authenticated user shares one bucket). Run JWT FIRST.
- "IP allowlists belong in a custom rate-limit key resolver." Wrong tool. Use `IpAllowlistMiddleware` from `br-network-security`; rate limiting throttles, allowlists authorize by network.

## When to use this skill

Trigger when ANY of the following is true:

- The diff instantiates `RateLimitMiddleware`, `WpObjectCacheRateLimiter`, `TransientRateLimiter`, `ClientIpResolver`, or `TrustedProxyClientIpResolver`.
- The user asks "how do I throttle this endpoint" / "rate limit per user".
- Reviewing PR with `$_SERVER['HTTP_X_FORWARDED_FOR']` or similar header reads in handler code.
- Triaging "users get rate-limited as if they were anonymous" — usually middleware ordering.

## Workflow

### 1. Pick a backend

```php
use \BetterRoute\Middleware\RateLimit\RateLimitMiddleware;
use \BetterRoute\Middleware\RateLimit\WpObjectCacheRateLimiter;
use \BetterRoute\Middleware\RateLimit\TransientRateLimiter;

// Preferred (Redis / Memcached / W3 Total Cache / etc. installed):
$limiter = new WpObjectCacheRateLimiter(group: 'myapp_rl');

// Fallback (no persistent object cache):
$limiter = new TransientRateLimiter(/* ... */);

$middleware = new RateLimitMiddleware(
    $limiter,
    limit: 60,           // 60 requests per window
    windowSeconds: 60,   // window = 60 seconds
);
```

`WpObjectCacheRateLimiter` throws `RuntimeException` at construction if `wp_cache_add` / `wp_cache_get` / `wp_cache_set` aren't all available. Catch the exception and fall back:

```php
try {
    $limiter = new WpObjectCacheRateLimiter();
} catch (\RuntimeException) {
    $limiter = new TransientRateLimiter();
}
```

### 2. Apply the middleware

```php
$router->group('/api', function ($group) {
    $group->get('/posts', $listPosts);
    $group->post('/posts', $createPost)
        ->protectedByMiddleware('jwt');
})->middleware([$jwt, $rateLimit]);   // ← order matters: JWT FIRST
```

JWT runs first, so the request context has `user.id` populated when the rate limiter computes its key. The default key resolver ([RateLimitMiddleware.php:21-29](RateLimitMiddleware.php)) is:

```php
fn (RequestContext $context): string
    => $context->routePath . '|' . $this->identityKey($context);
```

Where `identityKey` returns `{provider}:user:{userId}` for authenticated, `{provider}:sub:{subject}` for token-without-WP-user, and `'guest'` for anonymous.

### 3. On limit exceeded

The middleware throws `ApiException` with status 429:

```json
{
  "error": {
    "code": "rate_limited",
    "message": "Rate limit exceeded.",
    "details": {
      "limit": 60,
      "remaining": 0,
      "resetAt": 1714356060
    }
  }
}
```

Response headers:

```
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 23
X-RateLimit-Reset: 1714356060
```

In 0.5.0 `RateLimitMiddleware` wraps plain array handler results into `BetterRoute\Http\Response`, so these headers are preserved even if the handler returns `['data' => ...]`. Existing `Response` and `WP_REST_Response` outputs remain supported.

### 4. Custom key resolver (per-IP, per-route, per-tenant)

```php
$middleware = new RateLimitMiddleware(
    limiter: $limiter,
    limit: 100,
    windowSeconds: 60,
    keyResolver: function (\BetterRoute\Http\RequestContext $context): string {
        $ip = (new \BetterRoute\Http\ClientIpResolver(
            trustedProxies: ['127.0.0.1'],
        ))->resolve() ?? 'unknown';
        return $context->routePath . '|' . $ip;   // per-route, per-IP
    },
);
```

Common keying strategies:

| Strategy | keyResolver |
|---|---|
| Per-user (default v0.3+) | (omit) — uses identity-aware default |
| Per-IP | `fn ($ctx) => $ctx->routePath . '|' . $ipResolver->resolve()` |
| Per-API-key | `fn ($ctx) => $ctx->routePath . '|' . $ctx->user->apiKeyId` |
| Per-tenant | `fn ($ctx) => $ctx->routePath . '|tenant:' . $ctx->user->tenantId` |
| Global | `fn ($ctx) => 'global'` (one bucket for everyone — usually a bad idea) |

### 5. Behind proxies — the IP resolver

```php
use \BetterRoute\Http\ClientIpResolver;

$resolver = new ClientIpResolver(
    trustedProxies: ['127.0.0.1', '10.0.0.5'],   // your LB / reverse proxy
    trustedHeaders: ['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP'],
);

$ip = $resolver->resolve();   // null if REMOTE_ADDR is unavailable
```

Resolution algorithm (verified at [ClientIpResolver.php:23-46](ClientIpResolver.php)):

1. Read `REMOTE_ADDR`. If empty, return null.
2. If `REMOTE_ADDR` is NOT in `$trustedProxies`, return `REMOTE_ADDR` (don't read forwarded headers).
3. Walk `$trustedHeaders` in order. For each, read the header value, split on comma, return the first valid IP.
4. If all forwarded headers are empty / invalid, return `REMOTE_ADDR`.

Default `$trustedHeaders`: `['HTTP_X_FORWARDED_FOR', 'HTTP_CF_CONNECTING_IP', 'HTTP_X_REAL_IP']`. Override the order if you have a specific deployment (e.g. CF in front of nginx → `HTTP_CF_CONNECTING_IP` first).

### 5b. v0.6.0 hardened resolver

For new code, prefer `TrustedProxyClientIpResolver` because it accepts IPv4/IPv6 CIDRs and implements `ClientIpResolverInterface`, which can be injected into `RateLimitMiddleware`:

```php
use \BetterRoute\Middleware\Network\TrustedProxyClientIpResolver;

$resolver = new TrustedProxyClientIpResolver(
    trustedProxyCidrs: ['10.0.0.0/24', '2001:db8:1234::/48'],
    forwardedHeaders: ['CF-Connecting-IP', 'X-Forwarded-For'],
);

$rateLimit = new RateLimitMiddleware(
    limiter: $limiter,
    limit: 60,
    windowSeconds: 60,
    clientIpResolver: $resolver,
);
```

Use the legacy `BetterRoute\Http\ClientIpResolver` only when preserving older constructor shapes is useful.

### 6. Layer rate limits

```php
// Global anonymous limit (low):
$globalLimiter = new RateLimitMiddleware($limiter, limit: 10, windowSeconds: 60);

// Authenticated bucket (higher):
$userLimiter = new RateLimitMiddleware($limiter, limit: 1000, windowSeconds: 60);

$router->group('/public', function ($group) { /* ... */ })
    ->middleware([$globalLimiter]);

$router->group('/api', function ($group) { /* ... */ })
    ->middleware([$jwt, $userLimiter]);
```

Public endpoints get a low anonymous limit. Authenticated API gets a much higher per-user limit. The two share the same `$limiter` instance (same backend store) but with different keys.

## Critical rules

- **`WpObjectCacheRateLimiter` requires a persistent object cache.** Throws `RuntimeException` at construction otherwise. Catch and fall back to `TransientRateLimiter`.
- **`TransientRateLimiter` works without object cache** but writes to `wp_options` — higher latency on hot endpoints.
- **Default v0.3.0 key is identity-aware.** `{provider}:user:{userId}` → `{provider}:sub:{subject}` → `'guest'`. Authenticated users get per-user; anonymous share `'guest'`.
- **Run auth middleware BEFORE rate limit** so the rate limiter sees the authenticated user. Reversed order keys on `'guest'` for everyone.
- **`ClientIpResolver` requires `REMOTE_ADDR` to be in `trustedProxies`** before reading forwarded headers. Otherwise spoofing is trivial.
- **Prefer `TrustedProxyClientIpResolver` for new 0.6.0 code.** It supports CIDRs and the shared `ClientIpResolverInterface`.
- **Default `trustedHeaders`** = `['HTTP_X_FORWARDED_FOR', 'HTTP_CF_CONNECTING_IP', 'HTTP_X_REAL_IP']`. Override the order based on your deployment.
- **`ClientIpResolver::resolve` returns null** if `REMOTE_ADDR` is unavailable. Handle the null case in your key resolver.
- **Limit-exceeded response is `429 rate_limited`** with `details.{limit, remaining, resetAt}`.
- **Successful response gets `X-RateLimit-*` headers.** Don't overwrite them in the handler.

## Common mistakes

```php
// WRONG — trusting forwarded headers without proxy gate
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
$rateLimitKey = 'user:' . $ip;   // 🔴 client controls $ip

// RIGHT — ClientIpResolver
$ip = (new ClientIpResolver(trustedProxies: ['127.0.0.1']))->resolve();
$rateLimitKey = 'user:' . ($ip ?? 'unknown');

// WRONG — middleware order reversed
$router->group('/api', fn ($g) => /* ... */)
    ->middleware([$rateLimit, $jwt]);
// All authenticated users share the 'guest' bucket because rate-limit ran before JWT.

// RIGHT
->middleware([$jwt, $rateLimit])

// WRONG — WpObjectCacheRateLimiter without fallback on hosts without object cache
$limiter = new WpObjectCacheRateLimiter();
// Throws on Bluehost / SiteGround / shared hosts without Redis.

// RIGHT — graceful fallback
try {
    $limiter = new WpObjectCacheRateLimiter();
} catch (\RuntimeException) {
    $limiter = new TransientRateLimiter();
}

// WRONG — global single-bucket limit
$middleware = new RateLimitMiddleware(
    $limiter,
    limit: 10,
    windowSeconds: 60,
    keyResolver: fn () => 'global',
);
// One user's traffic exhausts the bucket for everyone. DoS via single client.

// RIGHT — per-user OR per-IP keying

// WRONG — assuming default key is per-IP
$middleware = new RateLimitMiddleware($limiter, limit: 100, windowSeconds: 60);
// Pre-v0.3.0 was per-IP; v0.3.0+ is identity-aware. Authenticated users with the same IP
// (e.g. shared NAT) get separate buckets — usually what you want, but different from old behavior.

// RIGHT — explicit keyResolver to preserve old behavior IF you want it
keyResolver: fn ($ctx) => $ctx->routePath . '|' . ($ipResolver->resolve() ?? 'unknown')

// WRONG — trusting CF-Connecting-IP without confirming Cloudflare proxy IPs
new ClientIpResolver(
    trustedProxies: [],   // 🔴 empty
    trustedHeaders: ['HTTP_CF_CONNECTING_IP'],
);
// REMOTE_ADDR not trusted → resolver returns REMOTE_ADDR; CF header ignored. Backwards.

// RIGHT — list CF's IP ranges as trustedProxies
new ClientIpResolver(
    trustedProxies: [''],   // refresh from cloudflare.com/ips
    trustedHeaders: ['HTTP_CF_CONNECTING_IP'],
);

// WRONG — limit too aggressive on public endpoint
$globalLimit = new RateLimitMiddleware($limiter, limit: 5, windowSeconds: 60);
// Search engine crawlers + your CDN's revalidation requests hit the limit constantly.

// RIGHT — separate buckets for crawlers / authenticated, OR keying that excludes user-agent matches
```

## Cross-references

- Run **`br-auth-middleware`** for the auth middleware that should run BEFORE rate limit.
- Run **`br-network-security`** for trusted-proxy CIDRs and IP allowlists.
- Run **`br-routes`** for the middleware attachment patterns (`->middleware([...])`, group middleware).
- Run **`br-error-contract`** for the `429 rate_limited` envelope shape.
- Run **`br-idempotency`** when combining throttling with idempotency keys (different concerns; can co-exist).

## What this skill does NOT cover

- Distributed rate limiting across multiple WP installs / sites. The object cache is per-install; for cross-install, point all instances at a shared Redis.
- Token bucket / leaky bucket algorithms. The library uses fixed-window counting (simpler; some bursts cross window boundaries).
- Cost-weighted rate limiting (some endpoints "cost" more). Implement at the keyResolver / multiple middleware layer.
- IP allowlists / blocklists. Use `br-network-security` and `IpAllowlistMiddleware`.
- DDoS protection at the application layer. The library is a layer-7 limit; layer-3/4 attacks need network-level mitigation (Cloudflare, AWS Shield).
- Honest-user fingerprinting beyond IP + user ID. Browser fingerprinting is fragile and ethically questionable.

## References

- RateLimitMiddleware: [libraries/better-route/src/Middleware/RateLimit/RateLimitMiddleware.php:21-30](RateLimitMiddleware.php) — constructor with `limit: 60`, `windowSeconds: 60` defaults; default `keyResolver` at line 29.
- WpObjectCacheRateLimiter: [libraries/better-route/src/Middleware/RateLimit/WpObjectCacheRateLimiter.php:17-25](WpObjectCacheRateLimiter.php) — `group: 'better_route_rate_limit'`. Throws if `wp_cache_*` unavailable.
- TransientRateLimiter: [libraries/better-route/src/Middleware/RateLimit/TransientRateLimiter.php](TransientRateLimiter.php) — fallback for hosts without object cache; uses `get_transient` / `set_transient`.
- ClientIpResolver: [libraries/better-route/src/Http/ClientIpResolver.php:13-17](ClientIpResolver.php) — `trustedProxies = []`, `trustedHeaders = ['HTTP_X_FORWARDED_FOR', 'HTTP_CF_CONNECTING_IP', 'HTTP_X_REAL_IP']`.
- TrustedProxyClientIpResolver: [libraries/better-route/src/Middleware/Network/TrustedProxyClientIpResolver.php](TrustedProxyClientIpResolver.php) — CIDR-aware resolver for new 0.6.0 code.
- IP resolution: [ClientIpResolver.php:23-46](ClientIpResolver.php) — `resolve(?array $server = null)`, walks `$_SERVER` if no override.
- RateLimitResult: [libraries/better-route/src/Middleware/RateLimit/RateLimitResult.php](RateLimitResult.php) — `allowed`, `remaining`, `resetAt`.
- Cloudflare IP ranges: [https://www.cloudflare.com/ips/](https://www.cloudflare.com/ips/).

## Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

- **Author:** [Lonsdale201](https://github.com/Lonsdale201)
- **Source:** [Lonsdale201/wp-agent-skills](https://github.com/Lonsdale201/wp-agent-skills)
- **License:** MIT

Install and usage instructions live in the source repository linked above.

## Pricing

- **Free** — Free

## Security capabilities

Automated source analysis of v0.1.0 — what this tool can access:

- **Network access:** no
- **Filesystem access:** no
- **Shell / process execution:** no
- **Environment & secrets:** no
- **Dynamic code execution:** no

*"Yes" means the capability is present in the source — more access means more to trust, not that it is unsafe.*


## Versions

- **0.1.0** — security scan: passed — Imported from the upstream source.

## Links

- Listing page: https://agentstack.voostack.com/l/skill-lonsdale201-wp-agent-skills-br-rate-limiting
- Seller: https://agentstack.voostack.com/s/lonsdale201
- Browse the marketplace: https://agentstack.voostack.com/browse

---
Listed on AgentStack — the marketplace for AI agent skills and MCP servers. Every listing is security-reviewed. Creators keep 70%.
