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
20 changes: 20 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 7 additions & 1 deletion backend/gateway/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down
14 changes: 12 additions & 2 deletions backend/integrations/zapier/ZapierIntegrationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
*/

import crypto from 'crypto';
import { validateOutboundUrl } from '../../shared/security/outboundUrlGuard';

// ── Types ─────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand Down
19 changes: 18 additions & 1 deletion backend/integrations/zapier/zapierRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>) {
return (req: Request, res: Response): void => {
Expand Down Expand Up @@ -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,
};

Expand Down
76 changes: 58 additions & 18 deletions backend/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | string[] | undefined> },
): 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<string, string | string[] | undefined>;
}): 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);
}

/**
Expand Down Expand Up @@ -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),
});
Expand Down Expand Up @@ -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<unknown> {
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);
}
Expand Down Expand Up @@ -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<string>((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);
});
Expand Down
50 changes: 46 additions & 4 deletions backend/server/createApiServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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). */
Expand Down Expand Up @@ -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
Expand Down
55 changes: 53 additions & 2 deletions backend/services/auth/domain/ApiKeyRotationService.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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');
}

/**
Expand Down
Loading
Loading