Skip to content

Commit e90aa42

Browse files
committed
Address audit design gaps: proof hardening, stream/async consistency, rate limiting, x402 cleanup
- TLS proof: requestProof rejects a proof whose claim.parameters don't match the request Airnode made (or that is malformed) — non-fatal; corrected the misleading pipeline comment; documented request-vs-response divergence. - Streaming mode runs inside runWithContext and calls callError/callResponseSent like the sync path, and propagates a non-200 pipeline outcome as a plain HTTP error response rather than a 200 SSE frame carrying an error payload. - The response cache is sync-mode only — async/stream neither read nor write it (serving a cached JSON body there would break the 202/SSE contract). - Async store: finished requests are retained only ~60s for polling instead of the full 10-minute TTL, and a finished/expired slot is reclaimed when the store is full — MAX_PENDING is no longer a sustained-throughput cap. - Rate limiting: opt-in server.rateLimit.trustForwardedFor uses the first X-Forwarded-For entry as the key (for Airnode behind a trusted reverse proxy); default false. - TLS proof gateway timeout is configurable via settings.proof.timeout (default 30000ms). - Dropped the vestigial x402 paymentId — the txHash dedup is the per-payment uniqueness guard; the signed message is now keccak256(encodePacked(airnode, endpointId, uint64(expiresAt))). Also clarified that this scheme is x402-flavoured, not the x402 wire protocol. - buildApiRequest rejects a cookie parameter value containing ';', CR, or LF. - FHE instance: caches the createInstance promise (concurrent first requests share one call), drops a rejected promise so the next request retries (also picks up a rotated chain key), and clears the instance on an encrypt failure. - Documented: caching an encrypt endpoint replays the same signed ciphertext (only one on-chain submission lands per window); set encoding.times on encrypt endpoints; the canonical endpoint-ID string format.
1 parent 2f68456 commit e90aa42

23 files changed

Lines changed: 533 additions & 133 deletions

