Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ The test suite includes:
| `PORT` | 3000 | HTTP server port |
| `API_KEYS` | - | Comma-separated list of valid API keys |
| `API_KEY_PEPPER` | - | Server-side pepper for API key hashing (**Required if API_KEYS is configured**) |
| `WEBHOOK_DNS_TIMEOUT_MS` | 2000 | Webhook DNS lookup resolution timeout in milliseconds (fail-closed) |


### Batch Size Tuning
Expand Down
8 changes: 7 additions & 1 deletion docs/webhooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Required configuration:
- `WEBHOOK_RETRY_RPS`: maximum outbound retry attempts per second per consumer URL. Defaults to `10`. Set lower (e.g. `2`) for consumers known to be slow or fragile.
- `WEBHOOK_CIRCUIT_BREAKER_THRESHOLD`: consecutive retryable failures before the circuit opens. Defaults to `0` (disabled). Set e.g. `10` to enable cross-instance protection.
- `WEBHOOK_CIRCUIT_BREAKER_RESET_MS`: how long the circuit stays open before a single half-open probe. Defaults to `300000` (5 minutes).
- `WEBHOOK_DNS_TIMEOUT_MS`: DNS lookup resolution timeout in milliseconds (fail-closed). Defaults to `2000` (2 seconds).

The service startup path starts the dispatcher after migrations are checked. Shutdown registers the dispatcher as a drainable service, so SIGTERM/SIGINT stops future polls and waits for the in-flight batch before closing database connections.

Expand Down Expand Up @@ -149,15 +150,20 @@ Add to your environment configuration:
```bash
# Optional: Restrict webhook delivery to specific hosts
WEBHOOK_ALLOWED_HOSTS=api.example.com,*.trusted.com

# Optional: Webhook DNS resolution timeout (in milliseconds)
WEBHOOK_DNS_TIMEOUT_MS=2000
```

### Error handling

SSRF validation failures are logged without exposing the full URL for security. The validation fails closed: any ambiguous or unresolvable target is rejected with a `WebhookTargetValidationError`.

If DNS resolution times out or is aborted, it is rejected with a `WebhookTargetValidationError` containing a `DNS_TIMEOUT` code, which fails closed.

### Implementation details

- Validation function: `validateWebhookTarget(url, options)` in `src/webhooks/ssrfGuard.ts`
- Applied in: `WebhookDispatcher.dispatch()` and `dispatchWebhook()` in `src/webhooks/dispatcher.ts`
- Timeout: Uses `DEFAULT_RETRY_POLICY.timeoutMs` (30 seconds)
- DNS resolution: Uses Node.js `dns.promises.lookup()`
- DNS resolution: Uses Node.js `dns.promises.lookup()` wrapped with a custom timeout helper and `AbortController` bounded by `WEBHOOK_DNS_TIMEOUT_MS` (default `2000` ms).
22 changes: 22 additions & 0 deletions src/config/env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,28 @@ describe('Environment Configuration', () => {

expect(() => loadConfig()).toThrow(ConfigError);
});

it('should parse WEBHOOK_DNS_TIMEOUT_MS with default value', () => {
process.env.NODE_ENV = 'development';
const config = loadConfig();

expect(config.webhookDnsTimeoutMs).toBe(2000);
});

it('should allow WEBHOOK_DNS_TIMEOUT_MS override', () => {
process.env.NODE_ENV = 'development';
process.env.WEBHOOK_DNS_TIMEOUT_MS = '5000';
const config = loadConfig();

expect(config.webhookDnsTimeoutMs).toBe(5000);
});

it('should reject invalid WEBHOOK_DNS_TIMEOUT_MS', () => {
process.env.NODE_ENV = 'development';
process.env.WEBHOOK_DNS_TIMEOUT_MS = '-100';

expect(() => loadConfig()).toThrow(ConfigError);
});
});

