You're posting to customer URLs with sensitive data. Sign requests, prevent SSRF, manage secrets, audit logs. By the end, customers can verify they're receiving legitimate webhooks.
← Back to Module 09 overviewX-Webhook-Signature: sha256=... header.Update src/worker/deliveryWorker.ts:
import crypto from 'crypto';
function signPayload(payload: any, secret: string): string {
const body = JSON.stringify(payload);
const timestamp = Date.now() / 1000; // Unix timestamp
const toSign = `${body}.${timestamp}`;
return crypto
.createHmac('sha256', secret)
.update(toSign)
.digest('hex');
}
// In delivery worker:
const signature = signPayload(payload, webhook.secret);
await axios.post(webhook_url, payload, {
headers: {
'Content-Type': 'application/json',
'X-Webhook-Signature': `sha256=${signature}`,
'X-Webhook-Timestamp': String(Date.now() / 1000),
'X-Delivery-ID': delivery_id
}
});
Create src/security/ssrf.ts:
import { isIP } from 'net';
const PRIVATE_RANGES = [
'127.0.0.1',
'::1',
'0.0.0.0',
'169.254.0.0/16', // Link-local
'10.0.0.0/8', // Private
'172.16.0.0/12', // Private
'192.168.0.0/16' // Private
];
const METADATA_HOSTS = [
'metadata.google.internal',
'169.254.169.254',
'http://169.254.169.254',
'http://metadata.google.internal'
];
export function isSafeURL(url: string): boolean {
try {
const parsed = new URL(url);
// Block dangerous schemes
if (['file:', 'data:', 'javascript:', 'vbscript:'].includes(parsed.protocol)) {
return false;
}
// Block metadata endpoints
if (METADATA_HOSTS.some(host => url.includes(host))) {
return false;
}
// Resolve hostname and check IP
const hostname = parsed.hostname;
if (isIP(hostname)) {
// Check if it's in a private range
if (PRIVATE_RANGES.some(range => {
if (range.includes('/')) {
// CIDR check would go here
return false;
}
return hostname === range;
})) {
return false;
}
}
return true;
} catch (err) {
return false;
}
}
export default isSafeURL;
Use in webhook registration:
import isSafeURL from '../security/ssrf';
if (!isSafeURL(req.body.url)) {
return res.status(400).json({ error: 'Webhook URL is not allowed (private IP or metadata endpoint)' });
}
Create .env.example:
DATABASE_URL=postgresql://user:password@localhost:5432/webhook_service REDIS_URL=redis://localhost:6379 JWT_SECRET=your-secret-key-here LOG_LEVEL=info
Use in code:
const dbUrl = process.env.DATABASE_URL || 'postgres://...';
const redisUrl = process.env.REDIS_URL || 'redis://...';
if (!process.env.JWT_SECRET) {
throw new Error('JWT_SECRET environment variable is required');
}
Never commit .env (add to .gitignore):
.env .env.local secrets.json
.env is in .gitignore. .env.example exists with dummy values.Create src/db/audit.ts:
import pool from './pool';
import logger from '../logger';
export async function auditLog(action: string, details: any, userId?: string) {
try {
await pool.query(`
INSERT INTO audit_logs (action, details, user_id, timestamp)
VALUES ($1, $2, $3, NOW())
`, [action, JSON.stringify(details), userId]);
logger.info({ action, details, userId, type: 'audit' });
} catch (err) {
logger.error({ err, action, details }, 'Failed to log audit event');
}
}
// Usage:
await auditLog('webhook_created', { webhook_id, tenant_id, url });
await auditLog('dlq_retry', { delivery_id, attempts });
await auditLog('quota_exceeded', { tenant_id, limit, current });
Create audit_logs table in migrations:
CREATE TABLE IF NOT EXISTS audit_logs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), action TEXT NOT NULL, details JSONB, user_id UUID, timestamp TIMESTAMP DEFAULT NOW() ); CREATE INDEX idx_audit_action ON audit_logs(action); CREATE INDEX idx_audit_timestamp ON audit_logs(timestamp);
Create docs/threat-model.md:
# Threat Model — Webhook Delivery Service ## Assets - Customer URLs (endpoints we POST to) - Customer secrets (used for HMAC signing) - Event payloads (may contain sensitive data) - Delivery state (audit trail) ## Threats & Mitigations ### 1. Customer sees other customer's events **Threat:** Query injection, authorization bypass **Mitigation:** Parameterized queries, tenant isolation, audit logs ### 2. SSRF attack via webhook URL **Threat:** Attacker registers webhook to internal IP, accesses metadata endpoints **Mitigation:** Block private IPs, metadata endpoints, file scheme ### 3. Replay attacks on webhooks **Threat:** Attacker replays a webhook POST, duplicating side effects **Mitigation:** Timestamp in signature, idempotency keys, customer deduplication ### 4. Secrets in git/logs **Threat:** API keys, database passwords leaked **Mitigation:** Environment variables, never log secrets, audit who accesses them ### 5. DoS via webhook registration **Threat:** Attacker registers 1M webhooks, crashes system **Mitigation:** Rate limiting, quotas, per-tenant limits
git add -A git commit -m "security: add HMAC signing, SSRF prevention, and audit logging - HMAC-SHA256 signing for all webhook POSTs - SSRF prevention: block private IPs and metadata endpoints - Move secrets to environment variables (not code) - Audit logging: track webhook creation, DLQ retries, quota changes - Threat model identifying assets, risks, and mitigations" git push origin main
git log --oneline shows your commit.Your system is now secure and well-documented. The final module is the capstone: deploy, write runbooks, and document everything. Head to Module 10.