Skip to content

Commit 1fa7ed6

Browse files
committed
Harden HTTP surface: explicit idleTimeout, drop /health version, scrub log URLs
- server.ts: set Bun.serve `idleTimeout` explicitly, derived from the upstream + proof timeouts (so a request waiting on a slow upstream isn't killed mid-flight) and capped at 255s — bounds connections that just sit there. - /health no longer returns `version` — don't volunteer build info on an unauthenticated endpoint. It returns `{ status, airnode }`; the airnode address is operationally useful and public anyway. (`airnode --version` for the build.) - logger.ts: strip the query string from any URL in a log line / error message / stack (`https://…?api_key=…` → `https://…?[redacted]`). Upstream creds are normally in headers, but some APIs — and the fetch errors they produce — put them in the query string; keep them out of logs and aggregators. - Tests + docs updated for the /health shape; logger redaction unit-tested. (The TLS-proof gateway timeout stays at 30s — zkTLS attestations are genuinely slow; the right fix for "30s blocking" is a non-blocking proof, which is a separate change.)
1 parent 82eb5c5 commit 1fa7ed6

10 files changed

Lines changed: 78 additions & 30 deletions

File tree

book/docs/concepts/architecture.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ starts, loads the config, and serves requests.
1515
| ------ | ------------------------- | ---------------------------------------------------- |
1616
| `POST` | `/endpoints/{endpointId}` | Call an endpoint with parameters in the request body |
1717
| `GET` | `/requests/{requestId}` | Poll an async request for its result |
18-
| `GET` | `/health` | Health check returning version and airnode address |
18+
| `GET` | `/health` | Health check (status + airnode address) |
1919

2020
CORS preflight (`OPTIONS`) is handled automatically. Rate limiting uses a token bucket per client IP, configured via
2121
`server.rateLimit`.

book/docs/consumers/http-client.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,5 +208,9 @@ uniqueness key.
208208
curl http://airnode.example.com/health
209209
```
210210

211-
Returns the airnode address and version. Use this to verify the server is running and to discover the airnode address
212-
for signature verification.
211+
```json
212+
{ "status": "ok", "airnode": "0x..." }
213+
```
214+
215+
Use this to verify the server is running and to discover the airnode address for signature verification. (No version
216+
field — build info isn't exposed on this endpoint.)

book/docs/introduction.md

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -181,15 +181,14 @@ curl http://localhost:3000/health
181181
```json
182182
{
183183
"status": "ok",
184-
"version": "2.0.0-alpha.0",
185184
"airnode": "0x..."
186185
}
187186
```
188187

189188
## Routes
190189

191-
| Method | Path | Description |
192-
| ------ | ------------------------- | --------------------------------------------- |
193-
| `POST` | `/endpoints/{endpointId}` | Call an endpoint with parameters |
194-
| `GET` | `/requests/{requestId}` | Poll an async request for its result |
195-
| `GET` | `/health` | Health check with version and airnode address |
190+
| Method | Path | Description |
191+
| ------ | ------------------------- | --------------------------------------- |
192+
| `POST` | `/endpoints/{endpointId}` | Call an endpoint with parameters |
193+
| `GET` | `/requests/{requestId}` | Poll an async request for its result |
194+
| `GET` | `/health` | Health check (status + airnode address) |

book/docs/operators/deployment.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ The `AIRNODE_PRIVATE_KEY` controls the airnode's on-chain identity and signs all
151151

152152
## Health checks
153153

154-
The `/health` endpoint returns the node's status, version, and airnode address:
154+
The `/health` endpoint returns the node's status and airnode address:
155155

156156
```bash
157157
curl http://localhost:3000/health
@@ -160,9 +160,9 @@ curl http://localhost:3000/health
160160
```json
161161
{
162162
"status": "ok",
163-
"version": "2.0.0-alpha.0",
164163
"airnode": "0x..."
165164
}
166165
```
167166

168-
Use this for Docker `HEALTHCHECK`, load balancer probes, or uptime monitoring.
167+
Use this for Docker `HEALTHCHECK`, load balancer probes, or uptime monitoring. (To check the binary version, run
168+
`airnode --version` — the version is deliberately not exposed on `/health`.)

book/docs/operators/index.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,6 @@ Expected response:
9292
```json
9393
{
9494
"status": "ok",
95-
"version": "2.0.0-alpha.0",
9695
"airnode": "0x..."
9796
}
9897
```

integration/scenarios/s20-health.test.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
2-
import { VERSION } from '../../src/version';
32
import { AIRNODE_ADDRESS, createTestServer } from '../helpers';
43
import type { TestContext } from '../helpers';
54

@@ -14,14 +13,12 @@ afterAll(() => {
1413
});
1514

1615
describe('S20 — Health endpoint', () => {
17-
test('returns status, version, and airnode address', async () => {
16+
test('returns status and the airnode address (no version field)', async () => {
1817
const response = await fetch(`${ctx.baseUrl}/health`);
19-
const body = (await response.json()) as { status: string; version: string; airnode: string };
18+
const body = (await response.json()) as Record<string, unknown>;
2019

2120
expect(response.status).toBe(200);
22-
expect(body.status).toBe('ok');
23-
expect(body.version).toBe(VERSION);
24-
expect(body.airnode).toBe(AIRNODE_ADDRESS);
21+
expect(body).toEqual({ status: 'ok', airnode: AIRNODE_ADDRESS });
2522
});
2623

2724
test('returns 404 for unknown routes', async () => {

src/logger.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,3 +237,27 @@ describe('runWithContext', () => {
237237
expect(result).toBe('async result');
238238
});
239239
});
240+
241+
describe('secret redaction', () => {
242+
test('strips a URL query string from a log message', () => {
243+
logger.error('API call failed: https://api.example.com/v3/price?x_cg_pro_api_key=SUPERSECRET&vs=usd');
244+
const output = lastCall(errorMock);
245+
expect(output).toContain('https://api.example.com/v3/price?[redacted]');
246+
expect(output).not.toContain('SUPERSECRET');
247+
expect(output).not.toContain('x_cg_pro_api_key');
248+
});
249+
250+
test('leaves a URL without a query string alone', () => {
251+
logger.info('GET https://api.example.com/health 200 3ms');
252+
expect(lastCall(infoMock)).toContain('https://api.example.com/health 200 3ms');
253+
});
254+
255+
test('redacts the query string in an Error message and stack (json format)', () => {
256+
configureLogger('json');
257+
logger.error('upstream unreachable', new Error('connect failed: https://api.example.com/p?token=LEAK'));
258+
const entry = JSON.parse(lastCall(errorMock)) as { error: { message: string; stack: string } };
259+
expect(entry.error.message).toBe('connect failed: https://api.example.com/p?[redacted]');
260+
expect(entry.error.message).not.toContain('LEAK');
261+
expect(entry.error.stack).not.toContain('LEAK');
262+
});
263+
});

src/logger.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,15 @@ export function getContext(): LogContext | undefined {
2626

2727
const MIN_MESSAGE_WIDTH = 80;
2828

29+
// Strip the query string from any URL in a log line. Upstream API credentials are
30+
// usually passed in headers, but some APIs (and the resulting fetch error
31+
// messages) put them in `?api_key=…`; this keeps them out of logs / aggregators.
32+
const URL_WITH_QUERY = /(\bhttps?:\/\/[^\s?#"'`<>]*)\?[^\s#"'`<>]*/gi;
33+
34+
function redactSecrets(text: string): string {
35+
return text.replaceAll(URL_WITH_QUERY, '$1?[redacted]');
36+
}
37+
2938
function formatText(level: LogLevel, message: string, context: LogContext | undefined): string {
3039
const timestamp = new Date().toISOString();
3140
const paddedMessage = message.padEnd(MIN_MESSAGE_WIDTH);
@@ -40,14 +49,23 @@ function formatJson(level: LogLevel, message: string, context: LogContext | unde
4049
level,
4150
message,
4251
...(context ? { requestId: context.requestId } : {}),
43-
...(error ? { error: { name: error.name, message: error.message, stack: error.stack } } : {}),
52+
...(error
53+
? {
54+
error: {
55+
name: error.name,
56+
message: redactSecrets(error.message),
57+
stack: error.stack ? redactSecrets(error.stack) : undefined,
58+
},
59+
}
60+
: {}),
4461
};
4562

4663
return JSON.stringify(entry);
4764
}
4865

49-
function formatEntry(level: LogLevel, message: string, error?: Error): string {
66+
function formatEntry(level: LogLevel, rawMessage: string, error?: Error): string {
5067
const context = logStore.getStore();
68+
const message = redactSecrets(rawMessage);
5169

5270
if (logFormat === 'json') {
5371
return formatJson(level, message, context, error);
@@ -58,7 +76,7 @@ function formatEntry(level: LogLevel, message: string, error?: Error): string {
5876
return base;
5977
}
6078

61-
return `${base}\n${error.stack}`;
79+
return `${base}\n${redactSecrets(error.stack)}`;
6280
}
6381

6482
export const logger = {

src/server.test.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import { createServer } from './server';
77
import type { ServerDependencies, ServerHandle } from './server';
88
import { createAirnodeAccount } from './sign';
99
import type { Config } from './types';
10-
import { VERSION } from './version';
1110

1211
const TEST_PRIVATE_KEY: Hex = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80';
1312
const TEST_AIRNODE: Hex = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266';
@@ -61,18 +60,16 @@ describe('createServer', () => {
6160
void server?.stop();
6261
});
6362

64-
test('health endpoint returns status, version, airnode', async () => {
63+
test('health endpoint returns status and airnode (no version)', async () => {
6564
const deps = makeDeps();
6665
server = createServer(deps);
6766
baseUrl = `http://127.0.0.1:${String(server.port)}`;
6867

6968
const response = await fetch(`${baseUrl}/health`);
70-
const body = (await response.json()) as { status: string; version: string; airnode: string };
69+
const body = (await response.json()) as Record<string, unknown>;
7170

7271
expect(response.status).toBe(200);
73-
expect(body.status).toBe('ok');
74-
expect(body.version).toBe(VERSION);
75-
expect(body.airnode).toBe(TEST_AIRNODE);
72+
expect(body).toEqual({ status: 'ok', airnode: TEST_AIRNODE });
7673
});
7774

7875
test('POST to /endpoints/{id} calls handleRequest', async () => {

src/server.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import type { PluginRegistry } from './plugins';
1010
import { checkRateLimit } from './rate-limit';
1111
import type { TokenBucket } from './rate-limit';
1212
import type { Config } from './types';
13-
import { VERSION } from './version';
1413

1514
// =============================================================================
1615
// Types
@@ -194,7 +193,10 @@ function createServer(deps: ServerDependencies): ServerHandle {
194193
}
195194

196195
if (url.pathname === '/health' && request.method === 'GET') {
197-
return jsonResponse({ status: 'ok', version: VERSION, airnode: deps.airnode }, 200, cors);
196+
// Deliberately no version field — don't volunteer build info on an
197+
// unauthenticated endpoint. The airnode address (the actual identity) is
198+
// useful for "is this the airnode I expect?" and is public anyway.
199+
return jsonResponse({ status: 'ok', airnode: deps.airnode }, 200, cors);
198200
}
199201

200202
// Async request polling
@@ -244,9 +246,17 @@ function createServer(deps: ServerDependencies): ServerHandle {
244246
return errorResponse('Not Found', 404, cors);
245247
}
246248

249+
// Connection idle timeout (seconds, Bun caps it at 255). Set it explicitly,
250+
// and high enough that a legitimate request waiting on a slow upstream + TLS
251+
// proof isn't killed mid-flight, while still bounding connections that just
252+
// sit there. Worst-case wait ≈ upstream timeout + proof timeout + overhead.
253+
const proofTimeoutMs = deps.settings.proof === 'none' ? 0 : deps.settings.proof.timeout;
254+
const idleTimeout = Math.min(255, Math.ceil((deps.settings.timeout + proofTimeoutMs) / 1000) + 20);
255+
247256
const server = Bun.serve({
248257
port: deps.config.server.port,
249258
hostname: deps.config.server.host,
259+
idleTimeout,
250260
fetch: async (request: Request, bunServer): Promise<Response> => {
251261
const start = Date.now();
252262
const url = new URL(request.url);

0 commit comments

Comments
 (0)