Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -647,6 +647,14 @@ 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 }`.
use_proxy:
type: boolean
description: >
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'

Expand Down
11 changes: 0 additions & 11 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/engine/src/__tests__/conformance/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export function makeNodeStack(options?: {
ttlMs?: number;
mailbox?: EngineConfig['mailbox'];
environment?: string;
httpPushProxy?: EngineConfig['httpPushProxy'];
entitlements?: EntitlementsProvider;
}): TestStack {
const runtime = createNodeRuntime({
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,99 @@ 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',
// 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);

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');
// 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();
}
});

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')
Expand Down
2 changes: 1 addition & 1 deletion packages/engine/src/adapters/node/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
52 changes: 49 additions & 3 deletions packages/engine/src/engine/httpPushDispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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<string, unknown>,
proxy: HttpPushProxyConfig | undefined,
): { ok: true; requestUrl: string; proxyHeaders: Record<string, string> } | { 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;
Expand All @@ -86,12 +121,21 @@ export async function postEphemeralEventToHttpPushNode(args: {
deliveryConfig: Record<string, unknown> | null | undefined;
strict: boolean;
event: EphemeralNodeEvent;
/** Egress proxy for `use_proxy` nodes; omit to always POST direct. */
proxy?: HttpPushProxyConfig;
}): Promise<boolean> {
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({
Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions packages/engine/src/engine/invocationCompletion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion packages/engine/src/engine/nodeContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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 = {
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion packages/engine/src/engine/nodeDeliver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,16 @@ 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;
nodeConnections: NodeConnectionRegistry;
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 = {
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading