From fa319c2832aa5dc1946671b8eefa1b2a67a969d2 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Tue, 30 Jun 2026 21:43:30 -0700 Subject: [PATCH 1/3] feat(engine): optional egress proxy for http_push delivery (use_proxy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a per-node opt-in that routes an http_push webhook POST through a deployment-configured forwarder instead of hitting the destination directly. Motivation: some receivers sit behind Cloudflare bot protection that blocks the engine's own network origin (Cloudflare Workers get a zone-level 403), so the webhook can never be delivered from the Worker directly. Routing through a non-Cloudflare proxy gets through. - `httpDeliverySchema` gains `use_proxy?: boolean` (stored in the node's deliveryConfig; `url` stays the real destination). - `EngineConfig.httpPushProxy?: { url, secret }` — the deployment's single forwarder, wired by the host adapter (env/secrets). - `dispatchHttpPush`: when `use_proxy` is set, POST to the proxy URL with the real target in `X-Forward-To` and the shared secret in `X-Proxy-Auth`; the real `url` is still SSRF-checked. If `use_proxy` is set but no proxy is configured, the delivery fails with a clear error rather than silently going direct (which would just be blocked). Tests: routes through the proxy with the right control headers + preserves the node's own webhook auth header; and fails cleanly when use_proxy is set without a configured proxy. 19/19 conformance tests pass. Co-Authored-By: Claude Opus 4.8 --- .../src/__tests__/conformance/harness.ts | 2 + .../conformance/nodeDeliveryContracts.test.ts | 59 +++++++++++++++++++ packages/engine/src/ports/index.ts | 13 ++++ packages/engine/src/routes/deliveryRouting.ts | 23 +++++++- packages/engine/src/routes/node.ts | 4 ++ 5 files changed, 99 insertions(+), 2 deletions(-) diff --git a/packages/engine/src/__tests__/conformance/harness.ts b/packages/engine/src/__tests__/conformance/harness.ts index 3d26fddc..2ed9ad78 100644 --- a/packages/engine/src/__tests__/conformance/harness.ts +++ b/packages/engine/src/__tests__/conformance/harness.ts @@ -15,6 +15,7 @@ export function makeNodeStack(options?: { ttlMs?: number; mailbox?: EngineConfig['mailbox']; environment?: string; + httpPushProxy?: EngineConfig['httpPushProxy']; entitlements?: EntitlementsProvider; }): TestStack { const runtime = createNodeRuntime({ @@ -24,6 +25,7 @@ export function makeNodeStack(options?: { config: { environment: options?.environment ?? 'test', mailbox: options?.mailbox, + httpPushProxy: options?.httpPushProxy, }, entitlements: options?.entitlements, // Disable the auto-sweep timer; tests drive presence.sweep() explicitly. diff --git a/packages/engine/src/__tests__/conformance/nodeDeliveryContracts.test.ts b/packages/engine/src/__tests__/conformance/nodeDeliveryContracts.test.ts index 0aadb479..d56cc1a0 100644 --- a/packages/engine/src/__tests__/conformance/nodeDeliveryContracts.test.ts +++ b/packages/engine/src/__tests__/conformance/nodeDeliveryContracts.test.ts @@ -407,6 +407,65 @@ describe('node delivery contracts', () => { }); }); + it('routes http_push through the configured egress proxy when use_proxy is set', async () => { + const proxyStack = makeNodeStack({ ttlMs: 60_000, httpPushProxy: { url: 'https://proxy.example.test/fwd', secret: 'proxy-secret' } }); + try { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('', { status: 202 })); + const ws = await createWorkspace(proxyStack.app, 'http-node-use-proxy'); + const alice = await registerAgent(proxyStack.app, ws.workspaceKey, 'alice'); + await registerAgent(proxyStack.app, ws.workspaceKey, 'bob'); + const authed = (path, key, body) => proxyStack.app.request(path, { + method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` }, body: JSON.stringify(body), + }); + expect((await authed('/v1/nodes', ws.workspaceKey, { + name: 'proxied-node', kind: 'http_push', + delivery: { url: 'https://receiver.example.test/relaycast', use_proxy: true, ack_mode: 'on_2xx', + auth: { type: 'static_headers', headers: { 'X-Webhook-Secret': 'shh' } } }, + })).status).toBe(201); + expect((await authed('/v1/nodes/proxied-node/agents', ws.workspaceKey, { agent_name: 'bob' })).status).toBe(201); + expect((await authed('/v1/channels/general/messages', alice.token, { text: 'via proxy' })).status).toBe(201); + + const deliveryCall = () => fetchMock.mock.calls.find((c) => (c[1]?.headers)?.['X-Relaycast-Event'] === 'message.created'); + await waitForAssertion(() => expect(deliveryCall()).toBeTruthy()); + const [reqUrl, init] = deliveryCall(); + // The POST goes to the PROXY, with the real target + auth in control headers, + // and the node's own webhook auth header preserved for the proxy to forward. + expect(reqUrl).toBe('https://proxy.example.test/fwd'); + const h = init.headers; + expect(h['X-Forward-To']).toBe('https://receiver.example.test/relaycast'); + expect(h['X-Proxy-Auth']).toBe('proxy-secret'); + expect(h['X-Webhook-Secret']).toBe('shh'); + } finally { + proxyStack.close(); + } + }); + + it('fails an http_push use_proxy delivery when no proxy is configured', async () => { + // Default `stack` has no httpPushProxy configured. + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('', { status: 202 })); + const ws = await createWorkspace(stack.app, 'http-node-proxy-missing'); + const alice = await registerAgent(stack.app, ws.workspaceKey, 'alice'); + const bob = await registerAgent(stack.app, ws.workspaceKey, 'bob'); + const authed = (path, key, body) => stack.app.request(path, { + method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` }, body: JSON.stringify(body), + }); + expect((await authed('/v1/nodes', ws.workspaceKey, { + name: 'proxy-missing-node', kind: 'http_push', + delivery: { url: 'https://receiver.example.test/relaycast', use_proxy: true, ack_mode: 'on_2xx', auth: { type: 'none' } }, + })).status).toBe(201); + expect((await authed('/v1/nodes/proxy-missing-node/agents', ws.workspaceKey, { agent_name: 'bob' })).status).toBe(201); + expect((await authed('/v1/channels/general/messages', alice.token, { text: 'no proxy' })).status).toBe(201); + + await waitForAssertion(async () => { + // No webhook POST should ever fire; the delivery stays queued with an error. + expect(deliveryPosts(fetchMock)).toHaveLength(0); + const queued = await stack.app.request('/v1/deliveries', { headers: { authorization: `Bearer ${bob.token}` } }); + const [item] = ((await queued.json()) as { data: Array<{ status: string; last_dispatch_error: string | null }> }).data; + expect(item).toMatchObject({ status: 'queued' }); + expect(item.last_dispatch_error).toMatch(/no http_push proxy configured/); + }); + }); + it('sweeps never-attempted http_push deliveries (nextAttemptAt IS NULL)', async () => { const fetchMock = vi .spyOn(globalThis, 'fetch') diff --git a/packages/engine/src/ports/index.ts b/packages/engine/src/ports/index.ts index e2ae4bc3..34efdbbc 100644 --- a/packages/engine/src/ports/index.ts +++ b/packages/engine/src/ports/index.ts @@ -93,6 +93,19 @@ export interface EngineConfig { appVersion?: string; appSemver?: string; sdkSemver?: string; + /** + * Optional egress proxy for http_push node delivery. When set, nodes that + * register with `delivery.use_proxy: true` have their webhook POST routed + * through this forwarder instead of hitting the destination directly — the + * real target is passed via the `X-Forward-To` header and authenticated with + * `secret` (sent as `X-Proxy-Auth`). Used to reach receivers that block the + * engine's own network origin (e.g. a webhook behind Cloudflare bot rules that + * rejects Cloudflare Workers). Hosted adapters wire this from their secrets. + */ + httpPushProxy?: { + url?: string; + secret?: string; + }; /** * When set, the structured logger exports prod logs to a PostHog OTLP endpoint * via plain fetch (no posthog-node dependency). Self-host leaves this unset and diff --git a/packages/engine/src/routes/deliveryRouting.ts b/packages/engine/src/routes/deliveryRouting.ts index 8271973b..f25510ac 100644 --- a/packages/engine/src/routes/deliveryRouting.ts +++ b/packages/engine/src/routes/deliveryRouting.ts @@ -246,6 +246,25 @@ async function dispatchHttpPush(args: { return 'failed'; } + // Optional egress proxy: when the node opts in with `use_proxy`, POST to the + // deployment-configured forwarder instead of the destination directly, passing + // the real target via X-Forward-To. Used to reach receivers that block the + // engine's own network origin (e.g. a webhook behind Cloudflare bot rules that + // reject Cloudflare Workers). The real `url` is still SSRF-checked above; the + // proxy URL is operator-configured and trusted. + let requestUrl = url; + const proxyHeaders: Record = {}; + if (config.use_proxy === true) { + const proxyCfg = args.ctx.engine.config?.httpPushProxy; + if (!proxyCfg?.url) { + await recordHttpPushRetry(args.ctx, args.delivery.id, 'use_proxy set but no http_push proxy configured', { incrementAttempts: true }); + return 'failed'; + } + requestUrl = proxyCfg.url; + proxyHeaders['X-Forward-To'] = url; + if (proxyCfg.secret) proxyHeaders['X-Proxy-Auth'] = proxyCfg.secret; + } + const ackMode = config.ack_mode === 'on_2xx' || config.ack_mode === 'response' ? config.ack_mode : 'manual'; @@ -296,8 +315,8 @@ async function dispatchHttpPush(args: { // Build headers inside the claim/retry boundary so a signing failure is // recorded as a retryable dispatch error rather than rejecting uncaught. - const headers = await buildHttpPushHeaders(config, args.eventType, args.delivery.id, body, timestamp); - const response = await globalThis.fetch(url, { + const headers = { ...(await buildHttpPushHeaders(config, args.eventType, args.delivery.id, body, timestamp)), ...proxyHeaders }; + const response = await globalThis.fetch(requestUrl, { method: 'POST', headers, body, diff --git a/packages/engine/src/routes/node.ts b/packages/engine/src/routes/node.ts index 2a63eb3f..1a487172 100644 --- a/packages/engine/src/routes/node.ts +++ b/packages/engine/src/routes/node.ts @@ -46,6 +46,10 @@ const httpDeliverySchema = z.object({ url: z.url(), ack_mode: deliveryAckModeSchema.default('manual'), auth: deliveryAuthSchema.default({ type: 'none' }), + // Opt this node's webhook delivery into the deployment-configured egress proxy + // (EngineConfig.httpPushProxy). `url` stays the real destination; the engine + // forwards through the proxy at dispatch time. See dispatchHttpPush. + use_proxy: z.boolean().optional(), }); const createNodeSchema = z.object({ From 2a597660796cde75806e4d5c556e97b83d0fb691 Mon Sep 17 00:00:00 2001 From: "agent-relay-code[bot]" Date: Wed, 1 Jul 2026 04:51:53 +0000 Subject: [PATCH 2/3] chore: apply pr-reviewer fixes for #227 --- openapi.yaml | 7 +++++++ package-lock.json | 11 ----------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/openapi.yaml b/openapi.yaml index e6089065..ab310fdb 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -649,6 +649,13 @@ components: for example `{ "ack": true }`. auth: $ref: '#/components/schemas/NodeDeliveryAuth' + use_proxy: + type: boolean + description: > + Route this node's webhook delivery through the deployment-configured egress + proxy instead of POSTing to `url` directly. `url` stays the real destination; + the engine forwards through the proxy at dispatch time. If the deployment has no + proxy configured, the delivery is not sent and stays queued with an error. NodeRosterEntry: type: object diff --git a/package-lock.json b/package-lock.json index 47c89f30..08b49092 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5666,7 +5666,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -5688,7 +5687,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -5710,7 +5708,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -5732,7 +5729,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -5754,7 +5750,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -5776,7 +5771,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -5798,7 +5792,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -5820,7 +5813,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -5842,7 +5834,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -5864,7 +5855,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -5886,7 +5876,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, From 6399c6c36641a5f3509ab3e4c258d27c73d91d31 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Tue, 30 Jun 2026 22:29:38 -0700 Subject: [PATCH 3/3] =?UTF-8?q?fix(engine):=20address=20use=5Fproxy=20revi?= =?UTF-8?q?ew=20=E2=80=94=20SSRF=20guard,=20required=20secret,=20ephemeral?= =?UTF-8?q?=20path,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR review (Gemini/cubic/CodeRabbit/Codex): - SSRF/header-injection (HIGH): operator static_headers could inject case-variant `x-forward-to`/`x-proxy-auth` and repoint the proxy at an internal address. Add both to RELAYCAST_CONTROLLED_HEADERS so they can never be set via static_headers (case-insensitive), for durable and ephemeral paths. Test asserts a malicious `x-forward-to` static header is dropped. - Require proxy secret: `use_proxy` now fails unless BOTH proxy url and secret are configured (no unauthenticated proxy POST). Extracted a shared `resolveHttpPushProxy` helper; dispatchHttpPush uses it. Test covers the url-without-secret case. - Ephemeral path: `postEphemeralEventToHttpPushNode` now honors `use_proxy` (via the shared helper) and also fixes its own `redirect:'error'` → `'manual'` (same Workers bug). `httpPushProxy` threaded through NodeDeliverDeps/NodeContextDeps and all ephemeral deps construction sites. - Docs: document `use_proxy` in openapi.yaml (HttpPushNodeDelivery), the TS SDK type (`useProxy`), and README, per AGENTS.md. 20/20 conformance tests pass; engine + SDK tsc clean. Co-Authored-By: Claude Opus 4.8 --- README.md | 6 +++ openapi.yaml | 13 ++--- .../conformance/nodeDeliveryContracts.test.ts | 36 ++++++++++++- packages/engine/src/adapters/node/index.ts | 2 +- .../engine/src/engine/httpPushDispatch.ts | 52 +++++++++++++++++-- .../engine/src/engine/invocationCompletion.ts | 1 + packages/engine/src/engine/nodeContext.ts | 5 +- packages/engine/src/engine/nodeDeliver.ts | 5 +- packages/engine/src/routes/action.ts | 1 + packages/engine/src/routes/agent.ts | 2 + packages/engine/src/routes/deliveryRouting.ts | 32 +++++------- packages/engine/src/routes/fanout.ts | 2 + packages/engine/src/routes/reaction.ts | 2 + packages/engine/src/routes/receipt.ts | 1 + packages/sdk-typescript/src/types.ts | 7 +++ 15 files changed, 136 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index b55e2853..6bf78f31 100644 --- a/README.md +++ b/README.md @@ -439,6 +439,12 @@ on any 2xx HTTP response, and `response` acks when the response body declares an Manual HTTP receivers ack by calling `/v1/deliveries/:id/ack` with the bound agent's token, so pure webhook endpoints should use `on_2xx` or `response` unless they can securely hold that token. +Set `useProxy: true` (wire field `use_proxy`) to route this node's webhook POST through +the deployment's configured egress proxy instead of hitting `url` directly — the real +target is forwarded in `X-Forward-To`. Use it for receivers that block the server's +network origin (e.g. a webhook behind Cloudflare bot protection that rejects requests +from Cloudflare Workers). If the deployment has no proxy configured, `use_proxy` +deliveries fail with a clear error rather than silently going direct. HTTP push nodes also receive the ephemeral channel/workspace events a WebSocket node gets — reactions (`message.reacted`), read receipts (`message.read`), and presence / status updates — as best-effort POSTs to the same delivery URL (same auth/signing, diff --git a/openapi.yaml b/openapi.yaml index ab310fdb..520b5808 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -647,15 +647,16 @@ components: HTTP push receivers must authenticate that ack with the bound agent token. `on_2xx` acks on any 2xx HTTP response; `response` acks when the response body declares an ack, for example `{ "ack": true }`. - auth: - $ref: '#/components/schemas/NodeDeliveryAuth' use_proxy: type: boolean description: > - Route this node's webhook delivery through the deployment-configured egress - proxy instead of POSTing to `url` directly. `url` stays the real destination; - the engine forwards through the proxy at dispatch time. If the deployment has no - proxy configured, the delivery is not sent and stays queued with an error. + When `true`, the deployment routes this node's webhook POST through its + configured egress proxy instead of hitting `url` directly (the real target + is passed to the proxy in `X-Forward-To`). Use for receivers that block the + server's network origin (e.g. a webhook behind Cloudflare bot rules). If the + deployment has no proxy configured, deliveries fail rather than going direct. + auth: + $ref: '#/components/schemas/NodeDeliveryAuth' NodeRosterEntry: type: object diff --git a/packages/engine/src/__tests__/conformance/nodeDeliveryContracts.test.ts b/packages/engine/src/__tests__/conformance/nodeDeliveryContracts.test.ts index d56cc1a0..ebbd7adc 100644 --- a/packages/engine/src/__tests__/conformance/nodeDeliveryContracts.test.ts +++ b/packages/engine/src/__tests__/conformance/nodeDeliveryContracts.test.ts @@ -420,7 +420,9 @@ describe('node delivery contracts', () => { expect((await authed('/v1/nodes', ws.workspaceKey, { name: 'proxied-node', kind: 'http_push', delivery: { url: 'https://receiver.example.test/relaycast', use_proxy: true, ack_mode: 'on_2xx', - auth: { type: 'static_headers', headers: { 'X-Webhook-Secret': 'shh' } } }, + // Includes a malicious case-variant control header that must NOT be able + // to override the engine-set X-Forward-To (SSRF/header-injection guard). + auth: { type: 'static_headers', headers: { 'X-Webhook-Secret': 'shh', 'x-forward-to': 'http://169.254.169.254/' } } }, })).status).toBe(201); expect((await authed('/v1/nodes/proxied-node/agents', ws.workspaceKey, { agent_name: 'bob' })).status).toBe(201); expect((await authed('/v1/channels/general/messages', alice.token, { text: 'via proxy' })).status).toBe(201); @@ -435,6 +437,38 @@ describe('node delivery contracts', () => { expect(h['X-Forward-To']).toBe('https://receiver.example.test/relaycast'); expect(h['X-Proxy-Auth']).toBe('proxy-secret'); expect(h['X-Webhook-Secret']).toBe('shh'); + // The malicious static header must not have leaked through in any casing. + expect(h['x-forward-to']).toBeUndefined(); + expect(JSON.stringify(h)).not.toContain('169.254.169.254'); + } finally { + proxyStack.close(); + } + }); + + it('fails a use_proxy delivery when the proxy has no secret configured', async () => { + const proxyStack = makeNodeStack({ ttlMs: 60_000, httpPushProxy: { url: 'https://proxy.example.test/fwd' } }); + try { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('', { status: 202 })); + const ws = await createWorkspace(proxyStack.app, 'http-node-proxy-nosecret'); + const alice = await registerAgent(proxyStack.app, ws.workspaceKey, 'alice'); + const bob = await registerAgent(proxyStack.app, ws.workspaceKey, 'bob'); + const authed = (path, key, body) => proxyStack.app.request(path, { + method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` }, body: JSON.stringify(body), + }); + expect((await authed('/v1/nodes', ws.workspaceKey, { + name: 'nosecret-node', kind: 'http_push', + delivery: { url: 'https://receiver.example.test/relaycast', use_proxy: true, ack_mode: 'on_2xx', auth: { type: 'none' } }, + })).status).toBe(201); + expect((await authed('/v1/nodes/nosecret-node/agents', ws.workspaceKey, { agent_name: 'bob' })).status).toBe(201); + expect((await authed('/v1/channels/general/messages', alice.token, { text: 'no secret' })).status).toBe(201); + + await waitForAssertion(async () => { + expect(deliveryPosts(fetchMock)).toHaveLength(0); + const queued = await proxyStack.app.request('/v1/deliveries', { headers: { authorization: `Bearer ${bob.token}` } }); + const [item] = ((await queued.json()) as { data: Array<{ status: string; last_dispatch_error: string | null }> }).data; + expect(item).toMatchObject({ status: 'queued' }); + expect(item.last_dispatch_error).toMatch(/no http_push proxy configured/); + }); } finally { proxyStack.close(); } diff --git a/packages/engine/src/adapters/node/index.ts b/packages/engine/src/adapters/node/index.ts index 840a2f47..f862d861 100644 --- a/packages/engine/src/adapters/node/index.ts +++ b/packages/engine/src/adapters/node/index.ts @@ -111,7 +111,7 @@ export function createNodeRuntime(options: NodeRuntimeOptions): NodeRuntime { // so a slow or black-holed http_push receiver must not stall them. WS // context sends are in-memory; the http_push POST runs detached. void sendNodePresenceContext( - { db, nodeConnections: realtime, realtime, workspaceId, environment: options.config?.environment }, + { db, nodeConnections: realtime, realtime, workspaceId, environment: options.config?.environment, httpPushProxy: options.config?.httpPushProxy }, { subjectAgentId, event: eventType, diff --git a/packages/engine/src/engine/httpPushDispatch.ts b/packages/engine/src/engine/httpPushDispatch.ts index 1b982892..efdc19f4 100644 --- a/packages/engine/src/engine/httpPushDispatch.ts +++ b/packages/engine/src/engine/httpPushDispatch.ts @@ -7,6 +7,11 @@ const RELAYCAST_CONTROLLED_HEADERS = new Set([ 'content-type', 'x-relaycast-event', 'x-relaycast-delivery', + // Egress-proxy control headers: never let operator-supplied static_headers set + // these (case-insensitively), or a node could inject `x-forward-to` to point + // the proxy at an internal address and bypass the SSRF check on the real url. + 'x-forward-to', + 'x-proxy-auth', ]); function publicHeaders( @@ -67,6 +72,36 @@ export function strictHttpPushDispatch(environment: string | undefined): boolean return environment !== 'test'; } +export interface HttpPushProxyConfig { + url?: string; + secret?: string; +} + +/** + * Resolve the effective request target for an http_push POST. When the node opts + * in with `delivery.use_proxy`, the POST is routed through the deployment's + * configured forwarder (`proxy`) instead of the destination directly — the real + * target rides in `X-Forward-To`, authenticated with `X-Proxy-Auth`. Used to + * reach receivers that block the engine's own network origin. Shared by durable + * message dispatch and ephemeral node-event dispatch so both honor `use_proxy`. + * The real `realUrl` must already be SSRF-checked by the caller. + */ +export function resolveHttpPushProxy( + realUrl: string, + config: Record, + proxy: HttpPushProxyConfig | undefined, +): { ok: true; requestUrl: string; proxyHeaders: Record } | { ok: false; reason: string } { + if (config.use_proxy !== true) return { ok: true, requestUrl: realUrl, proxyHeaders: {} }; + if (!proxy?.url || !proxy.secret) { + return { ok: false, reason: 'use_proxy set but no http_push proxy configured' }; + } + return { + ok: true, + requestUrl: proxy.url, + proxyHeaders: { 'X-Forward-To': realUrl, 'X-Proxy-Auth': proxy.secret }, + }; +} + export interface EphemeralNodeEvent { workspaceId: string; eventType: string; @@ -86,12 +121,21 @@ export async function postEphemeralEventToHttpPushNode(args: { deliveryConfig: Record | null | undefined; strict: boolean; event: EphemeralNodeEvent; + /** Egress proxy for `use_proxy` nodes; omit to always POST direct. */ + proxy?: HttpPushProxyConfig; }): Promise { const config = args.deliveryConfig ?? {}; const url = typeof config.url === 'string' ? config.url : null; if (!url) return false; if (!isSafeExternalUrl(url, { strict: args.strict })) return false; + // Honor `use_proxy` here too, so a proxied node's ephemeral events reach the + // receiver in the same environment durable messages do. Best-effort: if the + // node opts in but no proxy is configured, drop the event rather than leaking + // a direct request that would just be blocked. + const resolved = resolveHttpPushProxy(url, config, args.proxy); + if (!resolved.ok) return false; + const timestamp = new Date().toISOString(); // Spread `extra` first so the canonical event fields always win. const body = JSON.stringify({ @@ -103,12 +147,14 @@ export async function postEphemeralEventToHttpPushNode(args: { }); try { - const headers = await buildHttpPushHeaders(config, args.event.eventType, null, body, timestamp); - const response = await globalThis.fetch(url, { + const headers = { ...(await buildHttpPushHeaders(config, args.event.eventType, null, body, timestamp)), ...resolved.proxyHeaders }; + const response = await globalThis.fetch(resolved.requestUrl, { method: 'POST', headers, body, - redirect: 'error', + // Cloudflare Workers reject redirect:'error'; use 'manual' and treat any + // 3xx as non-ok below (response.ok is false for 3xx). + redirect: 'manual', signal: AbortSignal.timeout(10_000), }); // We only inspect status; release the connection instead of leaking the body. diff --git a/packages/engine/src/engine/invocationCompletion.ts b/packages/engine/src/engine/invocationCompletion.ts index fda09ade..32d98f0d 100644 --- a/packages/engine/src/engine/invocationCompletion.ts +++ b/packages/engine/src/engine/invocationCompletion.ts @@ -47,6 +47,7 @@ export async function emitInvocationCompletionEffects( nodeConnections: deps.nodeConnections, workspaceId, environment: deps.config?.environment, + httpPushProxy: deps.config?.httpPushProxy, }, { agentIds: [result.caller_id], event: eventType, diff --git a/packages/engine/src/engine/nodeContext.ts b/packages/engine/src/engine/nodeContext.ts index 9b919ed9..2318b908 100644 --- a/packages/engine/src/engine/nodeContext.ts +++ b/packages/engine/src/engine/nodeContext.ts @@ -3,7 +3,7 @@ import type { EngineDb } from '../ports/database.js'; import type { NodeConnectionRegistry, RealtimeBus } from '../ports/realtime.js'; import { agents, agentNodeBindings, channelMembers, nodes } from '../db/schema.js'; import { toFleetWireJson } from './deliveryWire.js'; -import { postEphemeralEventToHttpPushNode, strictHttpPushDispatch } from './httpPushDispatch.js'; +import { postEphemeralEventToHttpPushNode, strictHttpPushDispatch, type HttpPushProxyConfig } from './httpPushDispatch.js'; type NodeContextTopic = 'presence' | 'channel' | 'thread' | 'agent'; @@ -14,6 +14,8 @@ type NodeContextDeps = { workspaceId: string; /** Defaults to strict SSRF hardening when omitted (production-safe). */ environment?: string; + /** Egress proxy for http_push nodes that opt in with `delivery.use_proxy`. */ + httpPushProxy?: HttpPushProxyConfig; }; type ScopedNodeRow = { @@ -93,6 +95,7 @@ async function sendContextToRows( postEphemeralEventToHttpPushNode({ deliveryConfig: group.deliveryConfig, strict: strictHttpPushDispatch(deps.environment), + proxy: deps.httpPushProxy, event: { workspaceId: deps.workspaceId, eventType: message.event, diff --git a/packages/engine/src/engine/nodeDeliver.ts b/packages/engine/src/engine/nodeDeliver.ts index 3fed9fcd..83825e96 100644 --- a/packages/engine/src/engine/nodeDeliver.ts +++ b/packages/engine/src/engine/nodeDeliver.ts @@ -3,7 +3,7 @@ import { agents, agentNodeBindings, channelMembers, dmConversations, dmParticipa import type { EngineDb } from '../ports/database.js'; import type { NodeConnectionRegistry } from '../ports/realtime.js'; import { buildDeliverFrame, buildDeliverPayload } from './deliveryWire.js'; -import { postEphemeralEventToHttpPushNode, strictHttpPushDispatch } from './httpPushDispatch.js'; +import { postEphemeralEventToHttpPushNode, strictHttpPushDispatch, type HttpPushProxyConfig } from './httpPushDispatch.js'; type NodeDeliverDeps = { db: EngineDb; @@ -11,6 +11,8 @@ type NodeDeliverDeps = { workspaceId: string; /** Defaults to strict SSRF hardening when omitted (production-safe). */ environment?: string; + /** Egress proxy for http_push nodes that opt in with `delivery.use_proxy`. */ + httpPushProxy?: HttpPushProxyConfig; }; type NodeDeliverRecipient = { @@ -111,6 +113,7 @@ function deliverEventToRecipient( return postEphemeralEventToHttpPushNode({ deliveryConfig: recipient.deliveryConfig, strict: strictHttpPushDispatch(deps.environment), + proxy: deps.httpPushProxy, event: { workspaceId: deps.workspaceId, eventType: args.event, diff --git a/packages/engine/src/routes/action.ts b/packages/engine/src/routes/action.ts index 7ce68de1..da0901e6 100644 --- a/packages/engine/src/routes/action.ts +++ b/packages/engine/src/routes/action.ts @@ -212,6 +212,7 @@ actionRoutes.post('/actions/:name/invoke', requireAuth, rateLimit, async (c) => db: c.get('db'), nodeConnections: c.get('engine').nodeConnections, environment: c.get('engine').config?.environment, + httpPushProxy: c.get('engine').config?.httpPushProxy, workspaceId: workspace.id, }, { diff --git a/packages/engine/src/routes/agent.ts b/packages/engine/src/routes/agent.ts index 7215f5db..6af50a83 100644 --- a/packages/engine/src/routes/agent.ts +++ b/packages/engine/src/routes/agent.ts @@ -118,6 +118,7 @@ async function fanoutAgentStatus(c: Parameters[0], agent db: c.get('db'), nodeConnections: c.get('engine').nodeConnections, environment: c.get('engine').config?.environment, + httpPushProxy: c.get('engine').config?.httpPushProxy, realtime: c.get('engine').realtime, workspaceId: c.get('workspace').id, }, @@ -519,6 +520,7 @@ agentRoutes.post( db, nodeConnections: c.get('engine').nodeConnections, environment: c.get('engine').config?.environment, + httpPushProxy: c.get('engine').config?.httpPushProxy, realtime: c.get('engine').realtime, workspaceId: workspace.id, }, diff --git a/packages/engine/src/routes/deliveryRouting.ts b/packages/engine/src/routes/deliveryRouting.ts index f25510ac..d101c7f4 100644 --- a/packages/engine/src/routes/deliveryRouting.ts +++ b/packages/engine/src/routes/deliveryRouting.ts @@ -8,7 +8,7 @@ import type { DeliveryRejectionRecord, } from '../engine/deliveryWrites.js'; import { agents, agentNodeBindings, deliveries as deliveryRows, nodes } from '../db/schema.js'; -import { buildHttpPushHeaders } from '../engine/httpPushDispatch.js'; +import { buildHttpPushHeaders, resolveHttpPushProxy } from '../engine/httpPushDispatch.js'; import { isSafeExternalUrl } from '../lib/ssrf.js'; import { transformForClient, type WsEvent } from '../engine/wsTransform.js'; import type { EngineDb, EngineDeps } from '../ports/index.js'; @@ -79,6 +79,7 @@ async function fanoutToAgentsForContext( realtime: ctx.engine.realtime, workspaceId: ctx.workspaceId, environment: ctx.engine.config?.environment, + httpPushProxy: ctx.engine.config?.httpPushProxy, }, { agentIds: unique, @@ -246,24 +247,19 @@ async function dispatchHttpPush(args: { return 'failed'; } - // Optional egress proxy: when the node opts in with `use_proxy`, POST to the - // deployment-configured forwarder instead of the destination directly, passing - // the real target via X-Forward-To. Used to reach receivers that block the - // engine's own network origin (e.g. a webhook behind Cloudflare bot rules that - // reject Cloudflare Workers). The real `url` is still SSRF-checked above; the - // proxy URL is operator-configured and trusted. - let requestUrl = url; - const proxyHeaders: Record = {}; - if (config.use_proxy === true) { - const proxyCfg = args.ctx.engine.config?.httpPushProxy; - if (!proxyCfg?.url) { - await recordHttpPushRetry(args.ctx, args.delivery.id, 'use_proxy set but no http_push proxy configured', { incrementAttempts: true }); - return 'failed'; - } - requestUrl = proxyCfg.url; - proxyHeaders['X-Forward-To'] = url; - if (proxyCfg.secret) proxyHeaders['X-Proxy-Auth'] = proxyCfg.secret; + // Optional egress proxy: when the node opts in with `use_proxy`, route the POST + // through the deployment-configured forwarder instead of the destination + // directly (real target rides in X-Forward-To). Used to reach receivers that + // block the engine's own network origin (e.g. a webhook behind Cloudflare bot + // rules that reject Cloudflare Workers). The real `url` is still SSRF-checked + // above; the proxy requires both a url and a secret. + const proxied = resolveHttpPushProxy(url, config, args.ctx.engine.config?.httpPushProxy); + if (!proxied.ok) { + await recordHttpPushRetry(args.ctx, args.delivery.id, proxied.reason, { incrementAttempts: true }); + return 'failed'; } + const requestUrl = proxied.requestUrl; + const proxyHeaders = proxied.proxyHeaders; const ackMode = config.ack_mode === 'on_2xx' || config.ack_mode === 'response' ? config.ack_mode diff --git a/packages/engine/src/routes/fanout.ts b/packages/engine/src/routes/fanout.ts index 24808031..0fc8be30 100644 --- a/packages/engine/src/routes/fanout.ts +++ b/packages/engine/src/routes/fanout.ts @@ -95,6 +95,7 @@ export async function fanoutToChannel( db: c.get('db'), nodeConnections: c.get('engine').nodeConnections, environment: c.get('engine').config?.environment, + httpPushProxy: c.get('engine').config?.httpPushProxy, realtime: c.get('engine').realtime, workspaceId: ws, }, @@ -138,6 +139,7 @@ export async function fanoutToAgents( db: c.get('db'), nodeConnections: c.get('engine').nodeConnections, environment: c.get('engine').config?.environment, + httpPushProxy: c.get('engine').config?.httpPushProxy, realtime: c.get('engine').realtime, workspaceId, }, diff --git a/packages/engine/src/routes/reaction.ts b/packages/engine/src/routes/reaction.ts index 9fee5244..9529c6bb 100644 --- a/packages/engine/src/routes/reaction.ts +++ b/packages/engine/src/routes/reaction.ts @@ -95,6 +95,7 @@ reactionRoutes.post( db, nodeConnections: c.get('engine').nodeConnections, environment: c.get('engine').config?.environment, + httpPushProxy: c.get('engine').config?.httpPushProxy, workspaceId: workspace.id, }, { @@ -186,6 +187,7 @@ reactionRoutes.delete( db, nodeConnections: c.get('engine').nodeConnections, environment: c.get('engine').config?.environment, + httpPushProxy: c.get('engine').config?.httpPushProxy, workspaceId: workspace.id, }, { diff --git a/packages/engine/src/routes/receipt.ts b/packages/engine/src/routes/receipt.ts index ae1317cf..4b36a789 100644 --- a/packages/engine/src/routes/receipt.ts +++ b/packages/engine/src/routes/receipt.ts @@ -55,6 +55,7 @@ receiptRoutes.post( db, nodeConnections: c.get('engine').nodeConnections, environment: c.get('engine').config?.environment, + httpPushProxy: c.get('engine').config?.httpPushProxy, workspaceId: workspace.id, }, { diff --git a/packages/sdk-typescript/src/types.ts b/packages/sdk-typescript/src/types.ts index eeb0a907..bfb94551 100644 --- a/packages/sdk-typescript/src/types.ts +++ b/packages/sdk-typescript/src/types.ts @@ -320,6 +320,13 @@ export interface HttpPushNodeDelivery { url: string; ackMode?: NodeAckMode; auth?: NodeDeliveryAuth; + /** + * Route this node's webhook POST through the deployment's configured egress + * proxy (wire field `use_proxy`) instead of hitting `url` directly. For + * receivers that block the server's network origin; fails if no proxy is + * configured server-side. + */ + useProxy?: boolean; } export interface NodeRosterEntry {