Skip to content

Commit 81712d5

Browse files
committed
config: make x402 rate limit + maxConcurrentApiCalls explicit
`server.rateLimit.x402` and `settings.maxConcurrentApiCalls` are now required (no `.default()`). Hidden defaults make it hard to see what's actually running at a glance, and these tuning knobs aren't universal — operators should set them per deployment. Breaking, in line with `server.rateLimit` from c1a37c4. The x402 bucket is also now configurable per-deployment: it threads through `PipelineDependencies.rateLimit` into `AuthContext.x402RateLimit` and is honored when the per-IP bucket is checked in `checkX402`. Tests exercise both the default plumbing and a custom override. `createTestServer` and `makeConfig` (server.test.ts) deep-merge a `Partial<RateLimit>` override so call sites stay tight — tests pass `{ max: 3 }` instead of redeclaring the full shape every time. Examples, inline YAML fixtures, and book/docs/config/{server,settings} updated to include the new required fields.
1 parent a566d0b commit 81712d5

19 files changed

Lines changed: 209 additions & 34 deletions

File tree

book/docs/config/server.md

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,31 +16,40 @@ server:
1616
rateLimit:
1717
window: 60000 # ms
1818
max: 100 # requests per window per IP
19+
x402:
20+
window: 60000 # ms
21+
max: 30 # x402 verification attempts per window per IP
1922
```
2023
2124
## Fields
2225
23-
| Field | Type | Required | Default | Description |
24-
| ----------------------------- | ---------- | -------- | ----------- | --------------------------------------------------------------------------------------------------- |
25-
| `port` | `number` | Yes | -- | TCP port the server listens on. |
26-
| `host` | `string` | No | `'0.0.0.0'` | Bind address. Use `127.0.0.1` to restrict to localhost. |
27-
| `cors` | `object` | No | -- | CORS configuration. When omitted, `Access-Control-Allow-Origin: *` is used. |
28-
| `cors.origins` | `string[]` | No | `['*']` | Allow-list of origins. The request's `Origin` is reflected back only if it matches an entry. |
29-
| `rateLimit` | `object` | **Yes** | -- | Per-IP rate limiting (required). Set `max` very high to effectively disable it. |
30-
| `rateLimit.window` | `number` | Yes | -- | Time window in milliseconds. |
31-
| `rateLimit.max` | `number` | Yes | -- | Maximum requests per IP within the window. |
32-
| `rateLimit.trustForwardedFor` | `boolean` | No | `false` | Use the first `X-Forwarded-For` entry as the client IP. Only enable behind a trusted reverse proxy. |
26+
| Field | Type | Required | Default | Description |
27+
| ----------------------------- | ---------- | -------- | ----------- | -------------------------------------------------------------------------------------------------------------------------- |
28+
| `port` | `number` | Yes | -- | TCP port the server listens on. |
29+
| `host` | `string` | No | `'0.0.0.0'` | Bind address. Use `127.0.0.1` to restrict to localhost. |
30+
| `cors` | `object` | No | -- | CORS configuration. When omitted, `Access-Control-Allow-Origin: *` is used. |
31+
| `cors.origins` | `string[]` | No | `['*']` | Allow-list of origins. The request's `Origin` is reflected back only if it matches an entry. |
32+
| `rateLimit` | `object` | **Yes** | -- | Per-IP rate limiting (required). Set `max` very high to effectively disable it. |
33+
| `rateLimit.window` | `number` | Yes | -- | Time window in milliseconds. |
34+
| `rateLimit.max` | `number` | Yes | -- | Maximum requests per IP within the window. |
35+
| `rateLimit.trustForwardedFor` | `boolean` | No | `false` | Use the first `X-Forwarded-For` entry as the client IP. Only enable behind a trusted reverse proxy. |
36+
| `rateLimit.x402` | `object` | **Yes** | -- | Stricter per-IP bucket on x402 verification attempts (each one fires several chain-RPC reads). Shares `trustForwardedFor`. |
37+
| `rateLimit.x402.window` | `number` | Yes | -- | Time window in milliseconds. |
38+
| `rateLimit.x402.max` | `number` | Yes | -- | Maximum x402 verification attempts per IP within the window. |
3339

3440
## Minimal
3541

36-
`port` and `rateLimit` are required:
42+
`port` and `rateLimit` (including the `x402` sub-block) are required:
3743

3844
```yaml
3945
server:
4046
port: 3000
4147
rateLimit:
4248
window: 60000
4349
max: 100
50+
x402:
51+
window: 60000
52+
max: 30
4453
```
4554

4655
This binds to `0.0.0.0:3000` with a default `Access-Control-Allow-Origin: *` header. If you front the airnode with your
@@ -57,6 +66,9 @@ server:
5766
rateLimit:
5867
window: 60000 # 60 seconds
5968
max: 100 # 100 requests per 60s per IP
69+
x402:
70+
window: 60000
71+
max: 30 # 30 x402 verification attempts per 60s per IP
6072
```
6173

6274
When a client exceeds the limit, the server returns `429 Too Many Requests`.
@@ -68,6 +80,14 @@ every client would share one bucket — set `rateLimit.trustForwardedFor: true`
6880
instead. Only enable this when a trusted proxy controls that header; a client-supplied `X-Forwarded-For` is otherwise
6981
trivially spoofable.
7082

83+
### x402 verification bucket
84+
85+
`rateLimit.x402` is a separate, stricter per-IP bucket that applies only to **submitted x402 payment proofs**. Each
86+
verification fires several chain-RPC reads, so an unauthenticated flooder would otherwise drain the operator's RPC quota
87+
even at a generous global `rateLimit.max`. The 402-challenge response (sent when no proof header is present) does not
88+
draw from this bucket. The same client-IP key is used as the global limit, so `trustForwardedFor` applies consistently
89+
to both. When exceeded, the response is `401 Too many x402 verification attempts — slow down`.
90+
7191
## CORS
7292

7393
CORS headers are included on every response (and on the `OPTIONS` preflight, which returns `204`):

book/docs/config/settings.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ The `settings` section configures global behavior. It is placed immediately afte
1010
```yaml
1111
settings:
1212
timeout: 10000 # default, ms
13-
maxConcurrentApiCalls: 50 # default
13+
maxConcurrentApiCalls: 50
1414
proof: none
1515
fhe: none
1616
plugins:
@@ -23,7 +23,7 @@ settings:
2323
| Field | Type | Required | Default | Description |
2424
| ----------------------- | ------------------ | -------- | -------- | ------------------------------------------------------ |
2525
| `timeout` | `number` | No | `10000` | Global upstream API request timeout in milliseconds. |
26-
| `maxConcurrentApiCalls` | `number` | No | `50` | Process-wide ceiling on concurrent upstream API calls. |
26+
| `maxConcurrentApiCalls` | `number` | **Yes** | -- | Process-wide ceiling on concurrent upstream API calls. |
2727
| `proof` | `string \| object` | No | `'none'` | Proof mode. See [Proof](#proof). |
2828
| `fhe` | `string \| object` | No | `'none'` | FHE encryption relayer. See [FHE](#fhe). |
2929
| `plugins` | `array` | No | `[]` | Plugin entries. See [Plugin Configuration](./plugins). |

examples/configs/complete/config.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,13 @@ server:
99
rateLimit:
1010
window: 60000
1111
max: 100
12+
x402:
13+
window: 60000
14+
max: 30
1215

1316
settings:
1417
timeout: 15000
18+
maxConcurrentApiCalls: 50
1519
proof: none # or: { type: reclaim, gatewayUrl: 'http://localhost:5177/v1/prove' }
1620
plugins:
1721
- source: ../../plugins/heartbeat.ts

examples/configs/fhe-encrypt/config.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,12 @@ server:
55
rateLimit:
66
window: 60000
77
max: 100
8+
x402:
9+
window: 60000
10+
max: 30
811

912
settings:
13+
maxConcurrentApiCalls: 50
1014
proof: none
1115
# FHE relayer connection. With `fhe` configured, any endpoint that has an
1216
# `encrypt` block gets its ABI-encoded value replaced with an FHE ciphertext

examples/configs/minimal/config.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,12 @@ server:
55
rateLimit:
66
window: 60000
77
max: 100
8+
x402:
9+
window: 60000
10+
max: 30
811

912
settings:
13+
maxConcurrentApiCalls: 50
1014
proof: none
1115
plugins:
1216
- source: ../../plugins/logger.ts

examples/configs/reclaim-proof/config.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,12 @@ server:
55
rateLimit:
66
window: 60000
77
max: 100
8+
x402:
9+
window: 60000
10+
max: 30
811

912
settings:
13+
maxConcurrentApiCalls: 50
1014
proof:
1115
type: reclaim
1216
gatewayUrl: http://localhost:5177/v1/prove

integration/helpers.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,24 @@ function rewriteApiUrls(apis: readonly Api[]): Api[] {
4040
}
4141

4242
interface TestServerOptions {
43-
readonly server?: Partial<Config['server']>;
43+
readonly server?: Partial<Omit<Config['server'], 'rateLimit'>> & {
44+
readonly rateLimit?: Partial<Config['server']['rateLimit']>;
45+
};
4446
readonly settings?: Partial<Config['settings']>;
4547
readonly plugins?: PluginRegistry;
4648
readonly apiOverrides?: (apis: readonly Api[]) => Api[];
4749
}
4850

51+
const DEFAULT_TEST_RATE_LIMIT: Config['server']['rateLimit'] = {
52+
// Effectively no limit by default; rate-limit scenarios override this.
53+
window: 60_000,
54+
max: 1_000_000,
55+
trustForwardedFor: false,
56+
x402: { window: 60_000, max: 1_000_000 },
57+
};
58+
4959
async function createTestServer(options: TestServerOptions = {}): Promise<TestContext> {
50-
const serverOverrides = options.server ?? {};
60+
const { rateLimit: rateLimitOverride, ...serverOverrides } = options.server ?? {};
5161
const settingsOverrides = options.settings ?? {};
5262
await loadEnvFile(ENV_PATH);
5363

@@ -60,8 +70,7 @@ async function createTestServer(options: TestServerOptions = {}): Promise<TestCo
6070
server: {
6171
...parsed.server,
6272
port: 0,
63-
// Effectively no limit by default; rate-limit scenarios override this.
64-
rateLimit: { window: 60_000, max: 1_000_000, trustForwardedFor: false },
73+
rateLimit: { ...DEFAULT_TEST_RATE_LIMIT, ...rateLimitOverride },
6574
...serverOverrides,
6675
},
6776
settings: { ...parsed.settings, plugins: [], ...settingsOverrides },
@@ -83,6 +92,7 @@ async function createTestServer(options: TestServerOptions = {}): Promise<TestCo
8392
asyncStore,
8493
apiCallSemaphore,
8594
settings: testConfig.settings,
95+
rateLimit: testConfig.server.rateLimit,
8696
handleRequest: handleEndpointRequest,
8797
});
8898

integration/scenarios/s42-forwarded-for.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ async function hit(baseUrl: string, forwardedFor: string): Promise<number> {
2424

2525
describe('S42 — X-Forwarded-For rate limiting', () => {
2626
test('with trustForwardedFor on, each forwarded client has an independent bucket', async () => {
27-
ctx = await createTestServer({ server: { rateLimit: { window: 60_000, max: 3, trustForwardedFor: true } } });
27+
ctx = await createTestServer({ server: { rateLimit: { max: 3, trustForwardedFor: true } } });
2828

2929
expect(await hit(ctx.baseUrl, '1.2.3.4')).toBe(200);
3030
expect(await hit(ctx.baseUrl, '1.2.3.4')).toBe(200);
@@ -38,7 +38,7 @@ describe('S42 — X-Forwarded-For rate limiting', () => {
3838
});
3939

4040
test('with trustForwardedFor off, the header is ignored and all callers share one bucket', async () => {
41-
ctx = await createTestServer({ server: { rateLimit: { window: 60_000, max: 3, trustForwardedFor: false } } });
41+
ctx = await createTestServer({ server: { rateLimit: { max: 3 } } });
4242

4343
expect(await hit(ctx.baseUrl, '1.1.1.1')).toBe(200);
4444
expect(await hit(ctx.baseUrl, '2.2.2.2')).toBe(200);

integration/scenarios/s8-rate-limiting.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { TestContext } from '../helpers';
55
let ctx: TestContext;
66

77
beforeAll(async () => {
8-
ctx = await createTestServer({ server: { rateLimit: { window: 60_000, max: 3, trustForwardedFor: false } } });
8+
ctx = await createTestServer({ server: { rateLimit: { max: 3 } } });
99
});
1010

1111
afterAll(() => {

src/auth.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,4 +423,26 @@ describe('x402 auth', () => {
423423
const ok = await authenticateRequest(makeRequest(proofHeader(stillOkProof)), otherIpCtx, x402Config);
424424
expect(ok.authenticated).toBe(true);
425425
});
426+
427+
test('honors a custom x402RateLimit from config', async () => {
428+
setRpcClientFactory(() => fakeRpc({ txFrom: '0x000000000000000000000000000000000000bEEF', txTo: RECIPIENT }));
429+
const ipCtx: AuthContext = {
430+
...CTX,
431+
clientIp: '203.0.113.77', // a fresh IP, so the bucket is full
432+
x402RateLimit: { window: 60_000, max: 3 },
433+
};
434+
435+
// Burn the tight 3-token budget.
436+
for (let i = 0; i < 3; i++) {
437+
const txHash: Hex = `0xaa${i.toString(16).padStart(62, '0')}`;
438+
const proof = await makeSignedProof({ txHash });
439+
const r = await authenticateRequest(makeRequest(proofHeader(proof)), ipCtx, x402Config);
440+
expect(expectError(r)).toBe('Payment verification failed');
441+
}
442+
443+
// Fourth attempt is blocked by the configured limit.
444+
const overflow = await makeSignedProof({ txHash: `0xbb${'00'.repeat(31)}` });
445+
const blocked = await authenticateRequest(makeRequest(proofHeader(overflow)), ipCtx, x402Config);
446+
expect(expectError(blocked)).toBe('Too many x402 verification attempts — slow down');
447+
});
426448
});

0 commit comments

Comments
 (0)