From 94004a1b079d974fb6698c96987fd8bbb27252d0 Mon Sep 17 00:00:00 2001 From: pq198363-ops <246611021+pq198363-ops@users.noreply.github.com> Date: Sat, 4 Jul 2026 09:06:29 +0800 Subject: [PATCH] security: configure server timeouts --- README.md | 15 +++++++++ src/index.ts | 66 ++++++++++++++++++++++++++++++++++++- src/server-timeouts.test.ts | 64 +++++++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 src/server-timeouts.test.ts diff --git a/README.md b/README.md index 337ee4f..e35db2c 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,21 @@ agentpay-backend/ stroops, `priceStroops`, `billedStroops`, `/api/v1/billing/*`, and why `POST /api/v1/settle` drains backend counters without moving funds. +## Server Timeouts + +The HTTP listener configures request, header, keep-alive, and inactive socket +timeouts to bound slow or hung connections: + +| Environment variable | Default | Description | +| ---------------------- | ------- | ---------------------------------------- | +| `REQUEST_TIMEOUT_MS` | `30000` | Maximum time for a complete request. | +| `HEADERS_TIMEOUT_MS` | `10000` | Maximum time to receive request headers. | +| `KEEPALIVE_TIMEOUT_MS` | `5000` | Idle keep-alive socket lifetime. | + +Invalid, zero, negative, or decimal override values fall back to the defaults. +`HEADERS_TIMEOUT_MS` is raised to at least `KEEPALIVE_TIMEOUT_MS` so keep-alive +reuse does not violate Node's timeout invariant. + ## Quickstart Start a local backend on `http://localhost:3001` with the checked-in diff --git a/src/index.ts b/src/index.ts index 9ece0f2..998f2b5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ import express from "express"; +import type { Server } from "node:http"; import { installPreRouteMiddleware, installRequestStateMiddleware, @@ -16,6 +17,68 @@ import { createWebhooksRouter } from "./routes/webhooks.js"; const PORT = process.env.PORT ?? 3001; +type ServerTimeouts = { + requestTimeoutMs: number; + headersTimeoutMs: number; + keepAliveTimeoutMs: number; +}; + +const DEFAULT_SERVER_TIMEOUTS: ServerTimeouts = { + requestTimeoutMs: 30_000, + headersTimeoutMs: 10_000, + keepAliveTimeoutMs: 5_000, +}; + +function positiveIntegerEnv( + env: NodeJS.ProcessEnv, + key: string, + fallback: number +): number { + const raw = env[key]; + if (raw === undefined) return fallback; + const parsed = Number(raw); + if (!Number.isInteger(parsed) || parsed <= 0) return fallback; + return parsed; +} + +function resolveServerTimeouts(env: NodeJS.ProcessEnv): ServerTimeouts { + const requestTimeoutMs = positiveIntegerEnv( + env, + "REQUEST_TIMEOUT_MS", + DEFAULT_SERVER_TIMEOUTS.requestTimeoutMs + ); + const keepAliveTimeoutMs = positiveIntegerEnv( + env, + "KEEPALIVE_TIMEOUT_MS", + DEFAULT_SERVER_TIMEOUTS.keepAliveTimeoutMs + ); + const configuredHeadersTimeoutMs = positiveIntegerEnv( + env, + "HEADERS_TIMEOUT_MS", + DEFAULT_SERVER_TIMEOUTS.headersTimeoutMs + ); + const headersTimeoutMs = Math.max(configuredHeadersTimeoutMs, keepAliveTimeoutMs); + + return { requestTimeoutMs, headersTimeoutMs, keepAliveTimeoutMs }; +} + +/** + * Applies bounded HTTP server timeouts to limit slow or hung connections. + */ +function configureServerTimeouts( + server: Server, + env: NodeJS.ProcessEnv = process.env +): ServerTimeouts { + const timeouts = resolveServerTimeouts(env); + server.requestTimeout = timeouts.requestTimeoutMs; + server.headersTimeout = timeouts.headersTimeoutMs; + server.keepAliveTimeout = timeouts.keepAliveTimeoutMs; + server.setTimeout(timeouts.requestTimeoutMs, (socket) => { + socket.destroy(); + }); + return timeouts; +} + /** * Composes the AgentPay Express application from route and middleware modules. */ @@ -48,6 +111,7 @@ if (process.argv[1]?.endsWith("index.js") || process.argv[1]?.endsWith("index.ts const server = app.listen(PORT, () => { console.log(`AgentPay backend listening on port ${PORT}`); }); + configureServerTimeouts(server); const shutdown = (signal: string) => { console.log(`Received ${signal}, draining…`); @@ -67,4 +131,4 @@ if (process.argv[1]?.endsWith("index.js") || process.argv[1]?.endsWith("index.ts process.on("SIGINT", () => shutdown("SIGINT")); } -export { app, createApp }; +export { app, configureServerTimeouts, createApp, DEFAULT_SERVER_TIMEOUTS }; diff --git a/src/server-timeouts.test.ts b/src/server-timeouts.test.ts new file mode 100644 index 0000000..a64f01d --- /dev/null +++ b/src/server-timeouts.test.ts @@ -0,0 +1,64 @@ +import assert from "node:assert"; +import { createServer } from "node:http"; +import { describe, it } from "node:test"; +import { configureServerTimeouts, DEFAULT_SERVER_TIMEOUTS } from "./index.js"; + +void describe("server timeout hardening", () => { + void it("applies safe defaults to request, header, keep-alive, and socket timeouts", () => { + const server = createServer(); + + configureServerTimeouts(server, {}); + + assert.strictEqual(server.requestTimeout, DEFAULT_SERVER_TIMEOUTS.requestTimeoutMs); + assert.strictEqual(server.headersTimeout, DEFAULT_SERVER_TIMEOUTS.headersTimeoutMs); + assert.strictEqual( + server.keepAliveTimeout, + DEFAULT_SERVER_TIMEOUTS.keepAliveTimeoutMs + ); + assert.strictEqual(server.timeout, DEFAULT_SERVER_TIMEOUTS.requestTimeoutMs); + }); + + void it("uses positive integer environment overrides", () => { + const server = createServer(); + + configureServerTimeouts(server, { + REQUEST_TIMEOUT_MS: "45000", + HEADERS_TIMEOUT_MS: "12000", + KEEPALIVE_TIMEOUT_MS: "7000", + }); + + assert.strictEqual(server.requestTimeout, 45_000); + assert.strictEqual(server.headersTimeout, 12_000); + assert.strictEqual(server.keepAliveTimeout, 7_000); + assert.strictEqual(server.timeout, 45_000); + }); + + void it("ignores missing, non-integer, zero, and negative overrides", () => { + const server = createServer(); + + configureServerTimeouts(server, { + REQUEST_TIMEOUT_MS: "0", + HEADERS_TIMEOUT_MS: "12.5", + KEEPALIVE_TIMEOUT_MS: "-1", + }); + + assert.strictEqual(server.requestTimeout, DEFAULT_SERVER_TIMEOUTS.requestTimeoutMs); + assert.strictEqual(server.headersTimeout, DEFAULT_SERVER_TIMEOUTS.headersTimeoutMs); + assert.strictEqual( + server.keepAliveTimeout, + DEFAULT_SERVER_TIMEOUTS.keepAliveTimeoutMs + ); + }); + + void it("keeps headersTimeout greater than or equal to keepAliveTimeout", () => { + const server = createServer(); + + configureServerTimeouts(server, { + HEADERS_TIMEOUT_MS: "4000", + KEEPALIVE_TIMEOUT_MS: "9000", + }); + + assert.strictEqual(server.keepAliveTimeout, 9_000); + assert.strictEqual(server.headersTimeout, 9_000); + }); +});