From 74ceba8a62e50a455a2065f1102ec8e825453579 Mon Sep 17 00:00:00 2001 From: Oniel Alejo Feliz <197416079+XonkelX@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:40:00 -0400 Subject: [PATCH 01/12] feat: enforce secure endpoint URL policy --- .../src/endpoint-persistence.d1.test.ts | 29 +++++++ apps/console/src/endpoint-persistence.test.ts | 5 ++ apps/console/src/endpoint-url-policy.test.ts | 41 +++++++++ .../worker/lib/endpoint-persistence.ts | 23 +---- .../console/worker/lib/endpoint-url-policy.ts | 86 +++++++++++++++++++ 5 files changed, 163 insertions(+), 21 deletions(-) create mode 100644 apps/console/src/endpoint-url-policy.test.ts create mode 100644 apps/console/worker/lib/endpoint-url-policy.ts diff --git a/apps/console/src/endpoint-persistence.d1.test.ts b/apps/console/src/endpoint-persistence.d1.test.ts index e820f04..003a827 100644 --- a/apps/console/src/endpoint-persistence.d1.test.ts +++ b/apps/console/src/endpoint-persistence.d1.test.ts @@ -78,4 +78,33 @@ describe('D1 endpoint persistence', () => { expect(auditCount?.count).toBe(2) }) + + it('does not persist endpoints rejected by the URL policy', async () => { + const name = 'Blocked endpoint target' + + await expect( + createEndpoint( + env.DB, + { + name, + url: 'http://127.0.0.1/webhook', + eventTypes: ['order.created'], + }, + { + now: () => '2026-08-05T12:30:00.000Z', + createId: (prefix) => `${prefix}_blocked_d1`, + }, + ), + ).rejects.toBeInstanceOf(TypeError) + + const stored = await env.DB.prepare( + `SELECT COUNT(*) AS count + FROM endpoints + WHERE name = ?`, + ) + .bind(name) + .first<{ count: number }>() + + expect(stored?.count).toBe(0) + }) }) diff --git a/apps/console/src/endpoint-persistence.test.ts b/apps/console/src/endpoint-persistence.test.ts index 9f79582..3abb529 100644 --- a/apps/console/src/endpoint-persistence.test.ts +++ b/apps/console/src/endpoint-persistence.test.ts @@ -126,6 +126,11 @@ describe('endpoint persistence', () => { url: 'ftp://example.test/file', eventTypes: [], }, + { + name: 'Endpoint', + url: 'http://example.test/webhook', + eventTypes: [], + }, { name: 'Endpoint', url: 'https://user:secret@example.test', diff --git a/apps/console/src/endpoint-url-policy.test.ts b/apps/console/src/endpoint-url-policy.test.ts new file mode 100644 index 0000000..a4440ca --- /dev/null +++ b/apps/console/src/endpoint-url-policy.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { MAX_ENDPOINT_URL_LENGTH, normalizeEndpointUrl } from '../worker/lib/endpoint-url-policy.js' + +describe('endpoint URL policy', () => { + it.each([ + 'http://hooks.example.com/events', + 'ftp://hooks.example.com/events', + 'https://127.0.0.1/events', + 'https://2130706433/events', + 'https://[::1]/events', + 'https://localhost/events', + 'https://api.localhost/events', + 'https://receiver.local/events', + 'https://receiver.internal/events', + 'https://service.home.arpa/events', + 'https://intranet/events', + 'https://user:secret@hooks.example.com/events', + 'https://hooks.example.com/events#secret', + 'https://hooks.example.com:8443/events', + ])('rejects blocked target %s', (url) => { + expect(() => normalizeEndpointUrl(url)).toThrow(TypeError) + }) + + it('rejects oversized URLs', () => { + const url = 'https://hooks.example.com/' + 'a'.repeat(MAX_ENDPOINT_URL_LENGTH) + + expect(() => normalizeEndpointUrl(url)).toThrow(TypeError) + }) + + it('normalizes a public HTTPS endpoint', () => { + expect(normalizeEndpointUrl(' https://HOOKS.EXAMPLE.com:443/events?source=relay ')).toBe( + 'https://hooks.example.com/events?source=relay', + ) + }) + + it('allows nested public hostnames', () => { + expect(normalizeEndpointUrl('https://webhooks.api.example.com/v1/relay')).toBe( + 'https://webhooks.api.example.com/v1/relay', + ) + }) +}) diff --git a/apps/console/worker/lib/endpoint-persistence.ts b/apps/console/worker/lib/endpoint-persistence.ts index ac233dc..5c5ed0e 100644 --- a/apps/console/worker/lib/endpoint-persistence.ts +++ b/apps/console/worker/lib/endpoint-persistence.ts @@ -1,4 +1,5 @@ import { canonicalizeJson } from './canonical-json.js' +import { normalizeEndpointUrl } from './endpoint-url-policy.js' import type { RelayDatabase, RelayStatement } from './database.js' import type { RelayIdPrefix } from './ids.js' import { createPrefixedId } from './ids.js' @@ -33,26 +34,6 @@ function normalizeName(name: string): string { return normalized } -function normalizeUrl(value: string): string { - let parsed: URL - - try { - parsed = new URL(value.trim()) - } catch { - throw new TypeError('Endpoint URL must be a valid HTTP or HTTPS URL.') - } - - if (!['http:', 'https:'].includes(parsed.protocol)) { - throw new TypeError('Endpoint URL must be a valid HTTP or HTTPS URL.') - } - - if (parsed.username || parsed.password) { - throw new TypeError('Endpoint URL must not contain credentials.') - } - - return parsed.toString() -} - function normalizeEventTypes(eventTypes: readonly string[]): string[] { const normalized = new Set() @@ -75,7 +56,7 @@ export async function createEndpoint( dependencies: EndpointPersistenceDependencies = {}, ): Promise { const name = normalizeName(input.name) - const url = normalizeUrl(input.url) + const url = normalizeEndpointUrl(input.url) const eventTypes = normalizeEventTypes(input.eventTypes) const now = dependencies.now ?? (() => new Date().toISOString()) const createId = dependencies.createId ?? createPrefixedId diff --git a/apps/console/worker/lib/endpoint-url-policy.ts b/apps/console/worker/lib/endpoint-url-policy.ts new file mode 100644 index 0000000..d94d93d --- /dev/null +++ b/apps/console/worker/lib/endpoint-url-policy.ts @@ -0,0 +1,86 @@ +export const MAX_ENDPOINT_URL_LENGTH = 2048 + +const BLOCKED_HOST_SUFFIXES = ['.localhost', '.local', '.internal', '.home.arpa'] as const + +function reject(message: string): never { + throw new TypeError(message) +} + +function normalizedHostname(parsed: URL): string { + return parsed.hostname.toLowerCase().replace(/\.$/, '') +} + +function isIpLiteral(hostname: string): boolean { + const unwrapped = + hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname + + if (unwrapped.includes(':')) { + return true + } + + return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(unwrapped) +} + +function isPrivateLikeHostname(hostname: string): boolean { + if (hostname === 'localhost') { + return true + } + + if (!hostname.includes('.')) { + return true + } + + return BLOCKED_HOST_SUFFIXES.some( + (suffix) => hostname === suffix.slice(1) || hostname.endsWith(suffix), + ) +} + +export function normalizeEndpointUrl(value: string): string { + const input = value.trim() + + if (input.length < 1) { + return reject('Endpoint URL is required.') + } + + if (input.length > MAX_ENDPOINT_URL_LENGTH) { + return reject('Endpoint URL is too long.') + } + + let parsed: URL + + try { + parsed = new URL(input) + } catch { + return reject('Endpoint URL must be a valid HTTPS URL.') + } + + if (parsed.protocol !== 'https:') { + return reject('Endpoint URL must use HTTPS.') + } + + if (parsed.username || parsed.password) { + return reject('Endpoint URL must not contain credentials.') + } + + if (parsed.hash) { + return reject('Endpoint URL must not contain a fragment.') + } + + if (parsed.port) { + return reject('Endpoint URL must use port 443.') + } + + const hostname = normalizedHostname(parsed) + + if (isIpLiteral(hostname)) { + return reject('Endpoint URL must not use an IP-literal host.') + } + + if (isPrivateLikeHostname(hostname)) { + return reject('Endpoint URL must use a public hostname.') + } + + parsed.hostname = hostname + + return parsed.toString() +} From 033f195e6a4000c9493c0c723c82171948d33ed3 Mon Sep 17 00:00:00 2001 From: Oniel Alejo Feliz <197416079+XonkelX@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:48:03 -0400 Subject: [PATCH 02/12] feat: add endpoint challenge verification --- .../src/endpoint-verification.d1.test.ts | 167 ++++++++ .../worker/lib/endpoint-verification.ts | 385 ++++++++++++++++++ .../0007_phase_4_endpoint_verification.sql | 20 + 3 files changed, 572 insertions(+) create mode 100644 apps/console/src/endpoint-verification.d1.test.ts create mode 100644 apps/console/worker/lib/endpoint-verification.ts create mode 100644 migrations/0007_phase_4_endpoint_verification.sql diff --git a/apps/console/src/endpoint-verification.d1.test.ts b/apps/console/src/endpoint-verification.d1.test.ts new file mode 100644 index 0000000..8a88551 --- /dev/null +++ b/apps/console/src/endpoint-verification.d1.test.ts @@ -0,0 +1,167 @@ +import { env } from 'cloudflare:workers' +import { describe, expect, it, vi } from 'vitest' +import { createEndpoint } from '../worker/lib/endpoint-persistence.js' +import { verifyEndpoint } from '../worker/lib/endpoint-verification.js' + +const now = Date.parse('2026-08-06T22:00:00.000Z') + +async function seedEndpoint(suffix: string): Promise { + const endpoint = await createEndpoint( + env.DB, + { + name: `Verification ${suffix}`, + url: `https://${suffix}.example.test/webhook`, + eventTypes: [], + }, + { + now: () => '2026-08-06T21:55:00.000Z', + createId: (prefix) => `${prefix}_verify_${suffix}`, + }, + ) + + return endpoint.id +} + +describe('D1 endpoint verification', () => { + it('activates only after an exact signed challenge response', async () => { + const endpointId = await seedEndpoint('success') + const challenge = 'rly_verify_success_challenge' + + const fetcher = vi.fn(async (input: RequestInfo | URL) => { + const request = input instanceof Request ? input : new Request(input) + + expect(request.redirect).toBe('manual') + expect(request.headers.get('webhook-signature')).toMatch(/^v1,/) + + const body = (await request.clone().json()) as { + type: string + data: { + challenge: string + } + } + + expect(body.type).toBe('relay.endpoint_verification') + expect(body.data.challenge).toBe(challenge) + + return new Response(null, { + status: 200, + headers: { + 'X-Relay-Challenge': body.data.challenge, + }, + }) + }) as typeof fetch + + await expect( + verifyEndpoint(env.DB, endpointId, 'endpoint_test_secret', { + createChallenge: () => challenge, + fetcher, + nowMilliseconds: () => now, + }), + ).resolves.toEqual({ + kind: 'verified', + endpointId, + verifiedAt: '2026-08-06T22:00:00.000Z', + }) + + const stored = await env.DB.prepare( + `SELECT + status, + verified_at, + verification_challenge_hash, + verification_expires_at + FROM endpoints + WHERE id = ?`, + ) + .bind(endpointId) + .first<{ + status: string + verified_at: string | null + verification_challenge_hash: string | null + verification_expires_at: string | null + }>() + + expect(stored).toEqual({ + status: 'active', + verified_at: '2026-08-06T22:00:00.000Z', + verification_challenge_hash: null, + verification_expires_at: null, + }) + }) + + it('keeps the endpoint pending when the challenge does not match', async () => { + const endpointId = await seedEndpoint('wrong') + + const fetcher = vi.fn( + async () => + new Response(null, { + status: 200, + headers: { + 'X-Relay-Challenge': 'wrong', + }, + }), + ) as typeof fetch + + await expect( + verifyEndpoint(env.DB, endpointId, 'endpoint_test_secret', { + createChallenge: () => 'rly_verify_expected', + fetcher, + nowMilliseconds: () => now, + }), + ).resolves.toMatchObject({ + kind: 'failed', + reason: 'challenge_mismatch', + }) + + const stored = await env.DB.prepare( + `SELECT + status, + verified_at, + verification_challenge_hash + FROM endpoints + WHERE id = ?`, + ) + .bind(endpointId) + .first<{ + status: string + verified_at: string | null + verification_challenge_hash: string | null + }>() + + expect(stored).toEqual({ + status: 'pending', + verified_at: null, + verification_challenge_hash: null, + }) + }) + + it('does not follow redirects during verification', async () => { + const endpointId = await seedEndpoint('redirect') + const challenge = 'rly_verify_redirect' + + const fetcher = vi.fn(async (input: RequestInfo | URL) => { + const request = input instanceof Request ? input : new Request(input) + + expect(request.redirect).toBe('manual') + + return new Response(null, { + status: 302, + headers: { + location: 'https://other.example.test/', + 'X-Relay-Challenge': challenge, + }, + }) + }) as typeof fetch + + await expect( + verifyEndpoint(env.DB, endpointId, 'endpoint_test_secret', { + createChallenge: () => challenge, + fetcher, + nowMilliseconds: () => now, + }), + ).resolves.toMatchObject({ + kind: 'failed', + reason: 'http_status', + statusCode: 302, + }) + }) +}) diff --git a/apps/console/worker/lib/endpoint-verification.ts b/apps/console/worker/lib/endpoint-verification.ts new file mode 100644 index 0000000..cfeb9ca --- /dev/null +++ b/apps/console/worker/lib/endpoint-verification.ts @@ -0,0 +1,385 @@ +import { canonicalizeJson } from './canonical-json.js' +import { sha256Hex } from './auth.js' +import type { RelayDatabase } from './database.js' +import type { RelayIdPrefix } from './ids.js' +import { createPrefixedId } from './ids.js' +import { executeWebhook } from './webhook-executor.js' +import { createWebhookHeaders } from './webhook-signing.js' + +const VERIFICATION_CHALLENGE_BYTES = 32 +const VERIFICATION_TTL_MS = 5 * 60 * 1000 + +interface EndpointRow { + id: string + url: string + status: string +} + +export interface EndpointVerificationDependencies { + createChallenge?: () => string + createId?: (prefix: RelayIdPrefix) => string + fetcher?: typeof fetch + nowMilliseconds?: () => number + timeoutMs?: number +} + +export type EndpointVerificationResult = + | { + kind: 'verified' + endpointId: string + verifiedAt: string + } + | { + kind: 'missing' + } + | { + kind: 'ineligible' + status: string + } + | { + kind: 'failed' + reason: + | 'http_status' + | 'challenge_mismatch' + | 'timeout' + | 'network_error' + | 'expired' + | 'stale_challenge' + statusCode: number | null + } + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') +} + +export function createVerificationChallenge(): string { + const bytes = new Uint8Array(VERIFICATION_CHALLENGE_BYTES) + crypto.getRandomValues(bytes) + + return `rly_verify_${bytesToHex(bytes)}` +} + +async function buildVerificationRequest( + endpoint: EndpointRow, + challenge: string, + signingSecret: string, + timestampMs: number, +): Promise { + const timestampSeconds = Math.floor(timestampMs / 1000) + const timestamp = new Date(timestampMs).toISOString() + const messageId = `msg_verify_${endpoint.id.slice(3)}_` + challenge.slice(-16) + + const rawBody = JSON.stringify({ + id: messageId, + type: 'relay.endpoint_verification', + timestamp, + data: { + challenge, + }, + }) + + const headers = await createWebhookHeaders({ + messageId, + timestamp: timestampSeconds, + rawBody, + secret: signingSecret, + }) + + return new Request(endpoint.url, { + method: 'POST', + headers, + body: rawBody, + redirect: 'manual', + }) +} + +async function recordFailure( + database: RelayDatabase, + endpointId: string, + challengeHash: string, + failedAt: string, + reason: string, + statusCode: number | null, + createId: (prefix: RelayIdPrefix) => string, +): Promise { + const metadata: Record = { + reason, + } + + if (statusCode !== null) { + metadata.statusCode = statusCode + } + + await database.batch([ + database + .prepare( + `INSERT INTO audit_log ( + id, + actor_type, + action, + target_type, + target_id, + metadata_json, + created_at + ) + SELECT + ?, + 'owner', + 'endpoint.verification_failed', + 'endpoint', + ?, + ?, + ? + WHERE EXISTS ( + SELECT 1 + FROM endpoints + WHERE id = ? + AND status = 'pending' + AND verification_challenge_hash = ? + )`, + ) + .bind( + createId('aud'), + endpointId, + canonicalizeJson(metadata), + failedAt, + endpointId, + challengeHash, + ), + + database + .prepare( + `UPDATE endpoints + SET verification_challenge_hash = NULL, + verification_expires_at = NULL, + updated_at = ? + WHERE id = ? + AND status = 'pending' + AND verification_challenge_hash = ?`, + ) + .bind(failedAt, endpointId, challengeHash), + ]) +} + +export async function verifyEndpoint( + database: RelayDatabase, + endpointId: string, + signingSecret: string, + dependencies: EndpointVerificationDependencies = {}, +): Promise { + const endpoint = await database + .prepare( + `SELECT id, url, status + FROM endpoints + WHERE id = ? + LIMIT 1`, + ) + .bind(endpointId) + .first() + + if (!endpoint) { + return { kind: 'missing' } + } + + if (endpoint.status !== 'pending') { + return { + kind: 'ineligible', + status: endpoint.status, + } + } + + const nowMilliseconds = dependencies.nowMilliseconds ?? Date.now + const createChallenge = dependencies.createChallenge ?? createVerificationChallenge + const createId = dependencies.createId ?? createPrefixedId + + const startedAtMs = nowMilliseconds() + const startedAt = new Date(startedAtMs).toISOString() + const expiresAtMs = startedAtMs + VERIFICATION_TTL_MS + const expiresAt = new Date(expiresAtMs).toISOString() + const challenge = createChallenge() + const challengeHash = await sha256Hex(challenge) + + const claimed = await database + .prepare( + `UPDATE endpoints + SET verification_challenge_hash = ?, + verification_expires_at = ?, + verification_attempted_at = ?, + updated_at = ? + WHERE id = ? + AND status = 'pending' + RETURNING id`, + ) + .bind(challengeHash, expiresAt, startedAt, startedAt, endpointId) + .first<{ id: string }>() + + if (!claimed) { + return { + kind: 'ineligible', + status: endpoint.status, + } + } + + const request = await buildVerificationRequest(endpoint, challenge, signingSecret, startedAtMs) + + const executionInput = + dependencies.timeoutMs === undefined + ? { request } + : { + request, + timeoutMs: dependencies.timeoutMs, + } + + const executionDependencies = + dependencies.fetcher === undefined + ? { nowMilliseconds } + : { + fetcher: dependencies.fetcher, + nowMilliseconds, + } + + const execution = await executeWebhook(executionInput, executionDependencies) + + const completedAtMs = nowMilliseconds() + const completedAt = new Date(completedAtMs).toISOString() + + if (completedAtMs > expiresAtMs) { + await recordFailure(database, endpointId, challengeHash, completedAt, 'expired', null, createId) + + return { + kind: 'failed', + reason: 'expired', + statusCode: null, + } + } + + if (execution.kind !== 'response') { + const reason = execution.kind === 'timeout' ? 'timeout' : 'network_error' + + await recordFailure(database, endpointId, challengeHash, completedAt, reason, null, createId) + + return { + kind: 'failed', + reason, + statusCode: null, + } + } + + if (execution.statusCode !== 200) { + await recordFailure( + database, + endpointId, + challengeHash, + completedAt, + 'http_status', + execution.statusCode, + createId, + ) + + return { + kind: 'failed', + reason: 'http_status', + statusCode: execution.statusCode, + } + } + + if (execution.responseHeaders['x-relay-challenge'] !== challenge) { + await recordFailure( + database, + endpointId, + challengeHash, + completedAt, + 'challenge_mismatch', + execution.statusCode, + createId, + ) + + return { + kind: 'failed', + reason: 'challenge_mismatch', + statusCode: execution.statusCode, + } + } + + await database.batch([ + database + .prepare( + `UPDATE endpoints + SET status = 'active', + verified_at = ?, + verification_challenge_hash = NULL, + verification_expires_at = NULL, + updated_at = ? + WHERE id = ? + AND status = 'pending' + AND verification_challenge_hash = ? + AND verification_expires_at >= ?`, + ) + .bind(completedAt, completedAt, endpointId, challengeHash, completedAt), + + database + .prepare( + `INSERT INTO audit_log ( + id, + actor_type, + action, + target_type, + target_id, + metadata_json, + created_at + ) + SELECT + ?, + 'owner', + 'endpoint.verified', + 'endpoint', + ?, + ?, + ? + WHERE EXISTS ( + SELECT 1 + FROM endpoints + WHERE id = ? + AND status = 'active' + AND verified_at = ? + )`, + ) + .bind( + createId('aud'), + endpointId, + canonicalizeJson({ + method: 'challenge_response', + }), + completedAt, + endpointId, + completedAt, + ), + ]) + + const verified = await database + .prepare( + `SELECT status, verified_at + FROM endpoints + WHERE id = ? + LIMIT 1`, + ) + .bind(endpointId) + .first<{ + status: string + verified_at: string | null + }>() + + if (verified?.status !== 'active' || verified.verified_at !== completedAt) { + return { + kind: 'failed', + reason: 'stale_challenge', + statusCode: 200, + } + } + + return { + kind: 'verified', + endpointId, + verifiedAt: completedAt, + } +} diff --git a/migrations/0007_phase_4_endpoint_verification.sql b/migrations/0007_phase_4_endpoint_verification.sql new file mode 100644 index 0000000..06506e1 --- /dev/null +++ b/migrations/0007_phase_4_endpoint_verification.sql @@ -0,0 +1,20 @@ +ALTER TABLE endpoints +ADD COLUMN verification_challenge_hash TEXT + CHECK ( + verification_challenge_hash IS NULL + OR length(verification_challenge_hash) = 64 + ); + +ALTER TABLE endpoints +ADD COLUMN verification_expires_at TEXT; + +ALTER TABLE endpoints +ADD COLUMN verification_attempted_at TEXT; + +CREATE INDEX idx_endpoints_verification + ON endpoints (status, verification_expires_at); + +UPDATE relay_meta +SET value = '7', + updated_at = CURRENT_TIMESTAMP +WHERE key = 'schema_version'; From c2724298a6fd3c9403ead209963381808a20ceb9 Mon Sep 17 00:00:00 2001 From: Oniel Alejo Feliz <197416079+XonkelX@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:55:32 -0400 Subject: [PATCH 03/12] feat: add encrypted endpoint secret crypto --- .../src/endpoint-secret-crypto.test.ts | 83 +++++++++ .../worker/lib/endpoint-secret-crypto.ts | 161 ++++++++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 apps/console/src/endpoint-secret-crypto.test.ts create mode 100644 apps/console/worker/lib/endpoint-secret-crypto.ts diff --git a/apps/console/src/endpoint-secret-crypto.test.ts b/apps/console/src/endpoint-secret-crypto.test.ts new file mode 100644 index 0000000..e09a91f --- /dev/null +++ b/apps/console/src/endpoint-secret-crypto.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest' +import { + createEndpointSigningSecret, + decryptEndpointSecret, + encryptEndpointSecret, +} from '../worker/lib/endpoint-secret-crypto.js' + +const masterKeyV1 = btoa( + String.fromCharCode(...Array.from({ length: 32 }, (_, index) => index + 1)), +) + +const keyring = { + v1: masterKeyV1, +} + +describe('endpoint secret encryption', () => { + it('generates high-entropy endpoint signing secrets', () => { + const first = createEndpointSigningSecret() + const second = createEndpointSigningSecret() + + expect(first).toMatch(/^rly_whsec_[0-9a-f]{64}$/) + expect(second).toMatch(/^rly_whsec_[0-9a-f]{64}$/) + expect(first).not.toBe(second) + }) + + it('round-trips a secret through AES-GCM', async () => { + const secret = createEndpointSigningSecret() + + const encrypted = await encryptEndpointSecret(secret, 'ep_crypto_primary', 'v1', keyring) + + expect(encrypted.keyVersion).toBe('v1') + expect(encrypted.ivBase64).not.toContain(secret) + expect(encrypted.ciphertextBase64).not.toContain(secret) + + await expect(decryptEndpointSecret(encrypted, 'ep_crypto_primary', keyring)).resolves.toBe( + secret, + ) + }) + + it('binds ciphertext to its endpoint ID', async () => { + const encrypted = await encryptEndpointSecret( + createEndpointSigningSecret(), + 'ep_crypto_owner', + 'v1', + keyring, + ) + + await expect(decryptEndpointSecret(encrypted, 'ep_crypto_other', keyring)).rejects.toThrow( + 'Endpoint signing secret could not be decrypted.', + ) + }) + + it('rejects decryption with the wrong master key', async () => { + const encrypted = await encryptEndpointSecret( + createEndpointSigningSecret(), + 'ep_crypto_wrong_key', + 'v1', + keyring, + ) + + const wrongKeyring = { + v1: btoa(String.fromCharCode(...Array.from({ length: 32 }, (_, index) => 255 - index))), + } + + await expect( + decryptEndpointSecret(encrypted, 'ep_crypto_wrong_key', wrongKeyring), + ).rejects.toThrow('Endpoint signing secret could not be decrypted.') + }) + + it('rejects malformed master keys', async () => { + await expect( + encryptEndpointSecret(createEndpointSigningSecret(), 'ep_crypto_bad_key', 'v1', { + v1: btoa('too-short'), + }), + ).rejects.toThrow('Endpoint secret master key must contain exactly 32 bytes.') + }) + + it('rejects unavailable key versions', async () => { + await expect( + encryptEndpointSecret(createEndpointSigningSecret(), 'ep_crypto_missing', 'v2', keyring), + ).rejects.toThrow('Endpoint secret master key version v2 is unavailable.') + }) +}) diff --git a/apps/console/worker/lib/endpoint-secret-crypto.ts b/apps/console/worker/lib/endpoint-secret-crypto.ts new file mode 100644 index 0000000..43e3705 --- /dev/null +++ b/apps/console/worker/lib/endpoint-secret-crypto.ts @@ -0,0 +1,161 @@ +const ENDPOINT_SECRET_BYTES = 32 +const AES_GCM_IV_BYTES = 12 +const AES_256_KEY_BYTES = 32 +const ENDPOINT_SECRET_PREFIX = 'rly_whsec_' + +export interface EncryptedEndpointSecret { + keyVersion: string + ivBase64: string + ciphertextBase64: string +} + +export type EndpointSecretKeyring = Readonly> + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') +} + +function bytesToBase64(bytes: Uint8Array): string { + let binary = '' + + for (const byte of bytes) { + binary += String.fromCharCode(byte) + } + + return btoa(binary) +} + +function base64ToBytes(value: string): Uint8Array { + let binary: string + + try { + binary = atob(value) + } catch { + throw new TypeError('Endpoint secret master key must be valid base64.') + } + + const bytes = new Uint8Array(new ArrayBuffer(binary.length)) + + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index) + } + + return bytes +} + +async function importMasterKey( + keyVersion: string, + keyring: EndpointSecretKeyring, +): Promise { + const encoded = keyring[keyVersion] + + if (!encoded) { + throw new Error(`Endpoint secret master key version ${keyVersion} is unavailable.`) + } + + const keyBytes = base64ToBytes(encoded) + + if (keyBytes.byteLength !== AES_256_KEY_BYTES) { + throw new TypeError('Endpoint secret master key must contain exactly 32 bytes.') + } + + return crypto.subtle.importKey( + 'raw', + keyBytes, + { + name: 'AES-GCM', + }, + false, + ['encrypt', 'decrypt'], + ) +} + +function additionalData(endpointId: string, keyVersion: string): Uint8Array { + if (!endpointId.startsWith('ep_')) { + throw new TypeError('Endpoint ID must use the ep_ prefix.') + } + + const encoded = new TextEncoder().encode(`relay:endpoint-secret:${keyVersion}:${endpointId}`) + const bytes = new Uint8Array(new ArrayBuffer(encoded.byteLength)) + + bytes.set(encoded) + + return bytes +} + +export function createEndpointSigningSecret(): string { + const secret = new Uint8Array(ENDPOINT_SECRET_BYTES) + + crypto.getRandomValues(secret) + + return `${ENDPOINT_SECRET_PREFIX}${bytesToHex(secret)}` +} + +export async function encryptEndpointSecret( + plaintext: string, + endpointId: string, + keyVersion: string, + keyring: EndpointSecretKeyring, +): Promise { + if (!plaintext.startsWith(ENDPOINT_SECRET_PREFIX)) { + throw new TypeError('Endpoint signing secret has an invalid format.') + } + + if (!keyVersion.trim()) { + throw new TypeError('Endpoint secret key version is required.') + } + + const key = await importMasterKey(keyVersion, keyring) + const iv = new Uint8Array(AES_GCM_IV_BYTES) + + crypto.getRandomValues(iv) + + const ciphertext = await crypto.subtle.encrypt( + { + name: 'AES-GCM', + iv, + additionalData: additionalData(endpointId, keyVersion), + }, + key, + new TextEncoder().encode(plaintext), + ) + + return { + keyVersion, + ivBase64: bytesToBase64(iv), + ciphertextBase64: bytesToBase64(new Uint8Array(ciphertext)), + } +} + +export async function decryptEndpointSecret( + encrypted: EncryptedEndpointSecret, + endpointId: string, + keyring: EndpointSecretKeyring, +): Promise { + const key = await importMasterKey(encrypted.keyVersion, keyring) + + const iv = base64ToBytes(encrypted.ivBase64) + const ciphertext = base64ToBytes(encrypted.ciphertextBase64) + + if (iv.byteLength !== AES_GCM_IV_BYTES) { + throw new TypeError('Endpoint secret IV must contain exactly 12 bytes.') + } + + let plaintext: ArrayBuffer + + try { + plaintext = await crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv, + additionalData: additionalData(endpointId, encrypted.keyVersion), + }, + key, + ciphertext, + ) + } catch { + throw new Error('Endpoint signing secret could not be decrypted.') + } + + return new TextDecoder().decode(plaintext) +} From 21c1ccf6b64832aeb672348fef2898d31d576bb6 Mon Sep 17 00:00:00 2001 From: Oniel Alejo Feliz <197416079+XonkelX@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:58:39 -0400 Subject: [PATCH 04/12] feat: persist encrypted endpoint signing secrets --- .../src/endpoint-secret-store.d1.test.ts | 111 ++++++++++++++ .../worker/lib/endpoint-secret-store.ts | 138 ++++++++++++++++++ migrations/0008_phase_4_endpoint_secrets.sql | 37 +++++ 3 files changed, 286 insertions(+) create mode 100644 apps/console/src/endpoint-secret-store.d1.test.ts create mode 100644 apps/console/worker/lib/endpoint-secret-store.ts create mode 100644 migrations/0008_phase_4_endpoint_secrets.sql diff --git a/apps/console/src/endpoint-secret-store.d1.test.ts b/apps/console/src/endpoint-secret-store.d1.test.ts new file mode 100644 index 0000000..b3e38cd --- /dev/null +++ b/apps/console/src/endpoint-secret-store.d1.test.ts @@ -0,0 +1,111 @@ +import { env } from 'cloudflare:workers' +import { describe, expect, it } from 'vitest' +import { createEndpoint } from '../worker/lib/endpoint-persistence.js' +import { + provisionEndpointSigningSecret, + resolveEndpointSigningSecret, +} from '../worker/lib/endpoint-secret-store.js' + +const rawSecret = `rly_whsec_${'a'.repeat(64)}` + +const keyring = { + v1: btoa(String.fromCharCode(...Array.from({ length: 32 }, (_, index) => index + 10))), +} + +describe('D1 endpoint signing secret storage', () => { + it('stores only encrypted secret material and resolves the plaintext', async () => { + const endpoint = await createEndpoint( + env.DB, + { + name: 'Encrypted secret endpoint', + url: 'https://secret-storage.example.test/webhook', + eventTypes: [], + }, + { + now: () => '2026-08-06T23:00:00.000Z', + createId: (prefix) => `${prefix}_secret_store`, + }, + ) + + await expect( + provisionEndpointSigningSecret(env.DB, endpoint.id, 'v1', keyring, { + now: () => '2026-08-06T23:01:00.000Z', + createSecret: () => rawSecret, + }), + ).resolves.toEqual({ + endpointId: endpoint.id, + rawSecret, + generation: 1, + keyVersion: 'v1', + createdAt: '2026-08-06T23:01:00.000Z', + }) + + const stored = await env.DB.prepare( + `SELECT + generation, + state, + key_version, + iv_base64, + ciphertext_base64, + valid_until + FROM endpoint_signing_secrets + WHERE endpoint_id = ?`, + ) + .bind(endpoint.id) + .first<{ + generation: number + state: string + key_version: string + iv_base64: string + ciphertext_base64: string + valid_until: string | null + }>() + + expect(stored).not.toBeNull() + expect(stored?.generation).toBe(1) + expect(stored?.state).toBe('active') + expect(stored?.key_version).toBe('v1') + expect(stored?.valid_until).toBeNull() + expect(stored?.iv_base64).not.toContain(rawSecret) + expect(stored?.ciphertext_base64).not.toContain(rawSecret) + + await expect(resolveEndpointSigningSecret(env.DB, endpoint.id, keyring)).resolves.toBe( + rawSecret, + ) + }) + + it('rejects a second active secret provision', async () => { + const endpoint = await createEndpoint( + env.DB, + { + name: 'Duplicate secret endpoint', + url: 'https://duplicate-secret.example.test/webhook', + eventTypes: [], + }, + { + now: () => '2026-08-06T23:10:00.000Z', + createId: (prefix) => `${prefix}_secret_duplicate`, + }, + ) + + await provisionEndpointSigningSecret(env.DB, endpoint.id, 'v1', keyring, { + createSecret: () => rawSecret, + }) + + await expect( + provisionEndpointSigningSecret(env.DB, endpoint.id, 'v1', keyring, { + createSecret: () => `rly_whsec_${'b'.repeat(64)}`, + }), + ).rejects.toThrow('Endpoint already has an active signing secret.') + }) + + it('rejects secret operations for a missing endpoint', async () => { + await expect( + provisionEndpointSigningSecret(env.DB, 'ep_missing_secret', 'v1', keyring), + ).rejects.toThrow('Endpoint does not exist.') + + await expect( + resolveEndpointSigningSecret(env.DB, 'ep_missing_secret', keyring), + ).rejects.toThrow('Endpoint does not have an active signing secret.') + }) +}) diff --git a/apps/console/worker/lib/endpoint-secret-store.ts b/apps/console/worker/lib/endpoint-secret-store.ts new file mode 100644 index 0000000..98ebd21 --- /dev/null +++ b/apps/console/worker/lib/endpoint-secret-store.ts @@ -0,0 +1,138 @@ +import type { RelayDatabase } from './database.js' +import { + createEndpointSigningSecret, + decryptEndpointSecret, + encryptEndpointSecret, + type EndpointSecretKeyring, +} from './endpoint-secret-crypto.js' + +interface StoredSecretRow { + key_version: string + iv_base64: string + ciphertext_base64: string +} + +export interface ProvisionEndpointSecretDependencies { + now?: () => string + createSecret?: () => string +} + +export interface ProvisionedEndpointSecret { + endpointId: string + rawSecret: string + generation: number + keyVersion: string + createdAt: string +} + +export async function provisionEndpointSigningSecret( + database: RelayDatabase, + endpointId: string, + keyVersion: string, + keyring: EndpointSecretKeyring, + dependencies: ProvisionEndpointSecretDependencies = {}, +): Promise { + const endpoint = await database + .prepare( + `SELECT id + FROM endpoints + WHERE id = ? + LIMIT 1`, + ) + .bind(endpointId) + .first<{ id: string }>() + + if (!endpoint) { + throw new Error('Endpoint does not exist.') + } + + const existing = await database + .prepare( + `SELECT generation + FROM endpoint_signing_secrets + WHERE endpoint_id = ? + AND state = 'active' + LIMIT 1`, + ) + .bind(endpointId) + .first<{ generation: number }>() + + if (existing) { + throw new Error('Endpoint already has an active signing secret.') + } + + const createSecret = dependencies.createSecret ?? createEndpointSigningSecret + const now = dependencies.now ?? (() => new Date().toISOString()) + + const rawSecret = createSecret() + const createdAt = now() + const generation = 1 + + const encrypted = await encryptEndpointSecret(rawSecret, endpointId, keyVersion, keyring) + + await database + .prepare( + `INSERT INTO endpoint_signing_secrets ( + endpoint_id, + generation, + state, + key_version, + iv_base64, + ciphertext_base64, + valid_until, + created_at + ) + VALUES (?, ?, 'active', ?, ?, ?, NULL, ?)`, + ) + .bind( + endpointId, + generation, + encrypted.keyVersion, + encrypted.ivBase64, + encrypted.ciphertextBase64, + createdAt, + ) + .run() + + return { + endpointId, + rawSecret, + generation, + keyVersion, + createdAt, + } +} + +export async function resolveEndpointSigningSecret( + database: RelayDatabase, + endpointId: string, + keyring: EndpointSecretKeyring, +): Promise { + const stored = await database + .prepare( + `SELECT + key_version, + iv_base64, + ciphertext_base64 + FROM endpoint_signing_secrets + WHERE endpoint_id = ? + AND state = 'active' + LIMIT 1`, + ) + .bind(endpointId) + .first() + + if (!stored) { + throw new Error('Endpoint does not have an active signing secret.') + } + + return decryptEndpointSecret( + { + keyVersion: stored.key_version, + ivBase64: stored.iv_base64, + ciphertextBase64: stored.ciphertext_base64, + }, + endpointId, + keyring, + ) +} diff --git a/migrations/0008_phase_4_endpoint_secrets.sql b/migrations/0008_phase_4_endpoint_secrets.sql new file mode 100644 index 0000000..b44099f --- /dev/null +++ b/migrations/0008_phase_4_endpoint_secrets.sql @@ -0,0 +1,37 @@ +CREATE TABLE endpoint_signing_secrets ( + endpoint_id TEXT NOT NULL, + generation INTEGER NOT NULL + CHECK (generation >= 1), + state TEXT NOT NULL + CHECK (state IN ('active', 'previous')), + key_version TEXT NOT NULL + CHECK (length(trim(key_version)) BETWEEN 1 AND 40), + iv_base64 TEXT NOT NULL + CHECK (length(iv_base64) = 16), + ciphertext_base64 TEXT NOT NULL + CHECK (length(ciphertext_base64) BETWEEN 32 AND 512), + valid_until TEXT, + created_at TEXT NOT NULL, + PRIMARY KEY (endpoint_id, generation), + FOREIGN KEY (endpoint_id) + REFERENCES endpoints (id) + ON DELETE CASCADE, + CHECK ( + (state = 'active' AND valid_until IS NULL) + OR + (state = 'previous' AND valid_until IS NOT NULL) + ) +); + +CREATE UNIQUE INDEX idx_endpoint_signing_secrets_active + ON endpoint_signing_secrets (endpoint_id) + WHERE state = 'active'; + +CREATE INDEX idx_endpoint_signing_secrets_previous_expiry + ON endpoint_signing_secrets (valid_until) + WHERE state = 'previous'; + +UPDATE relay_meta +SET value = '8', + updated_at = CURRENT_TIMESTAMP +WHERE key = 'schema_version'; From cde1609441df2350f2dd608aeb2c9fed5a159c63 Mon Sep 17 00:00:00 2001 From: Oniel Alejo Feliz <197416079+XonkelX@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:02:55 -0400 Subject: [PATCH 05/12] feat: resolve encrypted endpoint secrets for delivery --- README.md | 2 +- .../src/endpoint-secret-keyring.test.ts | 32 +++++++++++++++++++ apps/console/src/queue-handler.test.ts | 2 +- .../worker/lib/endpoint-secret-keyring.ts | 26 +++++++++++++++ .../worker/middleware/require-api-key.ts | 3 +- apps/console/worker/queue-handler.ts | 13 +++----- 6 files changed, 66 insertions(+), 12 deletions(-) create mode 100644 apps/console/src/endpoint-secret-keyring.test.ts create mode 100644 apps/console/worker/lib/endpoint-secret-keyring.ts diff --git a/README.md b/README.md index 23c1b06..a9dbc09 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ Relay provides at-least-once delivery. An event may be delivered more than once, ## Environment Variables -Relay uses Cloudflare D1 and Queue bindings configured through `wrangler.jsonc`. During Phase 3, the delivery consumer resolves a temporary `DELIVERY_SIGNING_SECRET` Worker secret; Phase 4 will replace this with encrypted endpoint-specific signing secrets. +Relay uses Cloudflare D1 and Queue bindings configured through `wrangler.jsonc`. Endpoint signing secrets are stored encrypted in D1 with AES-GCM and decrypted at delivery time using a versioned Worker-held master key. Document non-secret examples in `.env.example`. Store local Worker secrets in an ignored `.dev.vars` file. Never commit credentials, tokens, signing secrets, or production identifiers. diff --git a/apps/console/src/endpoint-secret-keyring.test.ts b/apps/console/src/endpoint-secret-keyring.test.ts new file mode 100644 index 0000000..54435c7 --- /dev/null +++ b/apps/console/src/endpoint-secret-keyring.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { buildEndpointSecretKeyring } from '../worker/lib/endpoint-secret-keyring.js' + +describe('endpoint secret master-key environment', () => { + it('builds a versioned keyring', () => { + expect( + buildEndpointSecretKeyring({ + ENDPOINT_SECRET_MASTER_KEY_V1: 'key-v1', + ENDPOINT_SECRET_MASTER_KEY_V2: 'key-v2', + }), + ).toEqual({ + v1: 'key-v1', + v2: 'key-v2', + }) + }) + + it('allows only the currently configured version', () => { + expect( + buildEndpointSecretKeyring({ + ENDPOINT_SECRET_MASTER_KEY_V1: 'key-v1', + }), + ).toEqual({ + v1: 'key-v1', + }) + }) + + it('fails closed when no master key exists', () => { + expect(() => buildEndpointSecretKeyring({})).toThrow( + 'No endpoint secret master key is configured.', + ) + }) +}) diff --git a/apps/console/src/queue-handler.test.ts b/apps/console/src/queue-handler.test.ts index cbc044b..bd67a8d 100644 --- a/apps/console/src/queue-handler.test.ts +++ b/apps/console/src/queue-handler.test.ts @@ -17,7 +17,7 @@ function createEnvironment(): RelayWorkerEnvironment['Bindings'] { DELIVERY_QUEUE: { send: vi.fn(), }, - DELIVERY_SIGNING_SECRET: 'phase3_test_secret', + ENDPOINT_SECRET_MASTER_KEY_V1: btoa('a'.repeat(32)), } } diff --git a/apps/console/worker/lib/endpoint-secret-keyring.ts b/apps/console/worker/lib/endpoint-secret-keyring.ts new file mode 100644 index 0000000..1010070 --- /dev/null +++ b/apps/console/worker/lib/endpoint-secret-keyring.ts @@ -0,0 +1,26 @@ +import type { EndpointSecretKeyring } from './endpoint-secret-crypto.js' + +export interface EndpointSecretMasterKeyEnvironment { + ENDPOINT_SECRET_MASTER_KEY_V1?: string + ENDPOINT_SECRET_MASTER_KEY_V2?: string +} + +export function buildEndpointSecretKeyring( + environment: EndpointSecretMasterKeyEnvironment, +): EndpointSecretKeyring { + const keyring: Record = {} + + if (environment.ENDPOINT_SECRET_MASTER_KEY_V1) { + keyring.v1 = environment.ENDPOINT_SECRET_MASTER_KEY_V1 + } + + if (environment.ENDPOINT_SECRET_MASTER_KEY_V2) { + keyring.v2 = environment.ENDPOINT_SECRET_MASTER_KEY_V2 + } + + if (Object.keys(keyring).length === 0) { + throw new Error('No endpoint secret master key is configured.') + } + + return keyring +} diff --git a/apps/console/worker/middleware/require-api-key.ts b/apps/console/worker/middleware/require-api-key.ts index 172f3e4..4e71e3c 100644 --- a/apps/console/worker/middleware/require-api-key.ts +++ b/apps/console/worker/middleware/require-api-key.ts @@ -7,7 +7,8 @@ export interface RelayWorkerEnvironment { Bindings: { DB: RelayDatabase DELIVERY_QUEUE: DeliveryQueueProducer - DELIVERY_SIGNING_SECRET?: string + ENDPOINT_SECRET_MASTER_KEY_V1?: string + ENDPOINT_SECRET_MASTER_KEY_V2?: string } Variables: { apiKey: AuthenticatedApiKey diff --git a/apps/console/worker/queue-handler.ts b/apps/console/worker/queue-handler.ts index 991f432..bb16d55 100644 --- a/apps/console/worker/queue-handler.ts +++ b/apps/console/worker/queue-handler.ts @@ -1,5 +1,7 @@ import { DeliveryQueueMessageSchema } from '@relay/contracts' import type { RelayDatabase } from './lib/database.js' +import { buildEndpointSecretKeyring } from './lib/endpoint-secret-keyring.js' +import { resolveEndpointSigningSecret } from './lib/endpoint-secret-store.js' import { processDeliveryMessage, type DeliveryProcessorDependencies, @@ -46,15 +48,8 @@ export async function handleDeliveryQueue( try { const result = await processDelivery(env.DB, parsed.data, { - resolveSigningSecret: async (endpointId) => { - void endpointId - - if (!env.DELIVERY_SIGNING_SECRET) { - throw new Error('DELIVERY_SIGNING_SECRET is not configured.') - } - - return env.DELIVERY_SIGNING_SECRET - }, + resolveSigningSecret: async (endpointId) => + resolveEndpointSigningSecret(env.DB, endpointId, buildEndpointSecretKeyring(env)), }) if (result.action === 'ack') { From a9df180e16fefbb73142f5fc999df32427ca3759 Mon Sep 17 00:00:00 2001 From: Oniel Alejo Feliz <197416079+XonkelX@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:11:31 -0400 Subject: [PATCH 06/12] feat: rotate endpoint signing secrets --- .../src/delivery-exhaustion.d1.test.ts | 2 +- .../console/src/delivery-processor.d1.test.ts | 2 +- .../src/delivery-resilience.d1.test.ts | 2 +- .../src/delivery-retry-roundtrip.d1.test.ts | 2 +- .../endpoint-secret-dual-signature.d1.test.ts | 108 +++++++++++ .../src/endpoint-secret-rotation.d1.test.ts | 151 +++++++++++++++ apps/console/src/webhook-signing.test.ts | 2 +- apps/console/worker/lib/delivery-processor.ts | 6 +- .../worker/lib/endpoint-secret-store.ts | 182 ++++++++++++++++++ apps/console/worker/lib/webhook-request.ts | 12 +- apps/console/worker/lib/webhook-signing.ts | 45 +++++ apps/console/worker/queue-handler.ts | 6 +- 12 files changed, 502 insertions(+), 18 deletions(-) create mode 100644 apps/console/src/endpoint-secret-dual-signature.d1.test.ts create mode 100644 apps/console/src/endpoint-secret-rotation.d1.test.ts diff --git a/apps/console/src/delivery-exhaustion.d1.test.ts b/apps/console/src/delivery-exhaustion.d1.test.ts index ca018c5..72f0571 100644 --- a/apps/console/src/delivery-exhaustion.d1.test.ts +++ b/apps/console/src/delivery-exhaustion.d1.test.ts @@ -79,7 +79,7 @@ describe('D1 delivery exhaustion', () => { reason: 'retry', }, { - resolveSigningSecret: async () => 'exhaust_secret', + resolveSigningSecrets: async () => ['exhaust_secret'], fetcher, nowMilliseconds: () => nowMs, random: () => 0, diff --git a/apps/console/src/delivery-processor.d1.test.ts b/apps/console/src/delivery-processor.d1.test.ts index 613de3f..69c73e0 100644 --- a/apps/console/src/delivery-processor.d1.test.ts +++ b/apps/console/src/delivery-processor.d1.test.ts @@ -102,7 +102,7 @@ describe('D1 delivery processor', () => { let attemptSequence = 0 const dependencies = { - resolveSigningSecret: async () => 'endpoint_secret', + resolveSigningSecrets: async () => ['endpoint_secret'], fetcher, nowMilliseconds: () => nowMs, random: () => 0, diff --git a/apps/console/src/delivery-resilience.d1.test.ts b/apps/console/src/delivery-resilience.d1.test.ts index b658e4f..ef878b9 100644 --- a/apps/console/src/delivery-resilience.d1.test.ts +++ b/apps/console/src/delivery-resilience.d1.test.ts @@ -78,7 +78,7 @@ function dependencies(fetcher: typeof fetch, suffix: string) { let attempt = 0 return { - resolveSigningSecret: async () => 'test_secret', + resolveSigningSecrets: async () => ['test_secret'], fetcher, nowMilliseconds: () => createdMs, random: () => 0, diff --git a/apps/console/src/delivery-retry-roundtrip.d1.test.ts b/apps/console/src/delivery-retry-roundtrip.d1.test.ts index 2d0df81..ee3df55 100644 --- a/apps/console/src/delivery-retry-roundtrip.d1.test.ts +++ b/apps/console/src/delivery-retry-roundtrip.d1.test.ts @@ -116,7 +116,7 @@ describe('D1 delivery retry roundtrip', () => { let attempt = 0 const dependencies = { - resolveSigningSecret: async () => 'roundtrip_secret', + resolveSigningSecrets: async () => ['roundtrip_secret'], fetcher, nowMilliseconds: () => currentTime, random: () => 0, diff --git a/apps/console/src/endpoint-secret-dual-signature.d1.test.ts b/apps/console/src/endpoint-secret-dual-signature.d1.test.ts new file mode 100644 index 0000000..5efae14 --- /dev/null +++ b/apps/console/src/endpoint-secret-dual-signature.d1.test.ts @@ -0,0 +1,108 @@ +import { env } from 'cloudflare:workers' +import { describe, expect, it } from 'vitest' +import { createEndpoint } from '../worker/lib/endpoint-persistence.js' +import { + provisionEndpointSigningSecret, + resolveEndpointSigningSecrets, + rotateEndpointSigningSecret, +} from '../worker/lib/endpoint-secret-store.js' +import { buildWebhookRequest } from '../worker/lib/webhook-request.js' +import { createWebhookSignature } from '../worker/lib/webhook-signing.js' + +const previousSecret = `rly_whsec_${'a'.repeat(64)}` +const activeSecret = `rly_whsec_${'b'.repeat(64)}` + +const keyring = { + v1: btoa('k'.repeat(32)), +} + +describe('endpoint secret rotation delivery signatures', () => { + it('transitions from dual signatures to active-only after grace expiry', async () => { + const endpoint = await createEndpoint( + env.DB, + { + name: 'Dual signature endpoint', + url: 'https://dual-signature.example.test/webhook', + eventTypes: [], + }, + { + createId: (prefix) => `${prefix}_dual_signature`, + }, + ) + + await provisionEndpointSigningSecret(env.DB, endpoint.id, 'v1', keyring, { + createSecret: () => previousSecret, + }) + + await rotateEndpointSigningSecret(env.DB, endpoint.id, 'v1', keyring, { + nowMilliseconds: () => Date.parse('2026-08-07T02:00:00.000Z'), + graceSeconds: 3600, + createSecret: () => activeSecret, + }) + + const event = { + id: 'evt_rotation_signature', + type: 'order.created', + timestamp: '2026-08-07T02:15:00.000Z', + data: { + orderId: 'ord_rotation', + }, + } as const + + const timestampSeconds = 1786071600 + + const graceSecrets = await resolveEndpointSigningSecrets( + env.DB, + endpoint.id, + keyring, + Date.parse('2026-08-07T02:30:00.000Z'), + ) + + expect(graceSecrets).toEqual([activeSecret, previousSecret]) + + const duringGrace = await buildWebhookRequest({ + deliveryId: 'dlv_rotation_signature', + endpointUrl: endpoint.url, + event, + signingSecrets: graceSecrets, + timestampSeconds, + }) + + const activeSignature = await createWebhookSignature({ + messageId: duringGrace.webhookId, + timestamp: timestampSeconds, + rawBody: duringGrace.rawBody, + secret: activeSecret, + }) + + const previousSignature = await createWebhookSignature({ + messageId: duringGrace.webhookId, + timestamp: timestampSeconds, + rawBody: duringGrace.rawBody, + secret: previousSecret, + }) + + expect(duringGrace.request.headers.get('webhook-signature')).toBe( + `${activeSignature} ${previousSignature}`, + ) + + const expiredSecrets = await resolveEndpointSigningSecrets( + env.DB, + endpoint.id, + keyring, + Date.parse('2026-08-07T03:00:01.000Z'), + ) + + expect(expiredSecrets).toEqual([activeSecret]) + + const afterGrace = await buildWebhookRequest({ + deliveryId: 'dlv_rotation_signature', + endpointUrl: endpoint.url, + event, + signingSecrets: expiredSecrets, + timestampSeconds, + }) + + expect(afterGrace.request.headers.get('webhook-signature')).toBe(activeSignature) + }) +}) diff --git a/apps/console/src/endpoint-secret-rotation.d1.test.ts b/apps/console/src/endpoint-secret-rotation.d1.test.ts new file mode 100644 index 0000000..630d36f --- /dev/null +++ b/apps/console/src/endpoint-secret-rotation.d1.test.ts @@ -0,0 +1,151 @@ +import { env } from 'cloudflare:workers' +import { describe, expect, it } from 'vitest' +import { createEndpoint } from '../worker/lib/endpoint-persistence.js' +import { + provisionEndpointSigningSecret, + resolveEndpointSigningSecrets, + rotateEndpointSigningSecret, +} from '../worker/lib/endpoint-secret-store.js' + +const secretA = `rly_whsec_${'a'.repeat(64)}` +const secretB = `rly_whsec_${'b'.repeat(64)}` +const secretC = `rly_whsec_${'c'.repeat(64)}` + +const keyring = { + v1: btoa('k'.repeat(32)), + v2: btoa('m'.repeat(32)), +} + +async function seedEndpoint(suffix: string) { + return createEndpoint( + env.DB, + { + name: `Rotation ${suffix}`, + url: `https://rotation-${suffix}.example.test/webhook`, + eventTypes: [], + }, + { + createId: (prefix) => `${prefix}_rotation_${suffix}`, + }, + ) +} + +describe('D1 endpoint signing secret rotation', () => { + it('returns active then previous during the grace window', async () => { + const endpoint = await seedEndpoint('grace') + + await provisionEndpointSigningSecret(env.DB, endpoint.id, 'v1', keyring, { + createSecret: () => secretA, + }) + + const rotated = await rotateEndpointSigningSecret(env.DB, endpoint.id, 'v2', keyring, { + nowMilliseconds: () => Date.parse('2026-08-07T02:00:00.000Z'), + graceSeconds: 3600, + createSecret: () => secretB, + }) + + expect(rotated).toMatchObject({ + rawSecret: secretB, + generation: 2, + keyVersion: 'v2', + previousValidUntil: '2026-08-07T03:00:00.000Z', + }) + + await expect( + resolveEndpointSigningSecrets( + env.DB, + endpoint.id, + keyring, + Date.parse('2026-08-07T02:30:00.000Z'), + ), + ).resolves.toEqual([secretB, secretA]) + }) + + it('uses only the active secret after grace expires', async () => { + const endpoint = await seedEndpoint('expired') + + await provisionEndpointSigningSecret(env.DB, endpoint.id, 'v1', keyring, { + createSecret: () => secretA, + }) + + await rotateEndpointSigningSecret(env.DB, endpoint.id, 'v1', keyring, { + nowMilliseconds: () => Date.parse('2026-08-07T04:00:00.000Z'), + graceSeconds: 60, + createSecret: () => secretB, + }) + + await expect( + resolveEndpointSigningSecrets( + env.DB, + endpoint.id, + keyring, + Date.parse('2026-08-07T04:01:01.000Z'), + ), + ).resolves.toEqual([secretB]) + }) + + it('retains only the immediately previous generation', async () => { + const endpoint = await seedEndpoint('second') + + await provisionEndpointSigningSecret(env.DB, endpoint.id, 'v1', keyring, { + createSecret: () => secretA, + }) + + await rotateEndpointSigningSecret(env.DB, endpoint.id, 'v1', keyring, { + nowMilliseconds: () => Date.parse('2026-08-07T05:00:00.000Z'), + createSecret: () => secretB, + }) + + await rotateEndpointSigningSecret(env.DB, endpoint.id, 'v2', keyring, { + nowMilliseconds: () => Date.parse('2026-08-07T05:10:00.000Z'), + createSecret: () => secretC, + }) + + await expect( + resolveEndpointSigningSecrets( + env.DB, + endpoint.id, + keyring, + Date.parse('2026-08-07T05:20:00.000Z'), + ), + ).resolves.toEqual([secretC, secretB]) + + const rows = await env.DB.prepare( + `SELECT generation, state + FROM endpoint_signing_secrets + WHERE endpoint_id = ? + ORDER BY generation`, + ) + .bind(endpoint.id) + .all<{ + generation: number + state: string + }>() + + expect(rows.results).toEqual([ + { + generation: 2, + state: 'previous', + }, + { + generation: 3, + state: 'active', + }, + ]) + }) + + it('rejects an unbounded grace window', async () => { + const endpoint = await seedEndpoint('bounded') + + await provisionEndpointSigningSecret(env.DB, endpoint.id, 'v1', keyring, { + createSecret: () => secretA, + }) + + await expect( + rotateEndpointSigningSecret(env.DB, endpoint.id, 'v1', keyring, { + graceSeconds: 86_401, + createSecret: () => secretB, + }), + ).rejects.toThrow('Secret rotation grace must be an integer between 1 and 86400 seconds.') + }) +}) diff --git a/apps/console/src/webhook-signing.test.ts b/apps/console/src/webhook-signing.test.ts index 7744ffe..ae51e01 100644 --- a/apps/console/src/webhook-signing.test.ts +++ b/apps/console/src/webhook-signing.test.ts @@ -77,7 +77,7 @@ describe('webhook request construction', () => { deliveryId: 'dlv_01JTEST', endpointUrl: 'https://example.test/webhooks', event, - signingSecret: 'endpoint_secret', + signingSecrets: ['endpoint_secret'], timestampSeconds: 1785834000, }) diff --git a/apps/console/worker/lib/delivery-processor.ts b/apps/console/worker/lib/delivery-processor.ts index 9abf0f8..88c8429 100644 --- a/apps/console/worker/lib/delivery-processor.ts +++ b/apps/console/worker/lib/delivery-processor.ts @@ -11,7 +11,7 @@ import { buildWebhookRequest, createWebhookId } from './webhook-request.js' const REQUEUE_DELAY_SECONDS = 5 export interface DeliveryProcessorDependencies { - resolveSigningSecret(endpointId: string): Promise + resolveSigningSecrets(endpointId: string): Promise fetcher?: typeof fetch nowMilliseconds?: () => number @@ -89,7 +89,7 @@ export async function processDeliveryMessage( } } - const signingSecret = await dependencies.resolveSigningSecret(context.endpointId) + const signingSecrets = await dependencies.resolveSigningSecrets(context.endpointId) const requestStartedMilliseconds = nowMilliseconds() const requestStartedAt = toIso(requestStartedMilliseconds) @@ -128,7 +128,7 @@ export async function processDeliveryMessage( timestamp: context.eventCreatedAt, data: context.eventData, }, - signingSecret, + signingSecrets, timestampSeconds: Math.floor(requestStartedMilliseconds / 1000), }) diff --git a/apps/console/worker/lib/endpoint-secret-store.ts b/apps/console/worker/lib/endpoint-secret-store.ts index 98ebd21..ea444e3 100644 --- a/apps/console/worker/lib/endpoint-secret-store.ts +++ b/apps/console/worker/lib/endpoint-secret-store.ts @@ -136,3 +136,185 @@ export async function resolveEndpointSigningSecret( keyring, ) } + +export const DEFAULT_SECRET_ROTATION_GRACE_SECONDS = 60 * 60 +export const MAX_SECRET_ROTATION_GRACE_SECONDS = 24 * 60 * 60 + +interface StoredRotatingSecretRow { + generation: number + state: 'active' | 'previous' + key_version: string + iv_base64: string + ciphertext_base64: string + valid_until: string | null +} + +export interface RotateEndpointSecretDependencies { + nowMilliseconds?: () => number + graceSeconds?: number + createSecret?: () => string +} + +export interface RotatedEndpointSecret { + endpointId: string + rawSecret: string + generation: number + keyVersion: string + rotatedAt: string + previousValidUntil: string +} + +function validateGraceSeconds(value: number): void { + if (!Number.isInteger(value) || value < 1 || value > MAX_SECRET_ROTATION_GRACE_SECONDS) { + throw new TypeError( + `Secret rotation grace must be an integer between 1 and ${MAX_SECRET_ROTATION_GRACE_SECONDS} seconds.`, + ) + } +} + +export async function rotateEndpointSigningSecret( + database: RelayDatabase, + endpointId: string, + keyVersion: string, + keyring: EndpointSecretKeyring, + dependencies: RotateEndpointSecretDependencies = {}, +): Promise { + const active = await database + .prepare( + `SELECT generation + FROM endpoint_signing_secrets + WHERE endpoint_id = ? + AND state = 'active' + LIMIT 1`, + ) + .bind(endpointId) + .first<{ generation: number }>() + + if (!active) { + throw new Error('Endpoint does not have an active signing secret.') + } + + const graceSeconds = dependencies.graceSeconds ?? DEFAULT_SECRET_ROTATION_GRACE_SECONDS + + validateGraceSeconds(graceSeconds) + + const nowMilliseconds = dependencies.nowMilliseconds ?? Date.now + const rotatedMilliseconds = nowMilliseconds() + const rotatedAt = new Date(rotatedMilliseconds).toISOString() + const previousValidUntil = new Date(rotatedMilliseconds + graceSeconds * 1000).toISOString() + + const createSecret = dependencies.createSecret ?? createEndpointSigningSecret + + const rawSecret = createSecret() + const generation = active.generation + 1 + + const encrypted = await encryptEndpointSecret(rawSecret, endpointId, keyVersion, keyring) + + await database.batch([ + database + .prepare( + `DELETE FROM endpoint_signing_secrets + WHERE endpoint_id = ? + AND state = 'previous'`, + ) + .bind(endpointId), + + database + .prepare( + `UPDATE endpoint_signing_secrets + SET state = 'previous', + valid_until = ? + WHERE endpoint_id = ? + AND generation = ? + AND state = 'active'`, + ) + .bind(previousValidUntil, endpointId, active.generation), + + database + .prepare( + `INSERT INTO endpoint_signing_secrets ( + endpoint_id, + generation, + state, + key_version, + iv_base64, + ciphertext_base64, + valid_until, + created_at + ) + VALUES (?, ?, 'active', ?, ?, ?, NULL, ?)`, + ) + .bind( + endpointId, + generation, + encrypted.keyVersion, + encrypted.ivBase64, + encrypted.ciphertextBase64, + rotatedAt, + ), + ]) + + return { + endpointId, + rawSecret, + generation, + keyVersion, + rotatedAt, + previousValidUntil, + } +} + +export async function resolveEndpointSigningSecrets( + database: RelayDatabase, + endpointId: string, + keyring: EndpointSecretKeyring, + nowMilliseconds: number = Date.now(), +): Promise { + const now = new Date(nowMilliseconds).toISOString() + + const result = await database + .prepare( + `SELECT + generation, + state, + key_version, + iv_base64, + ciphertext_base64, + valid_until + FROM endpoint_signing_secrets + WHERE endpoint_id = ? + AND ( + state = 'active' + OR ( + state = 'previous' + AND valid_until > ? + ) + ) + ORDER BY + CASE state + WHEN 'active' THEN 0 + ELSE 1 + END, + generation DESC`, + ) + .bind(endpointId, now) + .all() + + if (result.results.length === 0) { + throw new Error('Endpoint does not have an active signing secret.') + } + + return Promise.all( + result.results.map((stored) => + decryptEndpointSecret( + { + keyVersion: stored.key_version, + ivBase64: stored.iv_base64, + ciphertextBase64: stored.ciphertext_base64, + }, + endpointId, + keyring, + ), + ), + ) +} diff --git a/apps/console/worker/lib/webhook-request.ts b/apps/console/worker/lib/webhook-request.ts index 9d09801..46ccf65 100644 --- a/apps/console/worker/lib/webhook-request.ts +++ b/apps/console/worker/lib/webhook-request.ts @@ -1,5 +1,5 @@ import type { JsonValue } from '@relay/contracts' -import { createWebhookHeaders, type WebhookSignatureInput } from './webhook-signing.js' +import { createWebhookHeadersForSecrets } from './webhook-signing.js' export interface OutboundWebhookEvent { id: string @@ -12,7 +12,7 @@ export interface BuildWebhookRequestInput { deliveryId: string endpointUrl: string event: OutboundWebhookEvent - signingSecret: string + signingSecrets: readonly string[] timestampSeconds: number } @@ -45,14 +45,12 @@ export async function buildWebhookRequest( const webhookId = createWebhookId(input.deliveryId) const rawBody = serializeWebhookBody(input.event) - const signatureInput: WebhookSignatureInput = { + const headers = await createWebhookHeadersForSecrets({ messageId: webhookId, timestamp: input.timestampSeconds, rawBody, - secret: input.signingSecret, - } - - const headers = await createWebhookHeaders(signatureInput) + secrets: input.signingSecrets, + }) return { webhookId, diff --git a/apps/console/worker/lib/webhook-signing.ts b/apps/console/worker/lib/webhook-signing.ts index ea23522..b15c2c6 100644 --- a/apps/console/worker/lib/webhook-signing.ts +++ b/apps/console/worker/lib/webhook-signing.ts @@ -67,3 +67,48 @@ export async function createWebhookHeaders(input: WebhookSignatureInput): Promis return headers } + +export interface WebhookMultiSignatureInput { + messageId: string + timestamp: number + rawBody: string + secrets: readonly string[] +} + +export async function createWebhookHeadersForSecrets( + input: WebhookMultiSignatureInput, +): Promise { + const [primarySecret, ...additionalSecrets] = input.secrets + + if (!primarySecret) { + throw new TypeError('At least one webhook signing secret is required.') + } + + const headers = await createWebhookHeaders({ + messageId: input.messageId, + timestamp: input.timestamp, + rawBody: input.rawBody, + secret: primarySecret, + }) + + const primarySignature = headers.get('webhook-signature') + + if (!primarySignature) { + throw new Error('Primary webhook signature was not generated.') + } + + const additionalSignatures = await Promise.all( + additionalSecrets.map((secret) => + createWebhookSignature({ + messageId: input.messageId, + timestamp: input.timestamp, + rawBody: input.rawBody, + secret, + }), + ), + ) + + headers.set('webhook-signature', [primarySignature, ...additionalSignatures].join(' ')) + + return headers +} diff --git a/apps/console/worker/queue-handler.ts b/apps/console/worker/queue-handler.ts index bb16d55..14d2861 100644 --- a/apps/console/worker/queue-handler.ts +++ b/apps/console/worker/queue-handler.ts @@ -1,7 +1,7 @@ import { DeliveryQueueMessageSchema } from '@relay/contracts' import type { RelayDatabase } from './lib/database.js' import { buildEndpointSecretKeyring } from './lib/endpoint-secret-keyring.js' -import { resolveEndpointSigningSecret } from './lib/endpoint-secret-store.js' +import { resolveEndpointSigningSecrets } from './lib/endpoint-secret-store.js' import { processDeliveryMessage, type DeliveryProcessorDependencies, @@ -48,8 +48,8 @@ export async function handleDeliveryQueue( try { const result = await processDelivery(env.DB, parsed.data, { - resolveSigningSecret: async (endpointId) => - resolveEndpointSigningSecret(env.DB, endpointId, buildEndpointSecretKeyring(env)), + resolveSigningSecrets: async (endpointId) => + resolveEndpointSigningSecrets(env.DB, endpointId, buildEndpointSecretKeyring(env)), }) if (result.action === 'ack') { From c2c144164e7eca29a6327d69b3a8056aa10086e6 Mon Sep 17 00:00:00 2001 From: Oniel Alejo Feliz <197416079+XonkelX@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:29:16 -0400 Subject: [PATCH 07/12] feat: block delivery to inactive endpoints --- .../src/delivery-endpoint-gate.d1.test.ts | 139 ++++++++++++++++++ .../worker/lib/delivery-endpoint-gate.ts | 37 +++++ apps/console/worker/lib/delivery-processor.ts | 17 ++- 3 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 apps/console/src/delivery-endpoint-gate.d1.test.ts create mode 100644 apps/console/worker/lib/delivery-endpoint-gate.ts diff --git a/apps/console/src/delivery-endpoint-gate.d1.test.ts b/apps/console/src/delivery-endpoint-gate.d1.test.ts new file mode 100644 index 0000000..21d83d6 --- /dev/null +++ b/apps/console/src/delivery-endpoint-gate.d1.test.ts @@ -0,0 +1,139 @@ +import { env } from 'cloudflare:workers' +import { describe, expect, it, vi } from 'vitest' +import { processDeliveryMessage } from '../worker/lib/delivery-processor.js' + +const now = '2026-08-07T03:30:00.000Z' +const nowMs = Date.parse(now) + +describe('D1 delivery endpoint execution gate', () => { + it.each([ + ['pending', null], + ['paused', null], + ['disabled', now], + ] as const)('cancels queued work when endpoint is %s', async (status, disabledAt) => { + const suffix = status + + await env.DB.batch([ + env.DB.prepare( + `INSERT INTO api_keys ( + id, name, key_prefix, secret_hash, + status, created_at + ) + VALUES (?, ?, ?, ?, 'active', ?)`, + ).bind( + `key_gate_${suffix}`, + `Gate ${suffix}`, + `rly_gate_${suffix}`, + (status === 'pending' ? 'a' : status === 'paused' ? 'b' : 'c').repeat(64), + now, + ), + + env.DB.prepare( + `INSERT INTO endpoints ( + id, name, url, status, + created_at, updated_at, + verified_at, disabled_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ).bind( + `ep_gate_${suffix}`, + `Gate ${suffix}`, + `https://gate-${suffix}.example.test/webhook`, + status, + now, + now, + status === 'pending' ? null : now, + disabledAt, + ), + + env.DB.prepare( + `INSERT INTO events ( + id, api_key_id, idempotency_key, + event_type, payload_json, + payload_sha256, payload_bytes, + created_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ).bind( + `evt_gate_${suffix}`, + `key_gate_${suffix}`, + `gate-${suffix}`, + 'gate.test', + '{"safe":true}', + 'b'.repeat(64), + 13, + now, + ), + + env.DB.prepare( + `INSERT INTO deliveries ( + id, event_id, endpoint_id, + status, attempt_count, + next_attempt_at, + created_at, updated_at + ) + VALUES (?, ?, ?, 'queued', 0, ?, ?, ?)`, + ).bind(`dlv_gate_${suffix}`, `evt_gate_${suffix}`, `ep_gate_${suffix}`, now, now, now), + ]) + + const fetcher = vi.fn() + const resolveSigningSecrets = vi.fn() + + await expect( + processDeliveryMessage( + env.DB, + { + version: 1, + deliveryId: `dlv_gate_${suffix}`, + reason: 'initial', + }, + { + resolveSigningSecrets, + fetcher: fetcher as typeof fetch, + nowMilliseconds: () => nowMs, + createLeaseToken: () => `lease_gate_${suffix}`, + }, + ), + ).resolves.toEqual({ + action: 'ack', + reason: 'cancelled', + }) + + expect(resolveSigningSecrets).not.toHaveBeenCalled() + expect(fetcher).not.toHaveBeenCalled() + + const delivery = await env.DB.prepare( + `SELECT + status, + attempt_count, + lease_token, + last_error_class + FROM deliveries + WHERE id = ?`, + ) + .bind(`dlv_gate_${suffix}`) + .first<{ + status: string + attempt_count: number + lease_token: string | null + last_error_class: string | null + }>() + + expect(delivery).toEqual({ + status: 'cancelled', + attempt_count: 0, + lease_token: null, + last_error_class: 'endpoint_inactive', + }) + + const attempts = await env.DB.prepare( + `SELECT COUNT(*) AS count + FROM delivery_attempts + WHERE delivery_id = ?`, + ) + .bind(`dlv_gate_${suffix}`) + .first<{ count: number }>() + + expect(attempts?.count).toBe(0) + }) +}) diff --git a/apps/console/worker/lib/delivery-endpoint-gate.ts b/apps/console/worker/lib/delivery-endpoint-gate.ts new file mode 100644 index 0000000..d6680f2 --- /dev/null +++ b/apps/console/worker/lib/delivery-endpoint-gate.ts @@ -0,0 +1,37 @@ +import type { RelayDatabase } from './database.js' + +interface CancelledDeliveryRow { + id: string +} + +export async function cancelClaimedDeliveryIfEndpointInactive( + database: RelayDatabase, + deliveryId: string, + leaseToken: string, + cancelledAt: string, +): Promise { + const cancelled = await database + .prepare( + `UPDATE deliveries + SET status = 'cancelled', + next_attempt_at = ?, + lease_token = NULL, + lease_expires_at = NULL, + last_error_class = 'endpoint_inactive', + updated_at = ? + WHERE id = ? + AND status = 'leased' + AND lease_token = ? + AND EXISTS ( + SELECT 1 + FROM endpoints + WHERE endpoints.id = deliveries.endpoint_id + AND endpoints.status != 'active' + ) + RETURNING id`, + ) + .bind(cancelledAt, cancelledAt, deliveryId, leaseToken) + .first() + + return cancelled !== null +} diff --git a/apps/console/worker/lib/delivery-processor.ts b/apps/console/worker/lib/delivery-processor.ts index 88c8429..7597e7d 100644 --- a/apps/console/worker/lib/delivery-processor.ts +++ b/apps/console/worker/lib/delivery-processor.ts @@ -2,6 +2,7 @@ import type { AttemptOutcome, DeliveryQueueMessage } from '@relay/contracts' import { completeDeliveryAttempt, startDeliveryAttempt } from './delivery-attempt.js' import { claimDelivery, type ClaimDeliveryResult } from './delivery-claim.js' import { loadDeliveryContext } from './delivery-context.js' +import { cancelClaimedDeliveryIfEndpointInactive } from './delivery-endpoint-gate.js' import { calculateBackoffDelay, decideHttpRetry } from './delivery-policy.js' import type { RelayDatabase } from './database.js' import type { RelayIdPrefix } from './ids.js' @@ -26,7 +27,7 @@ export interface DeliveryProcessorDependencies { export type ProcessDeliveryResult = | { action: 'ack' - reason: 'completed' | 'missing' | 'terminal' + reason: 'completed' | 'missing' | 'terminal' | 'cancelled' outcome?: AttemptOutcome } | { @@ -79,6 +80,20 @@ export async function processDeliveryMessage( return handleClaimFailure(claim) } + const cancelledForInactiveEndpoint = await cancelClaimedDeliveryIfEndpointInactive( + database, + claim.value.id, + claim.value.leaseToken, + claimedAt, + ) + + if (cancelledForInactiveEndpoint) { + return { + action: 'ack', + reason: 'cancelled', + } + } + const context = await loadDeliveryContext(database, claim.value.id) if (!context) { From eafbd92d87b9405c0490b116ff9130047ba7fd7e Mon Sep 17 00:00:00 2001 From: Oniel Alejo Feliz <197416079+XonkelX@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:53:18 -0400 Subject: [PATCH 08/12] feat: require reverification after endpoint URL changes --- .../src/endpoint-url-update.d1.test.ts | 198 ++++++++++++++++++ .../worker/lib/endpoint-persistence.ts | 187 ++++++++++++++++- 2 files changed, 383 insertions(+), 2 deletions(-) create mode 100644 apps/console/src/endpoint-url-update.d1.test.ts diff --git a/apps/console/src/endpoint-url-update.d1.test.ts b/apps/console/src/endpoint-url-update.d1.test.ts new file mode 100644 index 0000000..363dd37 --- /dev/null +++ b/apps/console/src/endpoint-url-update.d1.test.ts @@ -0,0 +1,198 @@ +import { env } from 'cloudflare:workers' +import { describe, expect, it } from 'vitest' +import { createEndpoint, updateEndpointUrl } from '../worker/lib/endpoint-persistence.js' +import { TEST_ENDPOINT_CRYPTO_DEPENDENCIES } from './test-endpoint-secret.js' + +const now = '2026-08-07T04:00:00.000Z' +const updatedAt = '2026-08-07T04:15:00.000Z' + +describe('D1 endpoint URL updates', () => { + it('requires re-verification and cancels scheduled work', async () => { + const endpoint = await createEndpoint( + env.DB, + { + name: 'URL change endpoint', + url: 'https://old-target.example.test/webhook', + eventTypes: [], + }, + { + ...TEST_ENDPOINT_CRYPTO_DEPENDENCIES, + now: () => now, + createId: (prefix) => `${prefix}_url_change`, + }, + ) + + await env.DB.batch([ + env.DB.prepare( + `UPDATE endpoints + SET status = 'active', + verified_at = ?, + verification_challenge_hash = ?, + verification_expires_at = ?, + verification_attempted_at = ? + WHERE id = ?`, + ).bind(now, 'd'.repeat(64), '2026-08-07T04:05:00.000Z', now, endpoint.id), + + env.DB.prepare( + `INSERT INTO api_keys ( + id, + name, + key_prefix, + secret_hash, + status, + created_at + ) + VALUES (?, ?, ?, ?, 'active', ?)`, + ).bind('key_url_change', 'URL change test', 'rly_urlchange', 'e'.repeat(64), now), + + ...[ + ['queued', 0], + ['retrying', 1], + ].flatMap(([status, attemptCount]) => { + const suffix = String(status) + + return [ + env.DB.prepare( + `INSERT INTO events ( + id, + api_key_id, + idempotency_key, + event_type, + payload_json, + payload_sha256, + payload_bytes, + created_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ).bind( + `evt_url_${suffix}`, + 'key_url_change', + `url-change-${suffix}`, + 'endpoint.url.changed', + '{"safe":true}', + suffix === 'queued' ? 'f'.repeat(64) : 'a'.repeat(64), + 13, + now, + ), + + env.DB.prepare( + `INSERT INTO deliveries ( + id, + event_id, + endpoint_id, + status, + attempt_count, + next_attempt_at, + created_at, + updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ).bind( + `dlv_url_${suffix}`, + `evt_url_${suffix}`, + endpoint.id, + status, + attemptCount, + '2026-08-07T05:00:00.000Z', + now, + now, + ), + + env.DB.prepare( + `INSERT INTO delivery_outbox ( + id, + delivery_id, + available_at, + published_at, + publish_attempts, + reason, + created_at + ) + VALUES (?, ?, ?, NULL, 0, ?, ?)`, + ).bind( + `out_url_${suffix}`, + `dlv_url_${suffix}`, + '2026-08-07T05:00:00.000Z', + status === 'queued' ? 'initial' : 'retry', + now, + ), + ] + }), + ]) + + await expect( + updateEndpointUrl(env.DB, endpoint.id, 'https://NEW-target.example.test/webhook', { + now: () => updatedAt, + createId: () => 'aud_url_change_updated', + }), + ).resolves.toEqual({ + updated: true, + url: 'https://new-target.example.test/webhook', + status: 'pending', + }) + + const storedEndpoint = await env.DB.prepare( + `SELECT + url, + status, + verified_at, + verification_challenge_hash, + verification_expires_at, + verification_attempted_at + FROM endpoints + WHERE id = ?`, + ) + .bind(endpoint.id) + .first<{ + url: string + status: string + verified_at: string | null + verification_challenge_hash: string | null + verification_expires_at: string | null + verification_attempted_at: string | null + }>() + + expect(storedEndpoint).toEqual({ + url: 'https://new-target.example.test/webhook', + status: 'pending', + verified_at: null, + verification_challenge_hash: null, + verification_expires_at: null, + verification_attempted_at: null, + }) + + const deliveries = await env.DB.prepare( + `SELECT status, last_error_class + FROM deliveries + WHERE endpoint_id = ? + ORDER BY id`, + ) + .bind(endpoint.id) + .all<{ + status: string + last_error_class: string | null + }>() + + expect(deliveries.results).toEqual([ + { + status: 'cancelled', + last_error_class: 'endpoint_url_changed', + }, + { + status: 'cancelled', + last_error_class: 'endpoint_url_changed', + }, + ]) + + const pendingOutbox = await env.DB.prepare( + `SELECT COUNT(*) AS count + FROM delivery_outbox + WHERE delivery_id IN ( + 'dlv_url_queued', + 'dlv_url_retrying' + )`, + ).first<{ count: number }>() + + expect(pendingOutbox?.count).toBe(0) + }) +}) diff --git a/apps/console/worker/lib/endpoint-persistence.ts b/apps/console/worker/lib/endpoint-persistence.ts index 5c5ed0e..0bae0ff 100644 --- a/apps/console/worker/lib/endpoint-persistence.ts +++ b/apps/console/worker/lib/endpoint-persistence.ts @@ -1,4 +1,9 @@ import { canonicalizeJson } from './canonical-json.js' +import { + createEndpointSigningSecret, + encryptEndpointSecret, + type EndpointSecretKeyring, +} from './endpoint-secret-crypto.js' import { normalizeEndpointUrl } from './endpoint-url-policy.js' import type { RelayDatabase, RelayStatement } from './database.js' import type { RelayIdPrefix } from './ids.js' @@ -15,6 +20,7 @@ export interface PersistedEndpoint { name: string url: string status: 'pending' + signingSecret: string eventTypes: string[] createdAt: string } @@ -24,6 +30,12 @@ export interface EndpointPersistenceDependencies { createId?: (prefix: RelayIdPrefix) => string } +export interface CreateEndpointDependencies extends EndpointPersistenceDependencies { + endpointSecretKeyVersion: string + endpointSecretKeyring: EndpointSecretKeyring + createSigningSecret?: () => string +} + function normalizeName(name: string): string { const normalized = name.trim() @@ -53,7 +65,7 @@ function normalizeEventTypes(eventTypes: readonly string[]): string[] { export async function createEndpoint( database: RelayDatabase, input: CreateEndpointInput, - dependencies: EndpointPersistenceDependencies = {}, + dependencies: CreateEndpointDependencies, ): Promise { const name = normalizeName(input.name) const url = normalizeEndpointUrl(input.url) @@ -62,6 +74,14 @@ export async function createEndpoint( const createId = dependencies.createId ?? createPrefixedId const createdAt = now() const endpointId = createId('ep') + const createSigningSecret = dependencies.createSigningSecret ?? createEndpointSigningSecret + const signingSecret = createSigningSecret() + const encryptedSigningSecret = await encryptEndpointSecret( + signingSecret, + endpointId, + dependencies.endpointSecretKeyVersion, + dependencies.endpointSecretKeyring, + ) const statements: RelayStatement[] = [] statements.push( @@ -80,6 +100,30 @@ export async function createEndpoint( .bind(endpointId, name, url, createdAt, createdAt), ) + statements.push( + database + .prepare( + `INSERT INTO endpoint_signing_secrets ( + endpoint_id, + generation, + state, + key_version, + iv_base64, + ciphertext_base64, + valid_until, + created_at + ) + VALUES (?, 1, 'active', ?, ?, ?, NULL, ?)`, + ) + .bind( + endpointId, + encryptedSigningSecret.keyVersion, + encryptedSigningSecret.ivBase64, + encryptedSigningSecret.ciphertextBase64, + createdAt, + ), + ) + for (const eventType of eventTypes) { statements.push( database @@ -119,6 +163,7 @@ export async function createEndpoint( name, url, status: 'pending', + signingSecret, eventTypes, createdAt, } @@ -160,7 +205,6 @@ export async function replaceEndpointSubscriptions( ) .bind(endpointId), ] - for (const eventType of eventTypes) { statements.push( database @@ -208,3 +252,142 @@ export async function replaceEndpointSubscriptions( eventTypes, } } + +export type UpdateEndpointUrlResult = + | { + updated: true + url: string + status: 'pending' + } + | { + updated: false + reason: 'missing' | 'unchanged' + url: string + } + +export async function updateEndpointUrl( + database: RelayDatabase, + endpointId: string, + urlInput: string, + dependencies: EndpointPersistenceDependencies = {}, +): Promise { + const url = normalizeEndpointUrl(urlInput) + + const endpoint = await database + .prepare( + `SELECT url + FROM endpoints + WHERE id = ? + LIMIT 1`, + ) + .bind(endpointId) + .first<{ url: string }>() + + if (!endpoint) { + return { + updated: false, + reason: 'missing', + url, + } + } + + if (endpoint.url === url) { + return { + updated: false, + reason: 'unchanged', + url, + } + } + + const now = dependencies.now ?? (() => new Date().toISOString()) + const createId = dependencies.createId ?? createPrefixedId + const updatedAt = now() + + await database.batch([ + database + .prepare( + `UPDATE endpoints + SET url = ?, + status = 'pending', + verified_at = NULL, + disabled_at = NULL, + verification_challenge_hash = NULL, + verification_expires_at = NULL, + verification_attempted_at = NULL, + updated_at = ? + WHERE id = ?`, + ) + .bind(url, updatedAt, endpointId), + + database + .prepare( + `DELETE FROM delivery_outbox + WHERE published_at IS NULL + AND delivery_id IN ( + SELECT id + FROM deliveries + WHERE endpoint_id = ? + AND status IN ( + 'queued', + 'retrying', + 'leased' + ) + )`, + ) + .bind(endpointId), + + database + .prepare( + `UPDATE deliveries + SET status = 'cancelled', + lease_token = NULL, + lease_expires_at = NULL, + last_error_class = 'endpoint_url_changed', + updated_at = ? + WHERE endpoint_id = ? + AND status IN ( + 'queued', + 'retrying', + 'leased' + )`, + ) + .bind(updatedAt, endpointId), + + database + .prepare( + `INSERT INTO audit_log ( + id, + actor_type, + action, + target_type, + target_id, + metadata_json, + created_at + ) + VALUES ( + ?, + 'owner', + 'endpoint.url.updated', + 'endpoint', + ?, + ?, + ? + )`, + ) + .bind( + createId('aud'), + endpointId, + canonicalizeJson({ + previousUrl: endpoint.url, + url, + }), + updatedAt, + ), + ]) + + return { + updated: true, + url, + status: 'pending', + } +} From 47e12ee7607e42a809d17de3288c1b5c41b5a0b1 Mon Sep 17 00:00:00 2001 From: Oniel Alejo Feliz <197416079+XonkelX@users.noreply.github.com> Date: Fri, 7 Aug 2026 00:15:38 -0400 Subject: [PATCH 09/12] feat: harden owner sessions --- apps/console/src/owner-session-http.test.ts | 71 +++++++ .../src/owner-session-route.d1.test.ts | 97 ++++++++++ apps/console/src/owner-session.d1.test.ts | 2 +- apps/console/worker/index.ts | 2 + apps/console/worker/lib/owner-session-http.ts | 177 ++++++++++++++++++ apps/console/worker/lib/owner-session.ts | 6 +- .../worker/middleware/require-api-key.ts | 4 + .../middleware/require-owner-session.ts | 117 ++++++++++++ apps/console/worker/routes/owner.ts | 27 +++ 9 files changed, 499 insertions(+), 4 deletions(-) create mode 100644 apps/console/src/owner-session-http.test.ts create mode 100644 apps/console/src/owner-session-route.d1.test.ts create mode 100644 apps/console/worker/lib/owner-session-http.ts create mode 100644 apps/console/worker/middleware/require-owner-session.ts create mode 100644 apps/console/worker/routes/owner.ts diff --git a/apps/console/src/owner-session-http.test.ts b/apps/console/src/owner-session-http.test.ts new file mode 100644 index 0000000..263074c --- /dev/null +++ b/apps/console/src/owner-session-http.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { + buildExpiredOwnerCookies, + buildOwnerCsrfCookie, + buildOwnerSessionCookie, + createOwnerCsrfToken, + createSignedOwnerSessionCookieValue, + readOwnerCsrfCookie, + readOwnerSessionCookie, + verifyOwnerCsrfToken, + verifySignedOwnerSessionCookieValue, +} from '../worker/lib/owner-session-http.js' + +const signingKey = btoa('s'.repeat(32)) +const rawToken = `rly_owner_${'a'.repeat(64)}` + +describe('owner session HTTP security', () => { + it('signs and verifies owner session cookie values', async () => { + const value = await createSignedOwnerSessionCookieValue(rawToken, signingKey) + + await expect(verifySignedOwnerSessionCookieValue(value, signingKey)).resolves.toBe(rawToken) + + await expect( + verifySignedOwnerSessionCookieValue(`${value}tampered`, signingKey), + ).resolves.toBeNull() + }) + + it('derives and validates double-submit CSRF tokens', async () => { + const csrf = await createOwnerCsrfToken(rawToken, signingKey) + + await expect(verifyOwnerCsrfToken(rawToken, csrf, csrf, signingKey)).resolves.toBe(true) + + await expect(verifyOwnerCsrfToken(rawToken, 'wrong', csrf, signingKey)).resolves.toBe(false) + + await expect(verifyOwnerCsrfToken(rawToken, csrf, null, signingKey)).resolves.toBe(false) + }) + + it('uses hardened Strict cookies', () => { + const sessionCookie = buildOwnerSessionCookie('signed', 3600) + + expect(sessionCookie).toContain('HttpOnly') + expect(sessionCookie).toContain('Secure') + expect(sessionCookie).toContain('SameSite=Strict') + expect(sessionCookie).toContain('Path=/') + + const csrfCookie = buildOwnerCsrfCookie('csrf', 3600) + + expect(csrfCookie).not.toContain('HttpOnly') + expect(csrfCookie).toContain('Secure') + expect(csrfCookie).toContain('SameSite=Strict') + }) + + it('reads owner cookies and creates deletion cookies', () => { + const header = '__Host-relay_owner=signed; other=x; __Host-relay_csrf=csrf' + + expect(readOwnerSessionCookie(header)).toBe('signed') + expect(readOwnerCsrfCookie(header)).toBe('csrf') + + const expired = buildExpiredOwnerCookies() + + expect(expired).toHaveLength(2) + expect(expired[0]).toContain('Max-Age=0') + expect(expired[1]).toContain('Max-Age=0') + }) + + it('rejects weak signing keys', async () => { + await expect(createSignedOwnerSessionCookieValue(rawToken, btoa('short'))).rejects.toThrow( + 'Owner session signing key must contain at least 32 bytes.', + ) + }) +}) diff --git a/apps/console/src/owner-session-route.d1.test.ts b/apps/console/src/owner-session-route.d1.test.ts new file mode 100644 index 0000000..951e604 --- /dev/null +++ b/apps/console/src/owner-session-route.d1.test.ts @@ -0,0 +1,97 @@ +import { env } from 'cloudflare:workers' +import { describe, expect, it, vi } from 'vitest' +import app from '../worker/index.js' +import { + createOwnerCsrfToken, + createSignedOwnerSessionCookieValue, +} from '../worker/lib/owner-session-http.js' +import { authenticateOwnerSession, createOwnerSession } from '../worker/lib/owner-session.js' + +const signingKey = btoa('o'.repeat(32)) + +async function createSession(suffix: string, tokenCharacter: string) { + const rawToken = `rly_owner_${tokenCharacter.repeat(64)}` + + const created = await createOwnerSession(env.DB, 3600, { + createId: () => `ses_http_${suffix}`, + createToken: () => rawToken, + }) + + const signedCookie = await createSignedOwnerSessionCookieValue(rawToken, signingKey) + + const csrf = await createOwnerCsrfToken(rawToken, signingKey) + + return { + ...created, + rawToken, + signedCookie, + csrf, + } +} + +function requestLogout(cookie: string, csrf?: string) { + const headers = new Headers({ + Cookie: cookie, + }) + + if (csrf) { + headers.set('X-Relay-CSRF', csrf) + } + + return app.request( + '/api/owner/logout', + { + method: 'POST', + headers, + }, + { + DB: env.DB, + DELIVERY_QUEUE: { + send: vi.fn().mockResolvedValue(undefined), + }, + OWNER_SESSION_SIGNING_KEY: signingKey, + }, + ) +} + +describe('POST /api/owner/logout', () => { + it('revokes the session and expires owner cookies', async () => { + const session = await createSession('logout', 'a') + + const response = await requestLogout( + `__Host-relay_owner=${session.signedCookie}; __Host-relay_csrf=${session.csrf}`, + session.csrf, + ) + + expect(response.status).toBe(204) + expect(response.headers.get('set-cookie')).toContain('Max-Age=0') + expect(response.headers.get('set-cookie')).toContain('SameSite=Strict') + + await expect(authenticateOwnerSession(env.DB, session.rawToken)).resolves.toBeNull() + }) + + it('rejects state-changing owner requests without CSRF', async () => { + const session = await createSession('csrf', 'b') + + const response = await requestLogout( + `__Host-relay_owner=${session.signedCookie}; __Host-relay_csrf=${session.csrf}`, + ) + + expect(response.status).toBe(403) + + await expect(authenticateOwnerSession(env.DB, session.rawToken)).resolves.not.toBeNull() + }) + + it('rejects tampered signed session cookies', async () => { + const session = await createSession('tampered', 'c') + + const response = await requestLogout( + `__Host-relay_owner=${session.signedCookie}x; __Host-relay_csrf=${session.csrf}`, + session.csrf, + ) + + expect(response.status).toBe(401) + + await expect(authenticateOwnerSession(env.DB, session.rawToken)).resolves.not.toBeNull() + }) +}) diff --git a/apps/console/src/owner-session.d1.test.ts b/apps/console/src/owner-session.d1.test.ts index c3a710d..0a497ed 100644 --- a/apps/console/src/owner-session.d1.test.ts +++ b/apps/console/src/owner-session.d1.test.ts @@ -70,7 +70,7 @@ describe('D1 owner sessions', () => { it('rejects invalid session TTL values', async () => { await expect(createOwnerSession(env.DB, 0)).rejects.toThrow( - 'Session TTL must be an integer between 1 second and 30 days.', + 'Session TTL must be an integer between 1 second and 8 hours.', ) }) }) diff --git a/apps/console/worker/index.ts b/apps/console/worker/index.ts index e48d6be..f5bbe3b 100644 --- a/apps/console/worker/index.ts +++ b/apps/console/worker/index.ts @@ -3,6 +3,7 @@ import { requireApiKey, type RelayWorkerEnvironment } from './middleware/require import { publishPendingOutbox } from './lib/outbox-publisher.js' import { handleDeliveryQueue } from './queue-handler.js' import { eventsRoute } from './routes/events.js' +import { ownerRoute } from './routes/owner.js' const app = new Hono() @@ -15,6 +16,7 @@ app.get('/api/health', (context) => app.use('/v1/*', requireApiKey) app.route('/v1/events', eventsRoute) +app.route('/api/owner', ownerRoute) app.notFound((context) => context.json( diff --git a/apps/console/worker/lib/owner-session-http.ts b/apps/console/worker/lib/owner-session-http.ts new file mode 100644 index 0000000..dd1ad5a --- /dev/null +++ b/apps/console/worker/lib/owner-session-http.ts @@ -0,0 +1,177 @@ +const SESSION_COOKIE_NAME = '__Host-relay_owner' +const CSRF_COOKIE_NAME = '__Host-relay_csrf' + +const COOKIE_VERSION = 'v1' +const MIN_SIGNING_KEY_BYTES = 32 + +const encoder = new TextEncoder() + +function bytesToBase64Url(bytes: Uint8Array): string { + let binary = '' + + for (const byte of bytes) { + binary += String.fromCharCode(byte) + } + + return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/u, '') +} + +function decodeSigningKey(signingKeyBase64: string): Uint8Array { + let binary: string + + try { + binary = atob(signingKeyBase64) + } catch { + throw new TypeError('Owner session signing key must be valid base64.') + } + + const bytes = new Uint8Array(binary.length) + + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index) + } + + if (bytes.byteLength < MIN_SIGNING_KEY_BYTES) { + throw new TypeError('Owner session signing key must contain at least 32 bytes.') + } + + return bytes +} + +async function createHmac(value: string, signingKeyBase64: string): Promise { + const keyBytes = decodeSigningKey(signingKeyBase64) + + const key = await crypto.subtle.importKey( + 'raw', + keyBytes, + { + name: 'HMAC', + hash: 'SHA-256', + }, + false, + ['sign'], + ) + + const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(value)) + + return bytesToBase64Url(new Uint8Array(signature)) +} + +function constantTimeEqual(left: string, right: string): boolean { + const maximumLength = Math.max(left.length, right.length) + + let difference = left.length ^ right.length + + for (let index = 0; index < maximumLength; index += 1) { + difference |= (left.charCodeAt(index) || 0) ^ (right.charCodeAt(index) || 0) + } + + return difference === 0 +} + +function parseCookies(cookieHeader: string | null): ReadonlyMap { + const cookies = new Map() + + if (!cookieHeader) { + return cookies + } + + for (const pair of cookieHeader.split(';')) { + const separator = pair.indexOf('=') + + if (separator < 1) { + continue + } + + const name = pair.slice(0, separator).trim() + const value = pair.slice(separator + 1).trim() + + cookies.set(name, value) + } + + return cookies +} + +export async function createSignedOwnerSessionCookieValue( + rawToken: string, + signingKeyBase64: string, +): Promise { + const signature = await createHmac( + `relay:owner-session:${COOKIE_VERSION}:${rawToken}`, + signingKeyBase64, + ) + + return `${COOKIE_VERSION}.${rawToken}.${signature}` +} + +export async function verifySignedOwnerSessionCookieValue( + cookieValue: string, + signingKeyBase64: string, +): Promise { + const firstSeparator = cookieValue.indexOf('.') + const lastSeparator = cookieValue.lastIndexOf('.') + + if (firstSeparator <= 0 || lastSeparator <= firstSeparator) { + return null + } + + const version = cookieValue.slice(0, firstSeparator) + const rawToken = cookieValue.slice(firstSeparator + 1, lastSeparator) + const signature = cookieValue.slice(lastSeparator + 1) + + if (version !== COOKIE_VERSION || !rawToken.startsWith('rly_owner_') || !signature) { + return null + } + + const expected = await createHmac( + `relay:owner-session:${COOKIE_VERSION}:${rawToken}`, + signingKeyBase64, + ) + + return constantTimeEqual(signature, expected) ? rawToken : null +} + +export async function createOwnerCsrfToken( + rawToken: string, + signingKeyBase64: string, +): Promise { + return createHmac(`relay:owner-csrf:${COOKIE_VERSION}:${rawToken}`, signingKeyBase64) +} + +export async function verifyOwnerCsrfToken( + rawToken: string, + submittedToken: string | null, + cookieToken: string | null, + signingKeyBase64: string, +): Promise { + if (!submittedToken || !cookieToken || !constantTimeEqual(submittedToken, cookieToken)) { + return false + } + + const expected = await createOwnerCsrfToken(rawToken, signingKeyBase64) + + return constantTimeEqual(submittedToken, expected) +} + +export function readOwnerSessionCookie(cookieHeader: string | null): string | null { + return parseCookies(cookieHeader).get(SESSION_COOKIE_NAME) ?? null +} + +export function readOwnerCsrfCookie(cookieHeader: string | null): string | null { + return parseCookies(cookieHeader).get(CSRF_COOKIE_NAME) ?? null +} + +export function buildOwnerSessionCookie(value: string, maxAgeSeconds: number): string { + return `${SESSION_COOKIE_NAME}=${value}; Path=/; Max-Age=${maxAgeSeconds}; HttpOnly; Secure; SameSite=Strict` +} + +export function buildOwnerCsrfCookie(value: string, maxAgeSeconds: number): string { + return `${CSRF_COOKIE_NAME}=${value}; Path=/; Max-Age=${maxAgeSeconds}; Secure; SameSite=Strict` +} + +export function buildExpiredOwnerCookies(): [string, string] { + return [ + `${SESSION_COOKIE_NAME}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Strict`, + `${CSRF_COOKIE_NAME}=; Path=/; Max-Age=0; Secure; SameSite=Strict`, + ] +} diff --git a/apps/console/worker/lib/owner-session.ts b/apps/console/worker/lib/owner-session.ts index 89216ff..f19e945 100644 --- a/apps/console/worker/lib/owner-session.ts +++ b/apps/console/worker/lib/owner-session.ts @@ -5,8 +5,8 @@ import { createPrefixedId } from './ids.js' const SESSION_NAMESPACE = 'rly_owner_' const SESSION_SECRET_BYTES = 32 -const DEFAULT_TTL_SECONDS = 8 * 60 * 60 -const MAX_TTL_SECONDS = 30 * 24 * 60 * 60 +const DEFAULT_TTL_SECONDS = 60 * 60 +const MAX_TTL_SECONDS = 8 * 60 * 60 interface OwnerSessionRow { id: string @@ -49,7 +49,7 @@ function createRawSessionToken(): string { function calculateExpiry(createdAt: string, ttlSeconds: number): string { if (!Number.isInteger(ttlSeconds) || ttlSeconds < 1 || ttlSeconds > MAX_TTL_SECONDS) { - throw new TypeError('Session TTL must be an integer between 1 second and 30 days.') + throw new TypeError('Session TTL must be an integer between 1 second and 8 hours.') } const createdTime = Date.parse(createdAt) diff --git a/apps/console/worker/middleware/require-api-key.ts b/apps/console/worker/middleware/require-api-key.ts index 4e71e3c..d799525 100644 --- a/apps/console/worker/middleware/require-api-key.ts +++ b/apps/console/worker/middleware/require-api-key.ts @@ -1,6 +1,7 @@ import { createMiddleware } from 'hono/factory' import { authenticateApiKey, type AuthenticatedApiKey } from '../lib/api-key-auth.js' import type { RelayDatabase } from '../lib/database.js' +import type { AuthenticatedOwnerSession } from '../lib/owner-session.js' import type { DeliveryQueueProducer } from '../lib/outbox-publisher.js' export interface RelayWorkerEnvironment { @@ -9,9 +10,12 @@ export interface RelayWorkerEnvironment { DELIVERY_QUEUE: DeliveryQueueProducer ENDPOINT_SECRET_MASTER_KEY_V1?: string ENDPOINT_SECRET_MASTER_KEY_V2?: string + OWNER_SESSION_SIGNING_KEY?: string } Variables: { apiKey: AuthenticatedApiKey + ownerSession: AuthenticatedOwnerSession + ownerSessionRawToken: string } } diff --git a/apps/console/worker/middleware/require-owner-session.ts b/apps/console/worker/middleware/require-owner-session.ts new file mode 100644 index 0000000..e679abb --- /dev/null +++ b/apps/console/worker/middleware/require-owner-session.ts @@ -0,0 +1,117 @@ +import { createMiddleware } from 'hono/factory' +import { + readOwnerCsrfCookie, + readOwnerSessionCookie, + verifyOwnerCsrfToken, + verifySignedOwnerSessionCookieValue, +} from '../lib/owner-session-http.js' +import { authenticateOwnerSession } from '../lib/owner-session.js' +import type { RelayWorkerEnvironment } from './require-api-key.js' + +const CSRF_HEADER_NAME = 'X-Relay-CSRF' + +function unauthorized() { + return { + error: { + code: 'UNAUTHORIZED', + message: 'A valid owner session is required.', + }, + } +} + +export const requireOwnerSession = createMiddleware( + async (context, next) => { + const signingKey = context.env.OWNER_SESSION_SIGNING_KEY + + if (!signingKey) { + console.error('Owner session signing key is not configured.') + + context.header('Cache-Control', 'no-store') + + return context.json( + { + error: { + code: 'INTERNAL_ERROR', + message: 'Owner authentication is unavailable.', + }, + }, + 500, + ) + } + + const cookieHeader = context.req.header('Cookie') ?? null + + const signedCookie = readOwnerSessionCookie(cookieHeader) + + if (!signedCookie) { + context.header('Cache-Control', 'no-store') + + return context.json(unauthorized(), 401) + } + + let rawToken: string | null + + try { + rawToken = await verifySignedOwnerSessionCookieValue(signedCookie, signingKey) + } catch (error) { + console.error('Owner session signing configuration is invalid.', error) + + context.header('Cache-Control', 'no-store') + + return context.json( + { + error: { + code: 'INTERNAL_ERROR', + message: 'Owner authentication is unavailable.', + }, + }, + 500, + ) + } + + if (!rawToken) { + context.header('Cache-Control', 'no-store') + + return context.json(unauthorized(), 401) + } + + const session = await authenticateOwnerSession(context.env.DB, rawToken) + + if (!session) { + context.header('Cache-Control', 'no-store') + + return context.json(unauthorized(), 401) + } + + const method = context.req.method.toUpperCase() + + if (method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS') { + const validCsrf = await verifyOwnerCsrfToken( + rawToken, + context.req.header(CSRF_HEADER_NAME) ?? null, + readOwnerCsrfCookie(cookieHeader), + signingKey, + ) + + if (!validCsrf) { + context.header('Cache-Control', 'no-store') + + return context.json( + { + error: { + code: 'CSRF_REJECTED', + message: 'A valid CSRF token is required.', + }, + }, + 403, + ) + } + } + + context.set('ownerSession', session) + context.set('ownerSessionRawToken', rawToken) + context.header('Cache-Control', 'no-store') + + await next() + }, +) diff --git a/apps/console/worker/routes/owner.ts b/apps/console/worker/routes/owner.ts new file mode 100644 index 0000000..c10fe3c --- /dev/null +++ b/apps/console/worker/routes/owner.ts @@ -0,0 +1,27 @@ +import { Hono } from 'hono' +import { buildExpiredOwnerCookies } from '../lib/owner-session-http.js' +import { revokeOwnerSession } from '../lib/owner-session.js' +import { requireOwnerSession } from '../middleware/require-owner-session.js' +import type { RelayWorkerEnvironment } from '../middleware/require-api-key.js' + +export const ownerRoute = new Hono() + +ownerRoute.use('*', requireOwnerSession) + +ownerRoute.post('/logout', async (context) => { + const rawToken = context.get('ownerSessionRawToken') + + await revokeOwnerSession(context.env.DB, rawToken) + + const headers = new Headers() + headers.set('Cache-Control', 'no-store') + + for (const cookie of buildExpiredOwnerCookies()) { + headers.append('Set-Cookie', cookie) + } + + return new Response(null, { + status: 204, + headers, + }) +}) From 359a9f4b1b1664f065928f429a6127cb77de8bec Mon Sep 17 00:00:00 2001 From: Oniel Alejo Feliz <197416079+XonkelX@users.noreply.github.com> Date: Fri, 7 Aug 2026 00:39:20 -0400 Subject: [PATCH 10/12] feat: enforce ingestion quotas and HTTP security --- apps/console/src/events-route.test.ts | 2 +- apps/console/src/ingest-event.test.ts | 7 +- apps/console/src/ingestion-quota.d1.test.ts | 141 ++++++++++++++++++ apps/console/src/security-http.test.ts | 68 +++++++++ apps/console/worker/index.ts | 21 +++ apps/console/worker/lib/ingest-event.ts | 48 ++++++ .../worker/middleware/security-http.ts | 75 ++++++++++ apps/console/worker/routes/events.ts | 17 +++ migrations/0009_phase_4_quotas.sql | 124 +++++++++++++++ 9 files changed, 500 insertions(+), 3 deletions(-) create mode 100644 apps/console/src/ingestion-quota.d1.test.ts create mode 100644 apps/console/src/security-http.test.ts create mode 100644 apps/console/worker/middleware/security-http.ts create mode 100644 migrations/0009_phase_4_quotas.sql diff --git a/apps/console/src/events-route.test.ts b/apps/console/src/events-route.test.ts index 5560c77..7fab032 100644 --- a/apps/console/src/events-route.test.ts +++ b/apps/console/src/events-route.test.ts @@ -201,7 +201,7 @@ describe('POST /v1/events', () => { deliveryCount: 2, }) expect(body.eventId).toMatch(/^evt_[a-f0-9]{32}$/) - expect(database.batched).toHaveLength(7) + expect(database.batched).toHaveLength(8) }) it('returns the original event for an idempotent retry', async () => { diff --git a/apps/console/src/ingest-event.test.ts b/apps/console/src/ingest-event.test.ts index 0794d72..6b8e5d9 100644 --- a/apps/console/src/ingest-event.test.ts +++ b/apps/console/src/ingest-event.test.ts @@ -158,7 +158,7 @@ describe('atomic event ingestion', () => { }, }) - expect(database.batched).toHaveLength(7) + expect(database.batched).toHaveLength(8) const queries = database.batched.map((statement) => statement.query.replace(/\s+/g, ' ')) @@ -166,6 +166,9 @@ describe('atomic event ingestion', () => { expect(queries.filter((query) => query.includes('INSERT INTO deliveries'))).toHaveLength(2) expect(queries.filter((query) => query.includes('INSERT INTO delivery_outbox'))).toHaveLength(2) expect(queries.filter((query) => query.includes('INSERT INTO daily_usage'))).toHaveLength(1) + expect( + queries.filter((query) => query.includes('INSERT INTO global_daily_usage')), + ).toHaveLength(1) expect(queries.filter((query) => query.includes('INSERT INTO audit_log'))).toHaveLength(1) const eventInsert = database.batched.find((statement) => @@ -253,7 +256,7 @@ describe('atomic event ingestion', () => { }, }) - expect(database.batched).toHaveLength(3) + expect(database.batched).toHaveLength(4) expect( database.batched.some((statement) => statement.query.includes('INSERT INTO deliveries')), ).toBe(false) diff --git a/apps/console/src/ingestion-quota.d1.test.ts b/apps/console/src/ingestion-quota.d1.test.ts new file mode 100644 index 0000000..4310b9d --- /dev/null +++ b/apps/console/src/ingestion-quota.d1.test.ts @@ -0,0 +1,141 @@ +import { env } from 'cloudflare:workers' +import { describe, expect, it } from 'vitest' +import { ingestEvent } from '../worker/lib/ingest-event.js' + +async function insertApiKey(id: string, prefix: string, hashCharacter: string) { + await env.DB.prepare( + `INSERT INTO api_keys ( + id, + name, + key_prefix, + secret_hash, + status, + created_at + ) + VALUES (?, ?, ?, ?, 'active', ?)`, + ) + .bind(id, id, prefix, hashCharacter.repeat(64), '2026-08-07T05:00:00.000Z') + .run() +} + +function request(idempotencyKey: string) { + return { + event: { + type: 'quota.test', + data: { + value: idempotencyKey, + }, + }, + idempotencyKey, + payloadJson: JSON.stringify({ + type: 'quota.test', + data: { + value: idempotencyKey, + }, + }), + payloadBytes: 64, + } as const +} + +describe('D1 ingestion quotas', () => { + it('fails closed at the per-key daily quota', async () => { + await env.DB.prepare( + `UPDATE quota_limits + SET per_key_daily_events = 1, + global_daily_events = 10 + WHERE id = 1`, + ).run() + + await insertApiKey('key_quota_per_key', 'rly_quota_key', 'a') + + const dependencies = { + now: () => '2026-08-07T05:00:00.000Z', + } + + await expect( + ingestEvent(env.DB, 'key_quota_per_key', request('quota-key-1'), dependencies), + ).resolves.toMatchObject({ + ok: true, + }) + + await expect( + ingestEvent(env.DB, 'key_quota_per_key', request('quota-key-2'), dependencies), + ).resolves.toEqual({ + ok: false, + reason: 'quota_exceeded', + scope: 'api_key', + }) + + const events = await env.DB.prepare( + `SELECT COUNT(*) AS count + FROM events + WHERE api_key_id = ?`, + ) + .bind('key_quota_per_key') + .first<{ count: number }>() + + expect(events?.count).toBe(1) + }) + + it('fails closed at the global daily quota', async () => { + await env.DB.prepare( + `UPDATE quota_limits + SET per_key_daily_events = 1, + global_daily_events = 1 + WHERE id = 1`, + ).run() + + await insertApiKey('key_quota_global_a', 'rly_quota_ga', 'b') + + await insertApiKey('key_quota_global_b', 'rly_quota_gb', 'c') + + const dependencies = { + now: () => '2026-08-08T05:00:00.000Z', + } + + await expect( + ingestEvent(env.DB, 'key_quota_global_a', request('quota-global-1'), dependencies), + ).resolves.toMatchObject({ + ok: true, + }) + + await expect( + ingestEvent(env.DB, 'key_quota_global_b', request('quota-global-2'), dependencies), + ).resolves.toEqual({ + ok: false, + reason: 'quota_exceeded', + scope: 'global', + }) + + const usage = await env.DB.prepare( + `SELECT accepted_event_count + FROM global_daily_usage + WHERE usage_date = ?`, + ) + .bind('2026-08-08') + .first<{ + accepted_event_count: number + }>() + + expect(usage?.accepted_event_count).toBe(1) + }) + + it('fails closed when quota configuration is missing', async () => { + await env.DB.prepare( + `DELETE FROM quota_limits + WHERE id = 1`, + ).run() + + await insertApiKey('key_quota_missing', 'rly_quota_missing', 'd') + + await expect( + ingestEvent(env.DB, 'key_quota_missing', request('quota-missing'), { + now: () => '2026-08-09T05:00:00.000Z', + }), + ).resolves.toEqual({ + ok: false, + reason: 'quota_exceeded', + scope: 'api_key', + }) + }) +}) diff --git a/apps/console/src/security-http.test.ts b/apps/console/src/security-http.test.ts new file mode 100644 index 0000000..783595e --- /dev/null +++ b/apps/console/src/security-http.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import app from '../worker/index.js' + +describe('Worker HTTP security policy', () => { + it('adds hardened response headers', async () => { + const response = await app.request('/api/health') + + expect(response.status).toBe(200) + + expect(response.headers.get('content-security-policy')).toContain("default-src 'self'") + + expect(response.headers.get('x-content-type-options')).toBe('nosniff') + + expect(response.headers.get('x-frame-options')).toBe('DENY') + + expect(response.headers.get('referrer-policy')).toBe('no-referrer') + + expect(response.headers.get('permissions-policy')).toContain('camera=()') + + expect(response.headers.get('cache-control')).toBe('no-store') + }) + + it('allows browser requests from the same origin', async () => { + const response = await app.request('/api/health', { + headers: { + Origin: 'http://localhost', + }, + }) + + expect(response.status).toBe(200) + }) + + it('rejects cross-origin browser API requests', async () => { + const response = await app.request('/api/health', { + headers: { + Origin: 'https://evil.example', + }, + }) + + expect(response.status).toBe(403) + + expect(await response.json()).toEqual({ + error: { + code: 'CROSS_ORIGIN_REJECTED', + message: 'Cross-origin API requests are not allowed.', + }, + }) + + expect(response.headers.get('access-control-allow-origin')).toBeNull() + }) + + it('rejects opaque Origin values', async () => { + const response = await app.request('/api/health', { + headers: { + Origin: 'null', + }, + }) + + expect(response.status).toBe(403) + }) + + it('still permits non-browser API clients without Origin', async () => { + const response = await app.request('/api/health') + + expect(response.status).toBe(200) + expect(response.headers.get('access-control-allow-origin')).toBeNull() + }) +}) diff --git a/apps/console/worker/index.ts b/apps/console/worker/index.ts index f5bbe3b..94b0aab 100644 --- a/apps/console/worker/index.ts +++ b/apps/console/worker/index.ts @@ -1,5 +1,6 @@ import { Hono } from 'hono' import { requireApiKey, type RelayWorkerEnvironment } from './middleware/require-api-key.js' +import { enforceSameOriginApi, securityHeaders } from './middleware/security-http.js' import { publishPendingOutbox } from './lib/outbox-publisher.js' import { handleDeliveryQueue } from './queue-handler.js' import { eventsRoute } from './routes/events.js' @@ -7,6 +8,10 @@ import { ownerRoute } from './routes/owner.js' const app = new Hono() +app.use('*', securityHeaders) +app.use('/api/*', enforceSameOriginApi) +app.use('/v1/*', enforceSameOriginApi) + app.get('/api/health', (context) => context.json({ status: 'ok', @@ -18,6 +23,22 @@ app.use('/v1/*', requireApiKey) app.route('/v1/events', eventsRoute) app.route('/api/owner', ownerRoute) +app.onError((error, context) => { + console.error('Unhandled Relay Worker error', error) + + context.header('Cache-Control', 'no-store') + + return context.json( + { + error: { + code: 'INTERNAL_ERROR', + message: 'The request could not be completed.', + }, + }, + 500, + ) +}) + app.notFound((context) => context.json( { diff --git a/apps/console/worker/lib/ingest-event.ts b/apps/console/worker/lib/ingest-event.ts index 92eafc9..8d61fe1 100644 --- a/apps/console/worker/lib/ingest-event.ts +++ b/apps/console/worker/lib/ingest-event.ts @@ -37,6 +37,11 @@ export type IngestEventResult = ok: false reason: 'idempotency_conflict' } + | { + ok: false + reason: 'quota_exceeded' + scope: 'api_key' | 'global' + } export interface IngestEventDependencies { now?: () => string @@ -226,6 +231,31 @@ export async function ingestEvent( .bind(apiKeyId, usageDate, endpointResult.results.length, request.payloadBytes, createdAt), ) + statements.push( + database + .prepare( + `INSERT INTO global_daily_usage ( + usage_date, + accepted_event_count, + generated_delivery_count, + payload_bytes, + updated_at + ) + VALUES (?, 1, ?, ?, ?) + ON CONFLICT (usage_date) + DO UPDATE SET + accepted_event_count = + accepted_event_count + 1, + generated_delivery_count = + generated_delivery_count + + excluded.generated_delivery_count, + payload_bytes = + payload_bytes + excluded.payload_bytes, + updated_at = excluded.updated_at`, + ) + .bind(usageDate, endpointResult.results.length, request.payloadBytes, createdAt), + ) + statements.push( database .prepare( @@ -256,6 +286,24 @@ export async function ingestEvent( try { await database.batch(statements) } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + + if (errorMessage.includes('quota_per_key_daily')) { + return { + ok: false, + reason: 'quota_exceeded', + scope: 'api_key', + } + } + + if (errorMessage.includes('quota_global_daily')) { + return { + ok: false, + reason: 'quota_exceeded', + scope: 'global', + } + } + const concurrentEvent = await findExistingEvent(database, apiKeyId, request.idempotencyKey) if (concurrentEvent) { diff --git a/apps/console/worker/middleware/security-http.ts b/apps/console/worker/middleware/security-http.ts new file mode 100644 index 0000000..ecaae48 --- /dev/null +++ b/apps/console/worker/middleware/security-http.ts @@ -0,0 +1,75 @@ +import { createMiddleware } from 'hono/factory' + +const CONTENT_SECURITY_POLICY = [ + "default-src 'self'", + "base-uri 'none'", + "frame-ancestors 'none'", + "object-src 'none'", + "form-action 'self'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data:", + "font-src 'self'", + "connect-src 'self'", +].join('; ') + +export const securityHeaders = createMiddleware(async (context, next) => { + context.header('Content-Security-Policy', CONTENT_SECURITY_POLICY) + context.header('X-Content-Type-Options', 'nosniff') + context.header('X-Frame-Options', 'DENY') + context.header('Referrer-Policy', 'no-referrer') + context.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=()') + context.header('Cross-Origin-Opener-Policy', 'same-origin') + context.header('Cross-Origin-Resource-Policy', 'same-origin') + + if (context.req.path.startsWith('/api/') || context.req.path.startsWith('/v1/')) { + context.header('Cache-Control', 'no-store') + } + + await next() +}) + +export const enforceSameOriginApi = createMiddleware(async (context, next) => { + const origin = context.req.header('Origin') + + context.header('Vary', 'Origin') + + // Non-browser API clients normally send no Origin + // header and remain supported. + if (!origin) { + await next() + return + } + + let submittedOrigin: string + + try { + submittedOrigin = new URL(origin).origin + } catch { + return context.json( + { + error: { + code: 'CROSS_ORIGIN_REJECTED', + message: 'Cross-origin API requests are not allowed.', + }, + }, + 403, + ) + } + + const requestOrigin = new URL(context.req.url).origin + + if (submittedOrigin !== requestOrigin) { + return context.json( + { + error: { + code: 'CROSS_ORIGIN_REJECTED', + message: 'Cross-origin API requests are not allowed.', + }, + }, + 403, + ) + } + + await next() +}) diff --git a/apps/console/worker/routes/events.ts b/apps/console/worker/routes/events.ts index 8723804..8b6275d 100644 --- a/apps/console/worker/routes/events.ts +++ b/apps/console/worker/routes/events.ts @@ -25,6 +25,23 @@ eventsRoute.post('/', async (context) => { const result = await ingestEvent(context.env.DB, context.get('apiKey').id, parsed.value) if (!result.ok) { + if (result.reason === 'quota_exceeded') { + context.header('Cache-Control', 'no-store') + + return context.json( + { + error: { + code: 'QUOTA_EXCEEDED', + message: + result.scope === 'api_key' + ? 'The API key daily event quota has been reached.' + : 'The Relay daily event quota has been reached.', + }, + }, + 429, + ) + } + return context.json( { error: { diff --git a/migrations/0009_phase_4_quotas.sql b/migrations/0009_phase_4_quotas.sql new file mode 100644 index 0000000..1f133bc --- /dev/null +++ b/migrations/0009_phase_4_quotas.sql @@ -0,0 +1,124 @@ +CREATE TABLE quota_limits ( + id INTEGER PRIMARY KEY + CHECK (id = 1), + + per_key_daily_events INTEGER NOT NULL + CHECK ( + per_key_daily_events BETWEEN 1 AND 1000000 + ), + + global_daily_events INTEGER NOT NULL + CHECK ( + global_daily_events BETWEEN 1 AND 10000000 + ), + + updated_at TEXT NOT NULL, + + CHECK ( + global_daily_events >= per_key_daily_events + ) +); + +INSERT INTO quota_limits ( + id, + per_key_daily_events, + global_daily_events, + updated_at +) +VALUES ( + 1, + 1000, + 5000, + CURRENT_TIMESTAMP +); + + +CREATE TABLE global_daily_usage ( + usage_date TEXT PRIMARY KEY + CHECK (length(usage_date) = 10), + + accepted_event_count INTEGER NOT NULL DEFAULT 0 + CHECK (accepted_event_count >= 0), + + generated_delivery_count INTEGER NOT NULL DEFAULT 0 + CHECK (generated_delivery_count >= 0), + + payload_bytes INTEGER NOT NULL DEFAULT 0 + CHECK (payload_bytes >= 0), + + updated_at TEXT NOT NULL +); + + +CREATE TRIGGER enforce_per_key_daily_quota_insert +BEFORE INSERT ON daily_usage +WHEN + NEW.accepted_event_count > + COALESCE( + ( + SELECT per_key_daily_events + FROM quota_limits + WHERE id = 1 + ), + 0 + ) +BEGIN + SELECT RAISE(ABORT, 'quota_per_key_daily'); +END; + + +CREATE TRIGGER enforce_per_key_daily_quota_update +BEFORE UPDATE OF accepted_event_count ON daily_usage +WHEN + NEW.accepted_event_count > + COALESCE( + ( + SELECT per_key_daily_events + FROM quota_limits + WHERE id = 1 + ), + 0 + ) +BEGIN + SELECT RAISE(ABORT, 'quota_per_key_daily'); +END; + + +CREATE TRIGGER enforce_global_daily_quota_insert +BEFORE INSERT ON global_daily_usage +WHEN + NEW.accepted_event_count > + COALESCE( + ( + SELECT global_daily_events + FROM quota_limits + WHERE id = 1 + ), + 0 + ) +BEGIN + SELECT RAISE(ABORT, 'quota_global_daily'); +END; + + +CREATE TRIGGER enforce_global_daily_quota_update +BEFORE UPDATE OF accepted_event_count ON global_daily_usage +WHEN + NEW.accepted_event_count > + COALESCE( + ( + SELECT global_daily_events + FROM quota_limits + WHERE id = 1 + ), + 0 + ) +BEGIN + SELECT RAISE(ABORT, 'quota_global_daily'); +END; + + +UPDATE relay_meta +SET value = '9', + updated_at = CURRENT_TIMESTAMP +WHERE key = 'schema_version'; From b3c815753df8887e317c3a3aa105331dd3d04302 Mon Sep 17 00:00:00 2001 From: Oniel Alejo Feliz <197416079+XonkelX@users.noreply.github.com> Date: Fri, 7 Aug 2026 00:43:20 -0400 Subject: [PATCH 11/12] test: align endpoint secret provisioning coverage --- .../src/endpoint-persistence.d1.test.ts | 3 + apps/console/src/endpoint-persistence.test.ts | 13 ++- .../endpoint-secret-dual-signature.d1.test.ts | 8 +- .../src/endpoint-secret-rotation.d1.test.ts | 20 +--- .../src/endpoint-secret-store.d1.test.ts | 69 +++++++------- .../src/endpoint-verification.d1.test.ts | 2 + apps/console/src/test-endpoint-secret.ts | 12 +++ .../worker/lib/endpoint-secret-store.ts | 91 ------------------- 8 files changed, 65 insertions(+), 153 deletions(-) create mode 100644 apps/console/src/test-endpoint-secret.ts diff --git a/apps/console/src/endpoint-persistence.d1.test.ts b/apps/console/src/endpoint-persistence.d1.test.ts index 003a827..1e078ff 100644 --- a/apps/console/src/endpoint-persistence.d1.test.ts +++ b/apps/console/src/endpoint-persistence.d1.test.ts @@ -1,6 +1,7 @@ import { env } from 'cloudflare:workers' import { describe, expect, it } from 'vitest' import { createEndpoint, replaceEndpointSubscriptions } from '../worker/lib/endpoint-persistence.js' +import { TEST_ENDPOINT_CRYPTO_DEPENDENCIES } from './test-endpoint-secret.js' describe('D1 endpoint persistence', () => { it('persists pending endpoints and atomically replaces subscriptions', async () => { @@ -12,6 +13,7 @@ describe('D1 endpoint persistence', () => { eventTypes: ['order.created', 'order.cancelled', 'order.created'], }, { + ...TEST_ENDPOINT_CRYPTO_DEPENDENCIES, now: () => '2026-08-05T12:15:00.000Z', createId: (prefix) => `${prefix}_endpoint_d1`, }, @@ -91,6 +93,7 @@ describe('D1 endpoint persistence', () => { eventTypes: ['order.created'], }, { + ...TEST_ENDPOINT_CRYPTO_DEPENDENCIES, now: () => '2026-08-05T12:30:00.000Z', createId: (prefix) => `${prefix}_blocked_d1`, }, diff --git a/apps/console/src/endpoint-persistence.test.ts b/apps/console/src/endpoint-persistence.test.ts index 3abb529..7817687 100644 --- a/apps/console/src/endpoint-persistence.test.ts +++ b/apps/console/src/endpoint-persistence.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest' import type { RelayDatabase, RelayStatement } from '../worker/lib/database.js' import { createEndpoint, replaceEndpointSubscriptions } from '../worker/lib/endpoint-persistence.js' +import { + TEST_ENDPOINT_CRYPTO_DEPENDENCIES, + TEST_ENDPOINT_SIGNING_SECRET, +} from './test-endpoint-secret.js' class FakeStatement implements RelayStatement { readonly query: string @@ -64,6 +68,8 @@ describe('endpoint persistence', () => { eventTypes: ['invoice.paid', 'invoice.created', 'invoice.paid'], }, { + ...TEST_ENDPOINT_CRYPTO_DEPENDENCIES, + createSigningSecret: () => TEST_ENDPOINT_SIGNING_SECRET, now: () => '2026-08-05T12:00:00.000Z', createId: (prefix) => `${prefix}_created`, }, @@ -74,11 +80,12 @@ describe('endpoint persistence', () => { name: 'Billing webhook', url: 'https://hooks.example.test/billing', status: 'pending', + signingSecret: TEST_ENDPOINT_SIGNING_SECRET, eventTypes: ['invoice.created', 'invoice.paid'], createdAt: '2026-08-05T12:00:00.000Z', }) - expect(database.batched).toHaveLength(4) + expect(database.batched).toHaveLength(5) }) it('replaces subscriptions in one batch', async () => { @@ -142,6 +149,8 @@ describe('endpoint persistence', () => { eventTypes: [''], }, ])('rejects invalid endpoint input %#', async (input) => { - await expect(createEndpoint(new FakeDatabase(), input)).rejects.toBeInstanceOf(TypeError) + await expect( + createEndpoint(new FakeDatabase(), input, TEST_ENDPOINT_CRYPTO_DEPENDENCIES), + ).rejects.toBeInstanceOf(TypeError) }) }) diff --git a/apps/console/src/endpoint-secret-dual-signature.d1.test.ts b/apps/console/src/endpoint-secret-dual-signature.d1.test.ts index 5efae14..c762836 100644 --- a/apps/console/src/endpoint-secret-dual-signature.d1.test.ts +++ b/apps/console/src/endpoint-secret-dual-signature.d1.test.ts @@ -2,7 +2,6 @@ import { env } from 'cloudflare:workers' import { describe, expect, it } from 'vitest' import { createEndpoint } from '../worker/lib/endpoint-persistence.js' import { - provisionEndpointSigningSecret, resolveEndpointSigningSecrets, rotateEndpointSigningSecret, } from '../worker/lib/endpoint-secret-store.js' @@ -26,14 +25,13 @@ describe('endpoint secret rotation delivery signatures', () => { eventTypes: [], }, { + endpointSecretKeyVersion: 'v1', + endpointSecretKeyring: keyring, + createSigningSecret: () => previousSecret, createId: (prefix) => `${prefix}_dual_signature`, }, ) - await provisionEndpointSigningSecret(env.DB, endpoint.id, 'v1', keyring, { - createSecret: () => previousSecret, - }) - await rotateEndpointSigningSecret(env.DB, endpoint.id, 'v1', keyring, { nowMilliseconds: () => Date.parse('2026-08-07T02:00:00.000Z'), graceSeconds: 3600, diff --git a/apps/console/src/endpoint-secret-rotation.d1.test.ts b/apps/console/src/endpoint-secret-rotation.d1.test.ts index 630d36f..a4b972a 100644 --- a/apps/console/src/endpoint-secret-rotation.d1.test.ts +++ b/apps/console/src/endpoint-secret-rotation.d1.test.ts @@ -2,7 +2,6 @@ import { env } from 'cloudflare:workers' import { describe, expect, it } from 'vitest' import { createEndpoint } from '../worker/lib/endpoint-persistence.js' import { - provisionEndpointSigningSecret, resolveEndpointSigningSecrets, rotateEndpointSigningSecret, } from '../worker/lib/endpoint-secret-store.js' @@ -25,6 +24,9 @@ async function seedEndpoint(suffix: string) { eventTypes: [], }, { + endpointSecretKeyVersion: 'v1', + endpointSecretKeyring: keyring, + createSigningSecret: () => secretA, createId: (prefix) => `${prefix}_rotation_${suffix}`, }, ) @@ -34,10 +36,6 @@ describe('D1 endpoint signing secret rotation', () => { it('returns active then previous during the grace window', async () => { const endpoint = await seedEndpoint('grace') - await provisionEndpointSigningSecret(env.DB, endpoint.id, 'v1', keyring, { - createSecret: () => secretA, - }) - const rotated = await rotateEndpointSigningSecret(env.DB, endpoint.id, 'v2', keyring, { nowMilliseconds: () => Date.parse('2026-08-07T02:00:00.000Z'), graceSeconds: 3600, @@ -64,10 +62,6 @@ describe('D1 endpoint signing secret rotation', () => { it('uses only the active secret after grace expires', async () => { const endpoint = await seedEndpoint('expired') - await provisionEndpointSigningSecret(env.DB, endpoint.id, 'v1', keyring, { - createSecret: () => secretA, - }) - await rotateEndpointSigningSecret(env.DB, endpoint.id, 'v1', keyring, { nowMilliseconds: () => Date.parse('2026-08-07T04:00:00.000Z'), graceSeconds: 60, @@ -87,10 +81,6 @@ describe('D1 endpoint signing secret rotation', () => { it('retains only the immediately previous generation', async () => { const endpoint = await seedEndpoint('second') - await provisionEndpointSigningSecret(env.DB, endpoint.id, 'v1', keyring, { - createSecret: () => secretA, - }) - await rotateEndpointSigningSecret(env.DB, endpoint.id, 'v1', keyring, { nowMilliseconds: () => Date.parse('2026-08-07T05:00:00.000Z'), createSecret: () => secretB, @@ -137,10 +127,6 @@ describe('D1 endpoint signing secret rotation', () => { it('rejects an unbounded grace window', async () => { const endpoint = await seedEndpoint('bounded') - await provisionEndpointSigningSecret(env.DB, endpoint.id, 'v1', keyring, { - createSecret: () => secretA, - }) - await expect( rotateEndpointSigningSecret(env.DB, endpoint.id, 'v1', keyring, { graceSeconds: 86_401, diff --git a/apps/console/src/endpoint-secret-store.d1.test.ts b/apps/console/src/endpoint-secret-store.d1.test.ts index b3e38cd..763201b 100644 --- a/apps/console/src/endpoint-secret-store.d1.test.ts +++ b/apps/console/src/endpoint-secret-store.d1.test.ts @@ -1,10 +1,7 @@ import { env } from 'cloudflare:workers' import { describe, expect, it } from 'vitest' import { createEndpoint } from '../worker/lib/endpoint-persistence.js' -import { - provisionEndpointSigningSecret, - resolveEndpointSigningSecret, -} from '../worker/lib/endpoint-secret-store.js' +import { resolveEndpointSigningSecret } from '../worker/lib/endpoint-secret-store.js' const rawSecret = `rly_whsec_${'a'.repeat(64)}` @@ -13,7 +10,7 @@ const keyring = { } describe('D1 endpoint signing secret storage', () => { - it('stores only encrypted secret material and resolves the plaintext', async () => { + it('creates an endpoint with exactly one encrypted active secret', async () => { const endpoint = await createEndpoint( env.DB, { @@ -22,25 +19,17 @@ describe('D1 endpoint signing secret storage', () => { eventTypes: [], }, { + endpointSecretKeyVersion: 'v1', + endpointSecretKeyring: keyring, + createSigningSecret: () => rawSecret, now: () => '2026-08-06T23:00:00.000Z', createId: (prefix) => `${prefix}_secret_store`, }, ) - await expect( - provisionEndpointSigningSecret(env.DB, endpoint.id, 'v1', keyring, { - now: () => '2026-08-06T23:01:00.000Z', - createSecret: () => rawSecret, - }), - ).resolves.toEqual({ - endpointId: endpoint.id, - rawSecret, - generation: 1, - keyVersion: 'v1', - createdAt: '2026-08-06T23:01:00.000Z', - }) + expect(endpoint.signingSecret).toBe(rawSecret) - const stored = await env.DB.prepare( + const rows = await env.DB.prepare( `SELECT generation, state, @@ -52,7 +41,7 @@ describe('D1 endpoint signing secret storage', () => { WHERE endpoint_id = ?`, ) .bind(endpoint.id) - .first<{ + .all<{ generation: number state: string key_version: string @@ -61,7 +50,11 @@ describe('D1 endpoint signing secret storage', () => { valid_until: string | null }>() - expect(stored).not.toBeNull() + expect(rows.results).toHaveLength(1) + + const stored = rows.results[0] + + expect(stored).toBeDefined() expect(stored?.generation).toBe(1) expect(stored?.state).toBe('active') expect(stored?.key_version).toBe('v1') @@ -74,36 +67,36 @@ describe('D1 endpoint signing secret storage', () => { ) }) - it('rejects a second active secret provision', async () => { + it('does not persist the plaintext signing secret', async () => { const endpoint = await createEndpoint( env.DB, { - name: 'Duplicate secret endpoint', - url: 'https://duplicate-secret.example.test/webhook', + name: 'Plaintext guard endpoint', + url: 'https://plaintext-guard.example.test/webhook', eventTypes: [], }, { - now: () => '2026-08-06T23:10:00.000Z', - createId: (prefix) => `${prefix}_secret_duplicate`, + endpointSecretKeyVersion: 'v1', + endpointSecretKeyring: keyring, + createSigningSecret: () => rawSecret, + createId: (prefix) => `${prefix}_plaintext_guard`, }, ) - await provisionEndpointSigningSecret(env.DB, endpoint.id, 'v1', keyring, { - createSecret: () => rawSecret, - }) + const stored = await env.DB.prepare( + `SELECT ciphertext_base64 + FROM endpoint_signing_secrets + WHERE endpoint_id = ?`, + ) + .bind(endpoint.id) + .first<{ + ciphertext_base64: string + }>() - await expect( - provisionEndpointSigningSecret(env.DB, endpoint.id, 'v1', keyring, { - createSecret: () => `rly_whsec_${'b'.repeat(64)}`, - }), - ).rejects.toThrow('Endpoint already has an active signing secret.') + expect(stored?.ciphertext_base64).not.toContain(rawSecret) }) - it('rejects secret operations for a missing endpoint', async () => { - await expect( - provisionEndpointSigningSecret(env.DB, 'ep_missing_secret', 'v1', keyring), - ).rejects.toThrow('Endpoint does not exist.') - + it('fails closed when no endpoint secret exists', async () => { await expect( resolveEndpointSigningSecret(env.DB, 'ep_missing_secret', keyring), ).rejects.toThrow('Endpoint does not have an active signing secret.') diff --git a/apps/console/src/endpoint-verification.d1.test.ts b/apps/console/src/endpoint-verification.d1.test.ts index 8a88551..5affd9c 100644 --- a/apps/console/src/endpoint-verification.d1.test.ts +++ b/apps/console/src/endpoint-verification.d1.test.ts @@ -2,6 +2,7 @@ import { env } from 'cloudflare:workers' import { describe, expect, it, vi } from 'vitest' import { createEndpoint } from '../worker/lib/endpoint-persistence.js' import { verifyEndpoint } from '../worker/lib/endpoint-verification.js' +import { TEST_ENDPOINT_CRYPTO_DEPENDENCIES } from './test-endpoint-secret.js' const now = Date.parse('2026-08-06T22:00:00.000Z') @@ -14,6 +15,7 @@ async function seedEndpoint(suffix: string): Promise { eventTypes: [], }, { + ...TEST_ENDPOINT_CRYPTO_DEPENDENCIES, now: () => '2026-08-06T21:55:00.000Z', createId: (prefix) => `${prefix}_verify_${suffix}`, }, diff --git a/apps/console/src/test-endpoint-secret.ts b/apps/console/src/test-endpoint-secret.ts new file mode 100644 index 0000000..d59d62f --- /dev/null +++ b/apps/console/src/test-endpoint-secret.ts @@ -0,0 +1,12 @@ +export const TEST_ENDPOINT_SECRET_KEYRING = { + v1: btoa('t'.repeat(32)), +} + +export const TEST_ENDPOINT_SECRET_KEY_VERSION = 'v1' + +export const TEST_ENDPOINT_SIGNING_SECRET = `rly_whsec_${'a'.repeat(64)}` + +export const TEST_ENDPOINT_CRYPTO_DEPENDENCIES = { + endpointSecretKeyVersion: TEST_ENDPOINT_SECRET_KEY_VERSION, + endpointSecretKeyring: TEST_ENDPOINT_SECRET_KEYRING, +} diff --git a/apps/console/worker/lib/endpoint-secret-store.ts b/apps/console/worker/lib/endpoint-secret-store.ts index ea444e3..f5712dc 100644 --- a/apps/console/worker/lib/endpoint-secret-store.ts +++ b/apps/console/worker/lib/endpoint-secret-store.ts @@ -12,97 +12,6 @@ interface StoredSecretRow { ciphertext_base64: string } -export interface ProvisionEndpointSecretDependencies { - now?: () => string - createSecret?: () => string -} - -export interface ProvisionedEndpointSecret { - endpointId: string - rawSecret: string - generation: number - keyVersion: string - createdAt: string -} - -export async function provisionEndpointSigningSecret( - database: RelayDatabase, - endpointId: string, - keyVersion: string, - keyring: EndpointSecretKeyring, - dependencies: ProvisionEndpointSecretDependencies = {}, -): Promise { - const endpoint = await database - .prepare( - `SELECT id - FROM endpoints - WHERE id = ? - LIMIT 1`, - ) - .bind(endpointId) - .first<{ id: string }>() - - if (!endpoint) { - throw new Error('Endpoint does not exist.') - } - - const existing = await database - .prepare( - `SELECT generation - FROM endpoint_signing_secrets - WHERE endpoint_id = ? - AND state = 'active' - LIMIT 1`, - ) - .bind(endpointId) - .first<{ generation: number }>() - - if (existing) { - throw new Error('Endpoint already has an active signing secret.') - } - - const createSecret = dependencies.createSecret ?? createEndpointSigningSecret - const now = dependencies.now ?? (() => new Date().toISOString()) - - const rawSecret = createSecret() - const createdAt = now() - const generation = 1 - - const encrypted = await encryptEndpointSecret(rawSecret, endpointId, keyVersion, keyring) - - await database - .prepare( - `INSERT INTO endpoint_signing_secrets ( - endpoint_id, - generation, - state, - key_version, - iv_base64, - ciphertext_base64, - valid_until, - created_at - ) - VALUES (?, ?, 'active', ?, ?, ?, NULL, ?)`, - ) - .bind( - endpointId, - generation, - encrypted.keyVersion, - encrypted.ivBase64, - encrypted.ciphertextBase64, - createdAt, - ) - .run() - - return { - endpointId, - rawSecret, - generation, - keyVersion, - createdAt, - } -} - export async function resolveEndpointSigningSecret( database: RelayDatabase, endpointId: string, From a5e22118b94262599e7f93eddf8d0a02e84b4493 Mon Sep 17 00:00:00 2001 From: Oniel Alejo Feliz <197416079+XonkelX@users.noreply.github.com> Date: Fri, 7 Aug 2026 00:48:16 -0400 Subject: [PATCH 12/12] docs: close endpoint security phase --- README.md | 8 +++++--- docs/threat-model/README.md | 33 ++++++++++++++++++++------------- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index a9dbc09..b233701 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,13 @@ Relay is a reliable outbound webhook delivery platform built on Cloudflare Worke ## Project Status -Relay has completed Phase 3: asynchronous webhook delivery and retry execution. +Relay has completed Phase 4: endpoint and secret security. -Relay now accepts authenticated events, creates delivery fanout atomically, publishes work through a durable outbox, consumes Cloudflare Queue messages, claims deliveries with leases, signs stable webhook requests, executes bounded outbound HTTP requests, records attempt evidence, classifies failures, honors `Retry-After`, schedules jittered retries through D1, recovers expired leases, and exhausts deliveries after the configured attempt limit. +Relay accepts authenticated events, creates delivery fanout atomically, publishes work through a durable outbox, consumes Cloudflare Queue messages, claims deliveries with leases, signs stable webhook requests, records attempt evidence, classifies failures, schedules bounded retries, and recovers expired leases. -Phase 4 endpoint verification, SSRF defenses, encrypted endpoint-specific signing secrets, secret rotation, hardened owner-session flows, and quota enforcement are not implemented yet. +Phase 4 adds strict public-HTTPS endpoint URL policy, signed challenge-response endpoint verification, mandatory re-verification after URL changes, encrypted endpoint-specific signing secrets, bounded dual-signature secret rotation, delivery suppression for inactive endpoints, hardened signed owner sessions with CSRF protection, fail-closed per-key and global daily quotas, restrictive browser-origin policy, security headers, and generic server error responses. + +Phase 5 will connect the operational Console to production data and delivery controls. ## Delivery Guarantee diff --git a/docs/threat-model/README.md b/docs/threat-model/README.md index 81d05a6..80c7f02 100644 --- a/docs/threat-model/README.md +++ b/docs/threat-model/README.md @@ -1,7 +1,7 @@ # Relay Threat Model -- Status: Draft -- Last reviewed: 2026-08-04 +- Status: Active +- Last reviewed: 2026-08-07 - Scope: Initial single-owner release and public demo mode ## System Summary @@ -72,15 +72,23 @@ Large payloads, excessive retries, unbounded logs, or abusive endpoint creation Webhook response bodies or headers could contain secrets or personal information. -## Initial Mitigations - -- Never expose stored signing secrets after creation. +## Implemented Mitigations + +- Return endpoint signing secrets only when initially created or explicitly rotated; persist only AES-GCM encrypted secret material. +- Bind encrypted endpoint secrets to endpoint identity and a versioned Worker-held master key. +- Allow only a bounded previous-secret grace window during rotation and emit active-plus-previous signatures only during that window. +- Require signed challenge-response verification before an endpoint becomes active. +- Return changed endpoint URLs to pending verification and cancel their scheduled delivery work. +- Require HTTPS destination URLs and reject credentials, fragments, IP literals, single-label or private-style hostnames, and explicit non-443 ports. +- Disable automatic redirects for verification and delivery requests so redirect chains cannot bypass the submitted destination policy. +- Prevent pending, paused, or disabled endpoints from executing queued deliveries. +- Protect owner sessions with signed Secure HttpOnly SameSite=Strict cookies, short TTLs, CSRF binding for mutations, revocation, and explicit logout. +- Apply fail-closed per-key and global daily event quotas backed by transactional D1 counters. +- Apply restrictive CSP and browser-origin policy plus no-store and defensive HTTP response headers. +- Return generic server errors without exposing internal exception details. - Redact secrets and authorization data from logs. -- Enforce owner authorization on every mutation route. - Keep public demo mode read-only and logically separated. -- Validate and normalize destination URLs. -- Block unsafe destination addresses before delivery. -- Use timestamped signatures with documented verification tolerance. +- Use timestamped webhook signatures with documented verification tolerance. - Persist attempts and use conditional claims with lease expiration. - Enforce payload, response, retry, and batch-size limits. - Truncate and sanitize captured response data. @@ -90,10 +98,9 @@ Webhook response bodies or headers could contain secrets or personal information The following require further design before production release: -- Complete SSRF protection for redirects and DNS rebinding -- Authentication provider selection and session hardening -- Secret encryption and rotation lifecycle -- Abuse prevention and rate limiting +- DNS resolution and rebinding defenses beyond the current hostname and IP-literal destination policy +- Owner authentication bootstrap and provider selection +- Public Failure Lab abuse controls and broader rate limiting - Data-retention and deletion policies - Incident response and audit-log requirements - Multi-tenant isolation