From ac5b9e2aa3622eb648ec9dfcd69c4a14c6ca4df6 Mon Sep 17 00:00:00 2001 From: junman140 Date: Wed, 26 Aug 2026 14:18:18 +0100 Subject: [PATCH] Security hardening: CSP for docs, X-Request-ID validation, log redaction, webhook SSRF guard - #129: relax helmet's default CSP for the /api-docs Swagger UI route only - #133: validate client X-Request-ID (max 128 chars, [A-Za-z0-9_-]); fall back to a server-generated ID otherwise; document trust boundary - #94: extend log redaction to arrays, string-embedded secret query params, and Authorization keys; preserve whsec_**** partial reveal; cover non-sensitive fields - #96: add delivery-time SSRF guard (DNS resolve + IP pinning, no redirects) blocking private/internal targets; reduce test-endpoint error detail to a generic category with raw detail kept server-side; add per-IP rate-limit aggregation test --- README.md | 34 ++++++- src/errors/AppError.js | 1 + src/index.js | 2 + src/logger.js | 32 +----- src/middleware/csp.js | 29 ++++++ src/middleware/requestId.js | 21 +++- src/routes/webhooks.js | 19 +++- src/services/logRedaction.js | 126 +++++++++++++++++++++++ src/services/ssrfGuard.js | 147 +++++++++++++++++++++++++++ src/services/webhookDispatcher.js | 19 +++- test/api-docs.test.js | 29 ++++++ test/logger.test.js | 71 +++++++++++++ test/requestId.test.js | 49 +++++++++ test/webhookDispatcher.test.js | 7 ++ test/webhooks.routes.test.js | 9 ++ test/webhooks.ssrf.test.js | 159 ++++++++++++++++++++++++++++++ 16 files changed, 717 insertions(+), 37 deletions(-) create mode 100644 src/middleware/csp.js create mode 100644 src/services/logRedaction.js create mode 100644 src/services/ssrfGuard.js create mode 100644 test/logger.test.js create mode 100644 test/webhooks.ssrf.test.js diff --git a/README.md b/README.md index 51173fa..f1ad793 100644 --- a/README.md +++ b/README.md @@ -531,6 +531,21 @@ POST /api/v1/webhooks/:id/test ``` Sends a synthetic `pool.assets_locked` payload to the registered URL and returns the resulting delivery summary. Limited to 5 calls/min/IP by default. +> **SSRF protection.** Webhook targets are validated against private/internal +> network ranges (RFC-1918, loopback, link-local, IPv6 ULA/link-local, CGNAT, +> etc.) both when registered **and** again at delivery time — and the outbound +> connection is pinned to the validated public IP, with redirects disabled — so +> a `test` call (or any real dispatch) cannot be used as an internal-network +> reconnaissance oracle. A blocked target is refused up front with a `422 +> WEBHOOK_TARGET_BLOCKED` error and is never delivered. +> +> **Reduced error detail.** The `last_error` field returned by the test endpoint +> is a coarse category (`unreachable` | `error_response` | `delivery_failed`), +> not the raw low-level network error string (e.g. `ECONNREFUSED`). The raw +> detail is still written to server-side logs for operators; only the public +> response is sanitized, to avoid turning the test endpoint into an information +> leak about internal reachability. See issue #96. + #### Inspect deliveries (admin dashboard feed) ``` GET /api/v1/webhooks/:id/deliveries?limit=50 @@ -615,11 +630,26 @@ The API returns appropriate HTTP status codes: ```json { "error": "Error type", - "message": "Detailed error message" + "message": "Detailed error message", + "request_id": "req_…" } - ``` +### `X-Request-ID` correlation header + +Every response carries an `X-Request-ID` header, and every JSON response body +(and every error body) includes a `request_id` field, so you can correlate a +client request with the server's logs. + +`X-Request-ID` is an **optional client hint**, not an authoritative or +guaranteed-unique identifier. A client may supply its own value via the +`X-Request-ID` request header to tie its own logs to SmartDrop's; if the value +is missing, malformed (contains characters outside `[A-Za-z0-9_-]`), or longer +than 128 characters, the server **ignores it and generates a fresh ID instead**. +Treat the returned `request_id` purely as a correlation aid — multiple unrelated +requests can share a client-chosen value, so it must not be used as a security +or uniqueness anchor. See issue #133. + --- ## Development diff --git a/src/errors/AppError.js b/src/errors/AppError.js index 35c636b..de51cfb 100644 --- a/src/errors/AppError.js +++ b/src/errors/AppError.js @@ -5,6 +5,7 @@ const ERROR_CODES = Object.freeze({ UNAUTHORIZED: { statusCode: 401 }, NOT_FOUND: { statusCode: 404 }, PAYLOAD_TOO_LARGE: { statusCode: 413 }, + WEBHOOK_TARGET_BLOCKED: { statusCode: 422 }, RATE_LIMITED: { statusCode: 429 }, UPSTREAM_ERROR: { statusCode: 502 }, INTERNAL_ERROR: { statusCode: 500 }, diff --git a/src/index.js b/src/index.js index 52ea147..f3c6310 100644 --- a/src/index.js +++ b/src/index.js @@ -14,6 +14,7 @@ const { makeLeaderAwareJob } = require('./jobs/leaderAwareJob'); const { warmCache } = require('./startup/cacheWarm'); const buildCorsMiddleware = require('./middleware/cors'); const buildRateLimit = require('./middleware/rateLimit'); +const { docsCspMiddleware } = require('./middleware/csp'); const { requestIdMiddleware } = require('./middleware/requestId'); const { requireApiKey } = require('./middleware/auth'); const { errorHandler, notFoundHandler } = require('./middleware/errorHandler'); @@ -166,6 +167,7 @@ app.use('/api/v1', indexerRouter); app.use('/api/v1', webhooksRouter); app.use('/api/v1', airdropsRouter); app.use('/api-docs', globalApiLimit); +app.use('/api-docs', docsCspMiddleware); app.use('/api-docs', apiDocsRouter); app.use(notFoundHandler); diff --git a/src/logger.js b/src/logger.js index 23238e5..79a3429 100644 --- a/src/logger.js +++ b/src/logger.js @@ -2,6 +2,7 @@ const winston = require('winston'); const DailyRotateFile = require('winston-daily-rotate-file'); const { name: serviceName, version } = require('../package.json'); const { requestContext } = require('./middleware/requestId'); +const { redactFormat } = require('./services/logRedaction'); // ==================== LOG LEVEL ==================== const getLogLevel = () => { @@ -14,37 +15,6 @@ const getLogLevel = () => { return 'debug'; }; -// ==================== REDACTION ==================== -const redactFormat = winston.format((info) => { - const sensitiveKeys = ['apikey', 'privatekey', 'secret', 'token']; - - const redactValue = (value, key) => { - if (typeof value !== 'string') return '[REDACTED]'; - if (key.toLowerCase().includes('secret') && value.startsWith('whsec_')) { - return 'whsec_****'; - } - return '[REDACTED]'; - }; - - const redact = (obj) => { - if (!obj || typeof obj !== 'object') return obj; - - for (const key of Object.keys(obj)) { - const lowerKey = key.toLowerCase(); - const isSensitive = sensitiveKeys.some(k => lowerKey.includes(k)); - - if (isSensitive) { - obj[key] = redactValue(obj[key], key); - } else if (typeof obj[key] === 'object') { - redact(obj[key]); - } - } - return obj; - }; - - return redact(info); -}); - // ==================== FORMAT DECISION ==================== const env = process.env.NODE_ENV || 'development'; const logFormat = process.env.LOG_FORMAT || (env === 'production' ? 'json' : 'pretty'); diff --git a/src/middleware/csp.js b/src/middleware/csp.js new file mode 100644 index 0000000..e278016 --- /dev/null +++ b/src/middleware/csp.js @@ -0,0 +1,29 @@ +'use strict'; + +/** + * Content-Security-Policy override for the Swagger UI docs route (/api-docs). + * + * helmet() applies a strict default CSP to every response (good for the API), + * but swagger-ui-express renders by injecting inline