// -------------------------------------------------------------------------
Expand Down
3 changes: 3 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ export const EnvSchema = z.object({
byteSizeToNumber,
z.number().int('WEBHOOK_MAX_RESPONSE_BYTES must resolve to whole bytes').positive('WEBHOOK_MAX_RESPONSE_BYTES must be positive'),
).default(64 * 1024),
WEBHOOK_DNS_TIMEOUT_MS: integerEnv('WEBHOOK_DNS_TIMEOUT_MS', 1).default(2000),

ENABLE_STREAM_VALIDATION: booleanEnv().default(true),
ENABLE_RATE_LIMIT: booleanEnv().optional(),
Expand Down Expand Up @@ -392,6 +393,7 @@ export interface Config {
webhookRetryRps: number;
webhookAllowedHosts: string[] | undefined;
webhookMaxResponseBytes: number;
webhookDnsTimeoutMs: number;

enableStreamValidation: boolean;
enableRateLimit: boolean;
Expand Down Expand Up @@ -547,6 +549,7 @@ function toConfig(env: ParsedEnv): Config {
? env.WEBHOOK_ALLOWED_HOSTS.split(',').map(h => h.trim()).filter(h => h.length > 0)
: undefined,
webhookMaxResponseBytes: env.WEBHOOK_MAX_RESPONSE_BYTES,
webhookDnsTimeoutMs: env.WEBHOOK_DNS_TIMEOUT_MS,

enableStreamValidation: env.ENABLE_STREAM_VALIDATION,
enableRateLimit: env.ENABLE_RATE_LIMIT ?? !isProduction,
Expand Down
111 changes: 88 additions & 23 deletions src/webhooks/ssrfGuard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,18 @@
*/

import { logger } from '../lib/logger.js';
import { getConfig } from '../config/env.js';

/**
* Error thrown when a webhook target URL fails SSRF validation.
*/
export class WebhookTargetValidationError extends Error {
constructor(message: string) {
readonly code?: string;

constructor(message: string, code?: string) {
super(message);
this.name = 'WebhookTargetValidationError';
this.code = code;
}
}

Expand Down Expand Up @@ -203,40 +207,85 @@ function isBlockedIPv6(ip: string): { blocked: boolean; reason?: string } {
}

/**
* Resolve a hostname to its IP addresses.
* This is used to defeat DNS rebinding attacks.
* Helper to perform a DNS lookup with a timeout and abort support.
*
* @param hostname - The hostname to resolve.
* @param timeoutMs - The timeout in milliseconds.
* @returns A promise that resolves with the lookup result (address).
* @throws {WebhookTargetValidationError} If the lookup times out or fails.
*/
async function resolveHostname(hostname: string): Promise<string[]> {
async function lookupWithTimeout(hostname: string, timeoutMs: number): Promise<string> {
const dns = await import('dns');
const { lookup } = dns.promises;

const controller = new AbortController();
const signal = controller.signal;

let timeoutId: NodeJS.Timeout | undefined;

const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
controller.abort();
reject(
new WebhookTargetValidationError(
`DNS resolution timed out for hostname: ${hostname}`,
'DNS_TIMEOUT'
)
);
}, timeoutMs);
});

try {
// Use Node.js DNS resolution
const dns = await import('dns');
const { lookup } = dns.promises;

const { address, family } = await lookup(hostname, { all: false });

// Convert to array for consistent interface
return [address];
} catch (error) {
// If DNS resolution fails, fail closed
const lookupPromise = lookup(hostname, { all: false, signal });
const result = await Promise.race([lookupPromise, timeoutPromise]);
return result.address;
} catch (error: any) {
if (error instanceof WebhookTargetValidationError) {
throw error;
}

if (error.name === 'AbortError' || error.code === 'ECANCELED') {
throw new WebhookTargetValidationError(
`DNS resolution timed out for hostname: ${hostname}`,
'DNS_TIMEOUT'
);
}

throw new WebhookTargetValidationError(
`DNS resolution failed for hostname: ${hostname}`
`DNS resolution failed for hostname: ${hostname}`,
'DNS_RESOLUTION_FAILED'
);
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}

/**
* Resolve a hostname to its IP addresses.
* This is used to defeat DNS rebinding attacks.
*/
async function resolveHostname(hostname: string, timeoutMs: number): Promise<string[]> {
const address = await lookupWithTimeout(hostname, timeoutMs);
return [address];
}

/**
* Validate that a URL's protocol is allowed.
*/
function validateProtocol(url: URL, requireHttps: boolean): void {
if (requireHttps && url.protocol !== 'https:') {
throw new WebhookTargetValidationError(
`Webhook URL must use HTTPS, got: ${url.protocol}`
`Webhook URL must use HTTPS, got: ${url.protocol}`,
'INVALID_PROTOCOL'
);
}

if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new WebhookTargetValidationError(
`Webhook URL must use HTTP or HTTPS, got: ${url.protocol}`
`Webhook URL must use HTTP or HTTPS, got: ${url.protocol}`,
'INVALID_PROTOCOL'
);
}
}
Expand Down Expand Up @@ -268,7 +317,8 @@ function validateAllowlist(hostname: string, allowlist: string[] | undefined): v
}