book/docs/concepts/fhe-encryption.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,14 @@ encrypted. The auction contract compares `FHE.gt(bid, reservePrice)` without rev
201201
- **One consumer per endpoint.** The encrypted input is bound to `encrypt.contract`, so an endpoint feeds exactly one
202202
consumer contract. If several contracts need the same feed, deploy a shared registry contract (the
203203
`ConfidentialPriceFeed` pattern) that re-shares the handle via `FHE.allow`, or define one endpoint per consumer.
204+
- **Avoid caching encrypt endpoints.** A [cached](/docs/config/apis#cache) response replays the same signed bytes for
205+
the whole TTL — and `AirnodeVerifier` deduplicates on `keccak256(endpointId, timestamp, data)`, so only the _first_
206+
on-chain submission of a cached ciphertext succeeds; the rest revert "already fulfilled". A cache on an encrypt
207+
endpoint therefore both serves stale data and caps on-chain ingestion to one submission per window. Leave `cache`
208+
unset unless that's actually what you want.
209+
- **Set `encoding.times` explicitly.** If you omit `encoding.times`, the requester controls the scale multiplier via the
210+
`_times` parameter (and the endpoint ID records `times=*`). For an encrypt endpoint you almost always want a fixed
211+
scale — set `times: '1'` if you mean "no scaling".
204212
- **Throughput.** The Zama coprocessor currently handles a limited number of input verifications per second.
205213
High-frequency price feeds may hit this limit.
206214
- **Numeric, encoded responses only.** `encrypt` requires an `encoding` block producing a single `int256`/`uint256`

book/docs/concepts/proofs.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,14 +113,29 @@ When a proof is successfully generated, the response includes a `proof` field:
113113
The `proof.signatures.attestorAddress` identifies the attestor that generated the proof. The `claimSignature` is a
114114
signature over the claim data that can be verified independently.
115115

116+
## What the proof attests (and what it doesn't)
117+
118+
The proof covers the _request_ exactly: Airnode hands the gateway the same URL, method, headers, and body it actually
119+
sent, and rejects any returned proof whose `claim.parameters` disagree (treated as a non-fatal failure — see below).
120+
121+
It does **not** guarantee that the proof's _response_ equals the data Airnode signed. The attestor performs its own TLS
122+
session to the upstream, separate from Airnode's. For volatile data (a price that ticks between the two fetches) the
123+
attested response can legitimately differ from the signed payload. A future on-chain verifier that wants to bind the
124+
proof to the signed data must account for this — e.g. by comparing only the `responseMatches`-extracted fields and
125+
allowing a tolerance, not by requiring byte-for-byte equality.
126+
116127
## Non-fatal behavior
117128

118-
Proof generation is **non-fatal**. If the proof gateway is unavailable, times out, or returns an error:
129+
Proof generation is **non-fatal**. If the proof gateway is unavailable, times out, returns an error, or returns a proof
130+
that doesn't match the request Airnode made:
119131

120132
- The response is still returned without the `proof` field.
121133
- A `WARN` log is emitted with the failure reason.
122134
- The EIP-191 signature is unaffected.
123135

136+
The gateway timeout is configurable via [`settings.proof.timeout`](/docs/config/settings#proof) (default 30s). Because
137+
the proof is fetched after signing on the sync path, that timeout is added to the response latency on a slow gateway.
138+
124139
This ensures that proof infrastructure issues do not block data delivery. Consumers that require proofs should check for
125140
the presence of the `proof` field and reject responses without it.
126141

book/docs/config/apis.md

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -82,27 +82,32 @@ key rotation.
8282

8383
### x402 (HTTP-native payment)
8484

85-
Pay-per-request using on-chain transfers. When a client requests without payment, the server returns a 402:
85+
Pay-per-request using on-chain transfers. When a client requests without payment, the server returns a 402.
86+
87+
This is an x402-_flavoured_ scheme — it borrows the HTTP 402 pay-per-request idea but is **not** the x402 wire protocol:
88+
clients pay on-chain first and then prove the confirmed transaction, rather than handing over a signed EIP-3009
89+
authorization in an `X-PAYMENT` header.
8690

8791
```yaml
8892
auth:
8993
type: x402
9094
network: 8453 # chain ID for payment
9195
rpc: https://mainnet.base.org
9296
token: '0xA0b8...' # ERC-20 address (or 0x000...0 for ETH)
93-
amount: '1000000' # in token's smallest unit (e.g. 1 USDC = 1000000)
97+
amount: '1000000' # token base units, integer string (e.g. 1 USDC = 1000000)
9498
recipient: '0x...' # operator's address
9599
expiry: 300000 # payment window in ms (default 5 min)
96100
```
97101

98-
Flow: client POSTs → gets 402 with payment details (including `airnode`, `endpointId`, `paymentId`, `expiresAt`) → sends
99-
on-chain transfer → signs `keccak256(encodePacked(airnode, endpointId, paymentId, uint64(expiresAt)))` with the payer's
100-
EOA → retries with `X-Payment-Proof: <json>` where the JSON is
101-
`{ "txHash": "0x…", "paymentId": "0x…", "expiresAt": <unix-seconds>, "signature": "0x…" }`.
102+
Flow: client POSTs → gets `402` with payment details (`airnode`, `endpointId`, `amount`, `token`, `network`,
103+
`recipient`, `expiresAt`) → sends the on-chain transfer → signs
104+
`keccak256(encodePacked(airnode, endpointId, uint64(expiresAt)))` with the payer's EOA → retries with
105+
`X-Payment-Proof: <json>` where the JSON is `{ "txHash": "0x…", "expiresAt": <unix-seconds>, "signature": "0x…" }`.
102106

103107
The server checks that the signature recovers to the transaction's sender, that the proof has not expired, and that the
104-
transaction matches the configured amount and recipient. This binds the payment to a specific request so mempool
105-
observers cannot steal it and cross-endpoint upgrade attacks are blocked. Each tx hash can only be used once.
108+
transaction matches the configured amount and recipient. The signature binds the payment to a specific airnode and
109+
endpoint (so it can't be replayed elsewhere) and to a short `expiresAt` window. Each `txHash` is the per-payment
110+
uniqueness key — it can be redeemed exactly once.
106111

107112
`expiresAt` must be a future unix-seconds timestamp no further ahead than 10 minutes; longer-lived proofs are rejected.
108113

@@ -191,7 +196,8 @@ Controls how the server delivers the response:
191196
polls `GET /requests/{requestId}` until the status is `complete` or `failed`.
192197
- **`stream`** — return the signed response as a Server-Sent Event (SSE). The response has
193198
`Content-Type: text/event-stream`. The full pipeline runs (including plugins), and the signed result is delivered as a
194-
single `data:` event with `done: true`.
199+
single `data:` event with `done: true`. A pipeline error is returned as the plain HTTP error response, not an SSE
200+
frame.
195201

196202
```yaml
197203
endpoints:
@@ -206,6 +212,10 @@ endpoints:
206212
mode: stream # return SSE events
207213
```
208214

215+
The response [cache](#cache) applies to `sync`-mode endpoints only. `async` returns a `202`+`pollUrl` and `stream`
216+
returns an SSE frame, so a cached plain-JSON body would break those response contracts — those modes neither read nor
217+
write the cache.
218+
209219
## Parameters
210220

211221
Parameters define the inputs to an upstream API call. Each parameter specifies where to send its value and how it is
@@ -348,7 +358,7 @@ Produces the request body:
348358

349359
### Cookie parameters
350360

351-
Sent as cookies on the upstream request:
361+
Sent as cookies on the upstream request, joined into a single `Cookie` header:
352362

353363
```yaml
354364
parameters:
@@ -358,6 +368,9 @@ parameters:
358368
secret: true
359369
```
360370

371+
Cookie values are concatenated verbatim, so a value containing `;`, CR, or LF (which would let it inject extra cookie
372+
pairs or split the header) is rejected — keep cookie values to ordinary cookie content.
373+
361374
## `responseMatches`
362375

363376
Defines regex patterns that a TLS proof attestor checks against the API response. Required for

book/docs/config/server.md

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,16 @@ server:
2020
2121
## Fields
2222
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 | `['*']` | Allowed origins. Each entry is joined with `, ` in the response header. |
29-
| `rateLimit` | `object` | No | -- | Per-IP rate limiting. When omitted, no rate limiting is applied. |
30-
| `rateLimit.window` | `number` | Yes | -- | Time window in milliseconds. |
31-
| `rateLimit.max` | `number` | Yes | -- | Maximum requests per IP within the window. |
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 | `['*']` | Allowed origins. Each entry is joined with `, ` in the response header. |
29+
| `rateLimit` | `object` | No | -- | Per-IP rate limiting. When omitted, no rate limiting is applied. |
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. |
3233

3334
## Minimal
3435

@@ -58,6 +59,11 @@ When a client exceeds the limit, the server returns `429 Too Many Requests`.
5859

5960
The rate limiter tracks up to 10,000 unique IPs. When this limit is reached, the oldest entries are evicted.
6061

62+
By default the client IP is the socket peer. If Airnode runs behind a reverse proxy that is the proxy's address, so
63+
every client would share one bucket — set `rateLimit.trustForwardedFor: true` to use the first `X-Forwarded-For` entry
64+
instead. Only enable this when a trusted proxy controls that header; a client-supplied `X-Forwarded-For` is otherwise
65+
trivially spoofable.
66+
6167
## CORS
6268

6369
CORS headers are included on every response. The `OPTIONS` preflight handler returns:

book/docs/config/settings.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,11 @@ settings:
5858
gatewayUrl: http://localhost:5177/v1/prove
5959
```
6060

61-
| Field | Type | Required | Description |
62-
| ------------ | -------- | -------- | ------------------------------------------------------------ |
63-
| `type` | `string` | Yes | Must be `'reclaim'`. |
64-
| `gatewayUrl` | `string` | Yes | Full URL of the proof gateway endpoint. Must be a valid URL. |
61+
| Field | Type | Required | Description |
62+
| ------------ | -------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
63+
| `type` | `string` | Yes | Must be `'reclaim'`. |
64+
| `gatewayUrl` | `string` | Yes | Full URL of the proof gateway endpoint. Must be a valid URL. |
65+
| `timeout` | `number` | No (default `30000`) | Gateway request timeout in milliseconds. The proof is fetched after signing on the sync path, so this latency is added to the response; a timeout just omits the `proof` field. |
6566

6667
When enabled, Airnode requests a TLS proof from the gateway after each API call. The proof is attached to the response
6768
in a `proof` field alongside the signature. See [TLS Proofs](/docs/concepts/proofs) for details on how proofs work.

book/docs/consumers/http-client.md

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -149,13 +149,15 @@ The full pipeline runs (including plugins), and the signed result is delivered a
149149

150150
## x402 payment
151151

152+
> This is an x402-_flavoured_ scheme — pay on-chain first, then prove the confirmed transaction. It is **not** the x402
153+
> wire protocol (no `X-PAYMENT`/EIP-3009 authorization).
154+
152155
For endpoints with x402 auth, the first request returns 402 with payment details:
153156

154157
```json
155158
{
156159
"airnode": "0x...",
157160
"endpointId": "0x...",
158-
"paymentId": "0x...",
159161
"amount": "1000000",
160162
"token": "0xA0b8...",
161163
"network": 8453,
@@ -164,18 +166,17 @@ For endpoints with x402 auth, the first request returns 402 with payment details
164166
}
165167
```
166168

167-
After sending the on-chain transfer, the payer signs an authorisation binding the payment to this specific request and
168-
airnode:
169+
After sending the on-chain transfer, the payer signs an authorisation binding the payment to this airnode and endpoint:
169170

170171
```
171-
message = keccak256(encodePacked(airnode, endpointId, paymentId, uint64(expiresAt)))
172+
message = keccak256(encodePacked(airnode, endpointId, uint64(expiresAt)))
172173
signature = EIP-191 personal-sign(message) with the EOA that sent the transaction
173174
```
174175

175176
Retry with a JSON-encoded `X-Payment-Proof` header:
176177

177178
```bash
178-
PROOF='{"txHash":"0x...","paymentId":"0x...","expiresAt":1700001000,"signature":"0x..."}'
179+
PROOF='{"txHash":"0x...","expiresAt":1700001000,"signature":"0x..."}'
179180

180181
curl -X POST http://airnode.example.com/endpoints/0x... \
181182
-H "Content-Type: application/json" \
@@ -184,8 +185,9 @@ curl -X POST http://airnode.example.com/endpoints/0x... \
184185
```
185186

186187
The server verifies the signature recovers to the transaction sender and checks that `expiresAt` is in the future and no
187-
more than 10 minutes ahead. This binds the payment to the specific request so mempool observers cannot steal the call,
188-
and signatures cannot be reused across different airnodes, endpoints, or (after expiry) time.
188+
more than 10 minutes ahead. This binds the payment to the specific airnode and endpoint (signatures can't be reused
189+
across either, nor after expiry), and each `txHash` can be redeemed only once — that on-chain hash is the per-payment
190+
uniqueness key.
189191

190192
## Error responses
191193

src/api/call.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,21 @@ describe('callApi', () => {
357357
expect((options.headers as Record<string, string>)['Cookie']).toBeUndefined();
358358
});
359359

360+
test('rejects a cookie parameter value that could inject extra cookies', async () => {
361+
const api = makeApi();
362+
const endpoint = makeEndpoint({
363+
parameters: [{ name: 'session', in: 'cookie', required: true, secret: false }],
364+
});
365+
366+
const semicolon = callApi(api, endpoint, { session: 'abc; evil=1' });
367+
expect(semicolon).rejects.toThrow('Cookie parameter "session"');
368+
await semicolon.catch(() => {});
369+
370+
const crlf = callApi(api, endpoint, { session: 'abc\r\nX-Injected: 1' });
371+
expect(crlf).rejects.toThrow('CR, or LF');
372+
await crlf.catch(() => {});
373+
});
374+
360375
test('returns null data for empty response body (204 No Content)', async () => {
361376
fetchMock.mockResolvedValue({
362377
text: () => Promise.resolve(''),

src/api/call.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,16 @@ function buildApiRequest(api: Api, endpoint: Endpoint, requestParameters: Record
7373
};
7474

7575
if (cookieParameters.length > 0) {
76-
const cookieString = cookieParameters.map((p) => `${p.name}=${p.value as string}`).join('; ');
76+
// Cookie values are concatenated raw into the Cookie header, so a `;`, CR,
77+
// or LF in a (possibly requester-supplied) value would let it inject extra
78+
// cookie pairs or split the header — reject those outright. (Path/query are
79+
// percent-encoded and header values are validated by fetch; only cookies
80+
// are joined verbatim.)
81+
const invalidCookie = cookieParameters.find((p) => /[;\r\n]/.test(String(p.value)));
82+
if (invalidCookie) {
83+
throw new Error(`Cookie parameter "${invalidCookie.name}" value must not contain ';', CR, or LF`);
84+
}
85+
const cookieString = cookieParameters.map((p) => `${p.name}=${String(p.value)}`).join('; ');
7786
const existing = headers['Cookie'];
7887
headers['Cookie'] = existing ? `${existing}; ${cookieString}` : cookieString; // eslint-disable-line functional/immutable-data
7988
}

0 commit comments

Comments
 (0)