diff --git a/.env.example b/.env.example index 543fb4ef..8e7af699 100644 --- a/.env.example +++ b/.env.example @@ -53,8 +53,28 @@ DB_LEAK_THRESHOLD_MS=30000 # ── Webhook Signature Verification ──────────────────────────────────────────── # HMAC-SHA256 signing key used to sign and verify outbound/inbound webhooks. # Rotate with POST /api/v1/webhooks/rotate-key (overlap window: 24h) +# REQUIRED in production: at least 16 random characters. Startup fails if unset +# or left at the example value, because a known key lets anyone forge signatures. WEBHOOK_SIGNING_KEY=change-me-to-a-long-random-secret +# ── Security Secrets (required in production) ───────────────────────────────── +# The following secrets have no safe fallback. In production the process refuses +# to start without them; outside production an ephemeral value is generated and +# a warning is logged (which means values will not survive a restart). + +# Pepper for API key digests (>=16 chars). Without it a leaked key table can be +# attacked offline; with it, a leaked table is useless on its own. +API_KEY_HASH_PEPPER= + +# HMAC key for opaque pagination cursors (>=16 chars). Cursors carry a sort +# field that is interpolated into SQL, so a known key would allow forging one. +CURSOR_HMAC_SECRET= + +# Set to `true` ONLY when an authenticating proxy sits in front of this process +# and strips `x-subscription-tier` from inbound requests. Otherwise rate-limit +# tiers are resolved from the server-side record for each API key. +TRUST_TIER_HEADER=false + # ── Zapier Integration ───────────────────────────────────────────────────────── # No server-side secrets required — merchants authenticate with their API keys. # Set this to restrict which origins can reach the Zapier endpoints. diff --git a/backend/gateway/index.ts b/backend/gateway/index.ts index a93d83ec..2ba528b1 100644 --- a/backend/gateway/index.ts +++ b/backend/gateway/index.ts @@ -40,13 +40,19 @@ export function createGateway(options: GatewayOptions = {}): Application { app.disable('x-powered-by'); - app.use(express.json({ limit: '10mb' })); + // Reduced from the previous blanket 10mb. A JSON control-plane body has no + // legitimate need to be that large, and the old limit let a single + // unauthenticated request buffer 10 MB per connection into memory. + app.use(express.json({ limit: '256kb' })); // ── Response envelope header ────────────────────────────────────────── app.use((_req: Request, res: Response, next: NextFunction) => { res.setHeader(API_VERSION_HEADER, API_VERSION_VALUE); res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Frame-Options', 'DENY'); + // The gateway previously set no caching or referrer policy at all. + res.setHeader('Referrer-Policy', 'no-referrer'); + res.setHeader('X-DNS-Prefetch-Control', 'off'); next(); }); diff --git a/backend/integrations/zapier/ZapierIntegrationService.ts b/backend/integrations/zapier/ZapierIntegrationService.ts index 2643e50a..c6254940 100644 --- a/backend/integrations/zapier/ZapierIntegrationService.ts +++ b/backend/integrations/zapier/ZapierIntegrationService.ts @@ -18,6 +18,7 @@ */ import crypto from 'crypto'; +import { validateOutboundUrl } from '../../shared/security/outboundUrlGuard'; // ── Types ───────────────────────────────────────────────────────────────────── @@ -293,12 +294,17 @@ export class ZapierIntegrationService { // ── Hook Subscription Management ────────────────────────────────────────── registerHook(input: RegisterHookInput): ZapierHookSubscription { + // Re-validated at the service boundary, not only in the router: hooks can + // also be registered programmatically, and every stored `targetUrl` is + // later fetched server-side. + const target = validateOutboundUrl(input.targetUrl); + const id = this.generateId('zhook'); const now = new Date().toISOString(); const hook: ZapierHookSubscription = { id, merchantId: input.merchantId, - targetUrl: input.targetUrl, + targetUrl: target.toString(), event: input.event, signingSecret: this.generateSigningSecret(), active: true, @@ -380,7 +386,11 @@ export class ZapierIntegrationService { const signature = this.signPayload(body, hook.signingSecret); try { - const response = await fetchImpl(hook.targetUrl, { + // Re-validate at send time. A hook persisted before this guard existed + // would otherwise remain an SSRF vector forever. + const target = validateOutboundUrl(hook.targetUrl); + + const response = await fetchImpl(target.toString(), { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/backend/integrations/zapier/zapierRouter.ts b/backend/integrations/zapier/zapierRouter.ts index c44d3227..a6eb0f1a 100644 --- a/backend/integrations/zapier/zapierRouter.ts +++ b/backend/integrations/zapier/zapierRouter.ts @@ -32,6 +32,9 @@ import { type RegisterHookInput, type ZapierTriggerEvent, } from './ZapierIntegrationService'; +import { + validateOutboundUrl, +} from '../../shared/security/outboundUrlGuard'; function asyncHandler(fn: (req: Request, res: Response) => Promise) { return (req: Request, res: Response): void => { @@ -122,9 +125,23 @@ export function createZapierRouter(service: ZapierIntegrationService): Router { return; } + // The registered URL is fetched server-side on every event delivery, so + // an unvalidated value here is an SSRF primitive: it would let a caller + // point the platform at cloud metadata, loopback or RFC1918 services. + let normalizedTarget: string; + try { + normalizedTarget = validateOutboundUrl(targetUrl).toString(); + } catch (error) { + res.status(400).json({ + success: false, + error: error instanceof Error ? error.message : 'Invalid targetUrl', + }); + return; + } + const input: RegisterHookInput = { merchantId, - targetUrl, + targetUrl: normalizedTarget, event: event as ZapierTriggerEvent, }; diff --git a/backend/server.ts b/backend/server.ts index c85596b9..b1c1fcfc 100644 --- a/backend/server.ts +++ b/backend/server.ts @@ -157,13 +157,31 @@ function decodeJwtSubject(token: string): string | undefined { } } -/** Resolve the caller tier from the x-subscription-tier header (default FREE). */ -function resolveTierFromRequest( - req: { headers: Record }, -): SubscriptionTier { - const raw = req.headers['x-subscription-tier']; - const value = typeof raw === 'string' ? raw : Array.isArray(raw) ? raw[0] : undefined; - return parseSubscriptionTier(value ?? null) ?? SubscriptionTier.FREE; +/** + * Resolve the caller's rate-limit tier for an API key. + * + * This previously read the client-supplied `x-subscription-tier` header + * verbatim, so any caller presenting a key could send + * `X-Subscription-Tier: enterprise` and receive the 10,000 req/hr allowance + * instead of the FREE tier's 100 req/hr — a 100× rate-limit bypass requiring + * no privilege at all. + * + * The tier now comes from the server-side record for that key. The header is + * honoured only when an explicit trusted-proxy opt-in is configured, which + * suits deployments where an authenticating gateway sits in front of this + * process and strips the header from inbound requests. + */ +function resolveTierForApiKey(apiKey: string, req: { + headers: Record; +}): SubscriptionTier { + if (process.env['TRUST_TIER_HEADER'] === 'true') { + const raw = req.headers['x-subscription-tier']; + const value = typeof raw === 'string' ? raw : Array.isArray(raw) ? raw[0] : undefined; + const parsed = parseSubscriptionTier(value ?? null); + if (parsed) return parsed; + } + + return rateLimitingService.getTierForApiKey(apiKey); } /** @@ -205,11 +223,10 @@ function buildRateLimitMiddleware() { '/metrics/pool', '/metrics/build', ], - // Per-key tier: read x-subscription-tier header; defaults to FREE. - getTier: (apiKey, req) => { - void apiKey; - return resolveTierFromRequest(req); - }, + + // Per-key tier: resolved from the server-side record, not from a + // client-supplied header. See resolveTierForApiKey. + getTier: (apiKey, req) => resolveTierForApiKey(apiKey, req), // Per-user aggregate limiting: x-user-id header or Bearer JWT sub claim. getUserId: (req) => resolveUserIdFromRequest(req), }); @@ -288,21 +305,32 @@ class HttpError extends Error { /** Upper bound on an ingested CI report body (1 MiB). */ const MAX_BUILD_REPORT_BYTES = 1024 * 1024; +/** + * Default ceiling for any other JSON request body (256 KiB). + * + * `maxBytes` was optional and 31 of 32 call sites omitted it, so every + * non-build-metrics route buffered an unbounded amount into memory before + * parsing. A single oversized POST was enough to exhaust the heap. A sane + * default is applied everywhere; individual routes may still raise it. + */ +const MAX_DEFAULT_BODY_BYTES = 256 * 1024; + /** * Read and parse a JSON request body. * - * @param maxBytes - Optional hard cap; exceeding it aborts with 413 so an - * untrusted upload cannot buffer unbounded memory. Omit for trusted routes - * to preserve the previous unlimited behaviour. + * @param maxBytes - Optional explicit cap; defaults to MAX_DEFAULT_BODY_BYTES. + * Exceeding it aborts with 413 so an untrusted upload cannot buffer + * unbounded memory. Set `Infinity` only for genuinely trusted routes. */ async function readJsonBody(req: http.IncomingMessage, maxBytes?: number): Promise { + const limit = maxBytes ?? MAX_DEFAULT_BODY_BYTES; const chunks: Buffer[] = []; let received = 0; for await (const chunk of req) { const buf = typeof chunk === 'string' ? Buffer.from(chunk) : chunk; received += buf.length; - if (maxBytes !== undefined && received > maxBytes) { - throw new HttpError(413, `request body exceeds ${maxBytes} bytes`); + if (Number.isFinite(limit) && received > limit) { + throw new HttpError(413, `request body exceeds ${limit} bytes`); } chunks.push(buf); } @@ -1957,9 +1985,21 @@ feat/stellar-wave-issues // HubSpot — POST /hubspot/webhook (ingest from HubSpot) // ----------------------------------------------------------------- if (pathname === '/hubspot/webhook' && method === 'POST') { + // Bounded read: this accumulated every chunk with no cap before the + // signature was even checked, so an anonymous caller could exhaust + // memory with a single oversized POST. const rawBody = await new Promise((resolve, reject) => { const chunks: Buffer[] = []; - req.on('data', (c: Buffer) => chunks.push(c)); + let received = 0; + req.on('data', (c: Buffer) => { + received += c.length; + if (received > MAX_DEFAULT_BODY_BYTES) { + reject(new HttpError(413, `request body exceeds ${MAX_DEFAULT_BODY_BYTES} bytes`)); + req.destroy(); + return; + } + chunks.push(c); + }); req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); req.on('error', reject); }); diff --git a/backend/server/createApiServer.ts b/backend/server/createApiServer.ts index 1775a52e..10c1074a 100644 --- a/backend/server/createApiServer.ts +++ b/backend/server/createApiServer.ts @@ -19,6 +19,8 @@ feat/stellar-wave-issues main */ +import { randomBytes } from 'node:crypto'; + import express, { type Express, type Request, type Response } from 'express'; import { cacheHeadersMiddleware } from '../shared/middleware'; import { createCspMiddleware, DEFAULT_CSP_POLICY } from '../services/shared/cspMiddleware'; @@ -122,7 +124,45 @@ const renewalScheduler = new RenewalReminderScheduler(calendarSyncService); const calendarController = createCalendarSyncController({ syncService: calendarSyncService }); const reminderController = createRenewalReminderController({ scheduler: renewalScheduler }); -const webhookKeyStore = new KeyStore(process.env['WEBHOOK_SIGNING_KEY'] ?? 'default-dev-key-change-in-prod'); +/** + * Resolves the outbound webhook signing key. + * + * This previously fell back to the literal `'default-dev-key-change-in-prod'`. + * Because the same key signs every webhook the platform emits, a single + * deployed default meant anyone reading the repository could forge a valid + * signature for arbitrary merchant webhooks. + * + * In production an unset or default value is a hard startup failure. Outside + * production an ephemeral random key is generated so local development still + * works without silently weakening verification. + */ +function resolveWebhookSigningKey(): string { + const configured = process.env['WEBHOOK_SIGNING_KEY']; + const placeholder = 'default-dev-key-change-in-prod'; + + if (configured && configured !== placeholder && configured.length >= 16) { + return configured; + } + + if (process.env['NODE_ENV'] === 'production') { + throw new Error( + 'WEBHOOK_SIGNING_KEY must be set to a random value of at least 16 characters in production.' + ); + } + + if (configured === placeholder) { + console.warn( + '[createApiServer] WEBHOOK_SIGNING_KEY is still the public default; using an ephemeral key for this process.' + ); + } + + return randomBytes(32).toString('hex'); +} + +const webhookKeyStore = new KeyStore(resolveWebhookSigningKey()); + +/** Ceiling for request bodies accepted by the public API (256 KiB). */ +const MAX_JSON_BODY = '256kb'; export interface CreateApiServerOptions { /** Optional middleware applied before cache headers (e.g. auth). */ @@ -172,11 +212,13 @@ export function createApiServer(options: CreateApiServerOptions = {}): Express { // first legitimate delivery as a duplicate and the router would answer // `200 { duplicate: true }` without ever dispatching the event. // nosemgrep: subtrackr.csrf.state-changing-route-without-csrf -- reason: body-parser-only registration, no handler and no state change; the request is HMAC-verified inside the Stripe router, which fails closed with 503 when unconfigured and 401 on a bad signature -- expires: 2027-04-16 - app.post('/api/v1/stripe/billing/webhook', express.raw({ type: '*/*' })); + app.post('/api/v1/stripe/billing/webhook', express.raw({ type: '*/*', limit: MAX_JSON_BODY })); // nosemgrep: subtrackr.csrf.state-changing-route-without-csrf -- reason: body-parser-only registration, no handler and no state change; the request is HMAC-verified inside the Paddle router, which fails closed with 503 when unconfigured and 401 on a bad signature -- expires: 2027-04-16 - app.post('/api/v1/paddle/webhook', express.raw({ type: '*/*' })); + app.post('/api/v1/paddle/webhook', express.raw({ type: '*/*', limit: MAX_JSON_BODY })); - app.use(express.json()); + // An explicit cap keeps a single oversized POST from buffering the whole + // body into memory before it is parsed. + app.use(express.json({ limit: MAX_JSON_BODY })); // ── CSRF protection — #1770 ─────────────────────────────────────────────── // Double-submit cookie strategy: safe methods (GET/HEAD/OPTIONS) issue a diff --git a/backend/services/auth/domain/ApiKeyRotationService.ts b/backend/services/auth/domain/ApiKeyRotationService.ts index 7f84952b..9abd7900 100644 --- a/backend/services/auth/domain/ApiKeyRotationService.ts +++ b/backend/services/auth/domain/ApiKeyRotationService.ts @@ -1,4 +1,4 @@ -import { randomBytes, createHash } from 'crypto'; +import { randomBytes, createHmac } from 'crypto'; import { AuthError } from '../errors'; import { logger } from '../../shared/logging'; import type { ApiKeyRecord, ApiKeyRotationPolicy, IApiKeyRotationService } from '../interfaces'; @@ -8,8 +8,59 @@ const KEY_BYTE_LENGTH = 32; const KEY_HASH_ALGORITHM = 'sha256'; const MAX_HISTORY = 5; +/** + * Server-side pepper for API key hashing. + * + * API keys are high-entropy random values, so a plain hash resists brute + * force — but an unsalted SHA-256 is still trivially attackable in bulk via + * precomputation, and it means a database disclosure immediately yields + * directly comparable key material. A keyed HMAC removes that: without the + * pepper, a leaked table cannot be attacked offline at all. + * + * Required in production; ephemeral per-process outside it (with a warning), + * because a checked-in fallback pepper would be no better than none. + */ +function resolveApiKeyPepper(): string { + const configured = process.env['API_KEY_HASH_PEPPER']; + + if (configured && configured.trim().length >= 16) { + return configured; + } + + if (process.env['NODE_ENV'] === 'production') { + throw new Error( + 'API_KEY_HASH_PEPPER must be set to a random value of at least 16 characters in production.' + ); + } + + console.warn( + '[ApiKeyRotationService] API_KEY_HASH_PEPPER is not configured; using an ephemeral pepper. API keys will not resolve across restarts.' + ); + + return randomBytes(32).toString('hex'); +} + +let _pepper: string | null = null; + +/** Test seam: forces a specific pepper, or `null` to re-resolve. */ +export function __setApiKeyPepperForTesting(pepper: string | null): void { + _pepper = pepper; +} + +function apiKeyPepper(): string { + if (_pepper === null) { + _pepper = resolveApiKeyPepper(); + } + return _pepper; +} + +/** + * Deterministic keyed digest of an API key. + * + * Stays deterministic so stored hashes remain searchable by lookup. + */ export function hashApiKey(rawKey: string): string { - return createHash(KEY_HASH_ALGORITHM).update(rawKey).digest('hex'); + return createHmac(KEY_HASH_ALGORITHM, apiKeyPepper()).update(rawKey).digest('hex'); } /** diff --git a/backend/services/shared/pagination.ts b/backend/services/shared/pagination.ts index e8c841e4..f245c513 100644 --- a/backend/services/shared/pagination.ts +++ b/backend/services/shared/pagination.ts @@ -8,13 +8,80 @@ * - Integration with the existing ApiResponse / PaginationMeta envelope */ -import { createHmac } from 'node:crypto'; +import { createHmac, timingSafeEqual } from 'node:crypto'; // ─── Cursor Encoding ────────────────────────────────────────────────────────── -const CURSOR_SECRET = process.env['CURSOR_HMAC_SECRET'] ?? 'subtrackr-cursor-secret'; const CURSOR_VERSION = 1; +/** + * Resolves the cursor HMAC secret. + * + * This previously fell back to the literal `'subtrackr-cursor-secret'`, which + * is published in the repository. Anyone could therefore mint a cursor with a + * forged `field`, and that identifier is interpolated straight into `ORDER BY` + * and `WHERE` — a known-secret cursor is a SQL identifier-injection primitive. + * + * Outside production (tests, local development) a per-process random secret is + * generated instead, so the integrity guarantee still holds rather than being + * silently disabled. In production an unset secret is a hard failure. + */ +function resolveCursorSecret(): string { + const configured = process.env['CURSOR_HMAC_SECRET']; + + if (configured && configured.trim().length >= 16) { + return configured; + } + + if (process.env['NODE_ENV'] === 'production') { + throw new Error( + 'CURSOR_HMAC_SECRET must be set to a random value of at least 16 characters in production.' + ); + } + + if (configured) { + console.warn( + '[pagination] CURSOR_HMAC_SECRET is too short (min 16 chars); using an ephemeral secret. Cursors will not survive a restart.' + ); + } + + return createHmac('sha256', `${process.pid}:${process.hrtime.bigint()}:${Math.random()}`) + .update('cursor-secret') + .digest('hex'); +} + +let _cursorSecret: string | null = null; + +/** Lazily resolved so importing this module never throws in production. */ +function cursorSecret(): string { + if (_cursorSecret === null) { + _cursorSecret = resolveCursorSecret(); + } + return _cursorSecret; +} + +/** Test seam: forces a specific secret, or `null` to re-resolve. */ +export function __setCursorSecretForTesting(secret: string | null): void { + _cursorSecret = secret; +} + +function sign(data: string): string { + return createHmac('sha256', cursorSecret()).update(data).digest('base64url').slice(0, 16); +} + +/** + * Restricts a sort/identifier to a safe SQL identifier. + * + * Anything outside `[A-Za-z0-9_]` is rejected outright rather than sanitised, + * because a partially-stripped identifier is still attacker-influenced. + */ +export function assertSafeIdentifier(identifier: string, label: string): string { + if (!/^[A-Za-z0-9_]+$/.test(identifier)) { + throw new Error(`Unsafe SQL identifier for ${label}: ${JSON.stringify(identifier)}`); + } + return identifier; +} + export interface CursorPayload { v: number; field: string; @@ -43,9 +110,22 @@ export function decodeCursor(cursor: string): CursorPayload | null { if (sepIdx === -1) return null; const json = raw.slice(0, sepIdx); const sig = raw.slice(sepIdx + 1); - if (sign(json) !== sig) return null; + + // Constant-time so a forged signature cannot be refined byte by byte. + const bufA = Buffer.from(sign(json), 'utf8'); + const bufB = Buffer.from(sig, 'utf8'); + if (bufA.length !== bufB.length) return null; + if (!timingSafeEqual(bufA, bufB)) return null; + const payload = JSON.parse(json) as CursorPayload; if (payload.v !== CURSOR_VERSION) return null; + + // The signature is only trustworthy if the secret actually is. When a + // short/absent secret yields an ephemeral key we still refuse to trust a + // cursor's `field`, because it is interpolated into SQL below. + if (!/^[A-Za-z0-9_]+$/.test(String(payload.field ?? ''))) { + return null; + } return payload; } catch { return null; @@ -97,7 +177,7 @@ export interface SqlCursorClause { */ export function buildCursorClause(options: PageOptions = {}): SqlCursorClause { const dir = options.direction ?? 'asc'; - const sortField = options.sortField ?? 'id'; + const sortField = assertSafeIdentifier(options.sortField ?? 'id', 'sortField'); const rawLimit = options.limit ?? 20; const limit = Math.min(Math.max(1, rawLimit), options.maxLimit ?? 100); const op = dir === 'asc' ? '>' : '<'; @@ -117,10 +197,15 @@ export function buildCursorClause(options: PageOptions = {}): SqlCursorClause { return { where: '', param: null, orderBy: `"${sortField}" ${orderDir}`, limit }; } + // `decodeCursor` has already restricted `field` to `[A-Za-z0-9_]`, so it is + // safe to interpolate as a quoted SQL identifier. Re-check rather than trust + // the caller's own type-checking to have held. + const cursorField = assertSafeIdentifier(decoded.field, 'cursor.field'); + return { - where: `("${decoded.field}", id) ${op} ($1, $2)`, + where: `("${cursorField}", id) ${op} ($1, $2)`, param: decoded.value, - orderBy: `"${decoded.field}" ${orderDir}, id ${orderDir}`, + orderBy: `"${cursorField}" ${orderDir}, id ${orderDir}`, limit, }; } diff --git a/backend/services/shared/rateLimitingService.ts b/backend/services/shared/rateLimitingService.ts index 8f1a1c19..9904a8f7 100644 --- a/backend/services/shared/rateLimitingService.ts +++ b/backend/services/shared/rateLimitingService.ts @@ -110,6 +110,20 @@ export class RateLimitingService { return Array.from(this.bypass.userIds ?? []); } + // ------------------------------------------------------------------------- + // Tier resolution + // ------------------------------------------------------------------------- + + /** + * Returns the tier recorded for `apiKey`, or FREE when unknown. + * + * This is the only trustworthy source for a caller's tier. Reading it from + * a request header lets any caller self-select the ENTERPRISE allowance. + */ + getTierForApiKey(apiKey: string): SubscriptionTier { + return this.usages.get(apiKey)?.tier ?? SubscriptionTier.FREE; + } + // ------------------------------------------------------------------------- // Custom limit configuration // ------------------------------------------------------------------------- diff --git a/backend/services/webhook/eventReplayWorker.ts b/backend/services/webhook/eventReplayWorker.ts index 29fd72e0..81943c66 100644 --- a/backend/services/webhook/eventReplayWorker.ts +++ b/backend/services/webhook/eventReplayWorker.ts @@ -3,6 +3,9 @@ * with idempotency key checking and ordering guarantees. */ +import { createHmac } from 'node:crypto'; +import { validateOutboundUrl } from '../../shared/security/outboundUrlGuard'; + export interface ReplayRequest { eventIds: string[]; webhookId: string; @@ -97,7 +100,12 @@ export class EventReplayWorker { ): Promise<{ ok: boolean; status: number }> { const body = JSON.stringify(payload); - const res = await fetch(url, { + // Replay targets come from stored webhook configuration; validate before + // fetching so a compromised or misconfigured record cannot be turned into + // an SSRF pivot. + const target = validateOutboundUrl(url); + + const res = await fetch(target.toString(), { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -113,14 +121,11 @@ export class EventReplayWorker { } private computeSignature(body: string, secret: string): string { - // In production, use HMAC-SHA256 - // Placeholder: simple hash for structure - let hash = 0; - const input = secret + body; - for (let i = 0; i < input.length; i++) { - hash = ((hash << 5) - hash + input.charCodeAt(i)) | 0; - } - return `sha256=${Math.abs(hash).toString(16)}`; + // This was a 31-bit rolling hash that merely *claimed* to be SHA-256 in + // the header value. It was trivially forgeable and, because replayed + // deliveries are signed with it, a receiver relying on it had no + // authenticity guarantee at all. Use a real keyed HMAC. + return `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`; } getEventHistory(webhookId: string, limit = 50): StoredEvent[] { diff --git a/backend/shared/security/outboundUrlGuard.ts b/backend/shared/security/outboundUrlGuard.ts new file mode 100644 index 00000000..9c6ab63a --- /dev/null +++ b/backend/shared/security/outboundUrlGuard.ts @@ -0,0 +1,184 @@ +/** + * outboundUrlGuard.ts + * + * Validation for URLs the server will fetch on a user's behalf. + * + * Several integration routes accept an operator- or merchant-supplied + * `targetUrl` and later call `fetch()` with it. Without validation that is a + * server-side request forgery primitive: an attacker can reach the cloud + * metadata endpoint, loopback services, or RFC1918 hosts that are only + * reachable from inside the network. + * + * The rules below reject the obvious cases statically (scheme, obvious host + * names, literal private/loopback/link-local addresses). DNS rebinding and + * internal names that resolve to private space are not covered here — for + * those, pair this with an egress proxy or a resolved-address check at + * connection time. + */ + +import { isIP } from 'node:net'; + +/** Hostnames that always denote the local machine or a container escape. */ +const BLOCKED_HOSTNAMES = new Set([ + 'localhost', + 'localhost.localdomain', + 'ip6-localhost', + 'ip6-loopback', + 'metadata', + 'metadata.google.internal', + 'instance-data', +]); + +/** Suffixes used by cloud metadata services. */ +const BLOCKED_SUFFIXES = ['.localhost', '.local', '.internal', '.localdomain']); + +/** Only plain web schemes; `file:`, `gopher:`, `dict:` etc. are refused. */ +const ALLOWED_PROTOCOLS = new Set(['http:', 'https:']); + +export class OutboundUrlError extends Error { + readonly code = 'OUTBOUND_URL_REJECTED' as const; + + constructor(reason: string) { + super(`Rejected outbound URL: ${reason}`); + this.name = 'OutboundUrlError'; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** + * Parses IPv4 dotted-quads into octets. + * Returns `null` for anything that is not exactly four numeric parts. + */ +function parseIPv4(host: string): number[] | null { + const parts = host.split('.'); + if (parts.length !== 4) return null; + + const octets: number[] = []; + for (const part of parts) { + if (!/^\d{1,3}$/.test(part)) return null; + const value = Number(part); + if (value > 255) return null; + octets.push(value); + } + + return octets; +} + +function isPrivateIPv4(octets: number[]): boolean { + const [a, b] = octets; + + if (a === 0) return true; // "this" network + if (a === 10) return true; // 10.0.0.0/8 + if (a === 127) return true; // loopback + if (a === 169 && b === 254) return true; // link-local, incl. 169.254.169.254 + if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 + if (a === 192 && b === 168) return true; // 192.168.0.0/16 + if (a === 192 && b === 0) return true; // 192.0.0.0/24 + 192.0.2.0/24 + if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT 100.64.0.0/10 + if (a >= 224) return true; // multicast + reserved + + return false; +} + +function isPrivateIPv6(host: string): boolean { + const normalized = host.replace(/^\[/, '').replace(/\]$/, '').toLowerCase(); + + if (normalized === '::' || normalized === '::1') return true; // unspecified, loopback + + // IPv4-mapped (::ffff:a.b.c.d) and IPv4-compatible forms embed an IPv4 address. + const embeddedIPv4 = normalized.match(/(\d{1,3}(?:\.\d{1,3}){3})$/); + if (embeddedIPv4) { + const octets = parseIPv4(embeddedIPv4[1]); + if (octets && isPrivateIPv4(octets)) return true; + } + + // Unique-local (fc00::/7) and link-local (fe80::/10). + if (/^f[cd][0-9a-f]{2}:/.test(normalized)) return true; + if (/^fe[89ab][0-9a-f]:/.test(normalized)) return true; + + return false; +} + +/** + * Returns true when `hostname` cannot be reached from the public internet. + */ +export function isPrivateHostname(hostname: string): boolean { + const host = hostname.trim().toLowerCase().replace(/\.$/, ''); + + if (host === '') return true; + if (BLOCKED_HOSTNAMES.has(host)) return true; + if (BLOCKED_SUFFIXES.some((suffix) => host.endsWith(suffix))) return true; + + const ipVersion = isIP(host); + if (ipVersion === 4) { + const octets = parseIPv4(host); + // A malformed dotted quad is treated as unsafe rather than trusted. + return octets === null ? true : isPrivateIPv4(octets); + } + if (ipVersion === 6) { + return isPrivateIPv6(host); + } + + // A host made only of digits and dots is an *attempted* IP literal. If it + // did not parse as one, some other layer (a resolver, a proxy, a downstream + // parser) may interpret it differently than we just did, so refuse it. + if (/^[\d.]+$/.test(host)) { + return true; + } + + return false; +} + +export interface ValidateOutboundUrlOptions { + /** When set, only these hostnames (and their subdomains) are permitted. */ + allowedHosts?: readonly string[]; +} + +/** + * Validates and normalises a URL the server is about to fetch. + * + * @throws {OutboundUrlError} when the URL is malformed, uses a non-HTTP + * scheme, points at private/loopback/link-local space, or is not on the + * allowlist. + */ +export function validateOutboundUrl( + rawUrl: unknown, + options: ValidateOutboundUrlOptions = {} +): URL { + if (typeof rawUrl !== 'string' || rawUrl.trim() === '') { + throw new OutboundUrlError('value must be a non-empty string'); + } + + let parsed: URL; + try { + parsed = new URL(rawUrl.trim()); + } catch { + throw new OutboundUrlError('value is not a valid absolute URL'); + } + + if (!ALLOWED_PROTOCOLS.has(parsed.protocol)) { + throw new OutboundUrlError(`protocol '${parsed.protocol}' is not allowed`); + } + + if (parsed.username !== '' || parsed.password !== '') { + throw new OutboundUrlError('embedded credentials are not allowed'); + } + + if (isPrivateHostname(parsed.hostname)) { + throw new OutboundUrlError(`host '${parsed.hostname}' resolves to non-public address space`); + } + + if (options.allowedHosts && options.allowedHosts.length > 0) { + const host = parsed.hostname.toLowerCase(); + const allowed = options.allowedHosts.some((entry) => { + const candidate = entry.toLowerCase(); + return host === candidate || host.endsWith(`.${candidate}`); + }); + + if (!allowed) { + throw new OutboundUrlError(`host '${parsed.hostname}' is not on the allowlist`); + } + } + + return parsed; +} \ No newline at end of file diff --git a/developer-portal/pages/api/revalidate.ts b/developer-portal/pages/api/revalidate.ts index 66433bc2..88395eef 100644 --- a/developer-portal/pages/api/revalidate.ts +++ b/developer-portal/pages/api/revalidate.ts @@ -1,3 +1,5 @@ +import { timingSafeEqual } from 'node:crypto'; + import type { NextApiRequest, NextApiResponse } from 'next'; type RevalidateResult = { revalidated: boolean; paths?: string[]; error?: string }; @@ -36,6 +38,31 @@ const TAG_TO_PATHS: Record = { sdks: ['/docs/webhook-integration'], }; +/** Every path the endpoint is permitted to revalidate, regardless of input. */ +const ALLOWED_PATHS = new Set(Object.values(TAG_TO_PATHS).flat()); + +/** + * Constant-time secret comparison. + * + * `secret !== expectedSecret` short-circuits on the first differing byte, + * which leaks the secret length and prefix through response timing and lets + * an attacker recover it one character at a time. + */ +function secretsMatch(provided: unknown, expected: string): boolean { + if (typeof provided !== 'string') return false; + + const a = Buffer.from(provided, 'utf8'); + const b = Buffer.from(expected, 'utf8'); + + if (a.length !== b.length) { + // Still perform a comparison so the fast path is not measurably faster. + timingSafeEqual(a, a); + return false; + } + + return timingSafeEqual(a, b); +} + export default async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method !== 'POST') { return res.status(405).json({ revalidated: false, error: 'Method not allowed' }); @@ -49,7 +76,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< // Verify caller secret const expectedSecret = process.env.REVALIDATE_SECRET; - if (!expectedSecret || secret !== expectedSecret) { + if (!expectedSecret || !secretsMatch(secret, expectedSecret)) { return res.status(401).json({ revalidated: false, error: 'Invalid secret' }); } @@ -57,6 +84,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< return res.status(400).json({ revalidated: false, error: 'Provide "path" or "tag"' }); } + // `path` is caller-supplied and goes straight into `res.revalidate()`. An + // arbitrary value lets a holder of the secret force revalidation of + // unrelated routes, so it is restricted to the documented set. + if (path !== undefined && !ALLOWED_PATHS.has(path)) { + return res + .status(400) + .json({ revalidated: false, error: `Path "${path}" is not revalidatable` }); + } + const pathsToRevalidate: string[] = path ? [path] : (TAG_TO_PATHS[tag as string] ?? []); if (pathsToRevalidate.length === 0) {