throw new WebhookTargetValidationError(
`Webhook hostname not in allowlist: ${hostname}`
`Webhook hostname not in allowlist: ${hostname}`,
'ALLOWLIST_VIOLATION'
);
}

Expand All @@ -283,14 +333,16 @@ function validateIPAddress(ip: string): void {
const result = isBlockedIPv6(ip);
if (result.blocked) {
throw new WebhookTargetValidationError(
`Blocked IPv6 address: ${ip} (${result.reason})`
`Blocked IPv6 address: ${ip} (${result.reason})`,
'BLOCKED_ADDRESS'
);
}
} else {
const result = isBlockedIPv4(ip);
if (result.blocked) {
throw new WebhookTargetValidationError(
`Blocked IPv4 address: ${ip} (${result.reason})`
`Blocked IPv4 address: ${ip} (${result.reason})`,
'BLOCKED_ADDRESS'
);
}
}
Expand Down Expand Up @@ -327,19 +379,30 @@ export async function validateWebhookTarget(
options?: {
requireHttps?: boolean;
allowlist?: string[];
dnsTimeoutMs?: number;
}
): Promise<void> {
const requireHttps = options?.requireHttps ?? true;
const allowlist = options?.allowlist;

let dnsTimeoutMs = options?.dnsTimeoutMs;
if (dnsTimeoutMs === undefined) {
try {
dnsTimeoutMs = getConfig().webhookDnsTimeoutMs;
} catch {
dnsTimeoutMs = 2000; // Sensible default fallback
}
}

try {
// Parse URL
let parsedUrl: URL;
try {
parsedUrl = new URL(url);
} catch (error) {
throw new WebhookTargetValidationError(
`Invalid webhook URL format: ${url}`
`Invalid webhook URL format: ${url}`,
'INVALID_URL'
);
}

Expand All @@ -360,7 +423,7 @@ export async function validateWebhookTarget(
validateIPAddress(hostname);
} else {
// Hostname - resolve and validate all IPs
const resolvedIPs = await resolveHostname(hostname);
const resolvedIPs = await resolveHostname(hostname, dnsTimeoutMs);

for (const ip of resolvedIPs) {
validateIPAddress(ip);
Expand All @@ -373,6 +436,7 @@ export async function validateWebhookTarget(
// Log the validation failure (without the full URL for security)
logger.warn('Webhook target validation failed', undefined, {
reason: error.message,
code: error.code,
});
throw error;
}
Expand All @@ -382,7 +446,8 @@ export async function validateWebhookTarget(
error: error instanceof Error ? error.message : String(error),
});
throw new WebhookTargetValidationError(
'Webhook target validation failed unexpectedly'
'Webhook target validation failed unexpectedly',
'UNKNOWN_ERROR'
);
}
}
Loading