diff --git a/app/api/domain-webhooks/route.ts b/app/api/domain-webhooks/route.ts index bb56f6b..ee77ca8 100644 --- a/app/api/domain-webhooks/route.ts +++ b/app/api/domain-webhooks/route.ts @@ -10,7 +10,7 @@ import { listInboundEvents, distinctWebhookUrls, } from "@/lib/db"; -import { fireDomainEvent, newSecret, isInternalUrl } from "@/lib/webhooks"; +import { fireDomainEvent, newSecret, isInternalUrl, isSelfWebhookUrl } from "@/lib/webhooks"; import { listAccessibleProjectIds } from "@/lib/authz"; export const runtime = "nodejs"; @@ -87,6 +87,9 @@ export async function POST(req: NextRequest) { if (isInternalUrl(url)) { return NextResponse.json({ error: "That URL points to an internal/loopback address." }, { status: 400 }); } + if (isSelfWebhookUrl(url)) { + return NextResponse.json({ error: "That's an inbound receiver URL — sending events there would loop back into this dashboard. Use the URL of your own server." }, { status: 400 }); + } const wh = await addDomainWebhook(dn, url, newSecret()); return NextResponse.json({ ok: true, webhook: { id: wh.id, url: wh.url, secret: wh.secret, active: wh.active } }, { status: 201 }); } diff --git a/app/api/webhooks/[dn]/route.ts b/app/api/webhooks/[dn]/route.ts index 8010072..68b309b 100644 --- a/app/api/webhooks/[dn]/route.ts +++ b/app/api/webhooks/[dn]/route.ts @@ -1,7 +1,13 @@ import { NextRequest, NextResponse } from "next/server"; import { safeDomain } from "@/lib/config"; import { isKnownDomain, recordInboundEvent } from "@/lib/db"; -import { normalizeInboundEventType } from "@/lib/webhook-events"; +import { + MAX_RELAY_HOPS, + RELAY_HOP_HEADER, + normalizeInboundEventType, + parseRelayHop, + relayEventType, +} from "@/lib/webhook-events"; import { fireDomainEvent } from "@/lib/webhooks"; export const runtime = "nodejs"; @@ -36,7 +42,12 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ dn: string await recordInboundEvent({ dn, source, eventType, payload: bodyText }); // Fan-in → fan-out: relay the inbound event to the domain's outbound targets. - await fireDomainEvent(dn, `inbound.${eventType || "event"}`, { source, contentType: ctype, body: bodyText.slice(0, 4000) }); + // Events we already relayed carry a hop counter; past the cap we record but + // stop re-broadcasting, so a target aimed back here can't feed itself forever. + const hop = parseRelayHop(req.headers.get(RELAY_HOP_HEADER)); + if (hop < MAX_RELAY_HOPS) { + await fireDomainEvent(dn, relayEventType(eventType), { source, contentType: ctype, body: bodyText.slice(0, 4000) }, { hop }); + } return NextResponse.json({ ok: true }); } diff --git a/lib/url-guard.ts b/lib/url-guard.ts index eade374..71759f0 100644 --- a/lib/url-guard.ts +++ b/lib/url-guard.ts @@ -1,3 +1,23 @@ +/* ---- self-target guard: never POST to our own inbound receivers ---- */ +/** + * True when a webhook target points back at one of our own inbound receivers. + * Delivering there re-enters the receiver, which records the event and relays it + * again — an endless self-feeding loop. Hosts we can't recognize as ours (a + * custom domain proxied to this app) are still bounded by the relay hop cap. + */ +export function isSelfWebhookUrl(raw: string): boolean { + let u: URL; + try { u = new URL(raw); } catch { return false; } + if (!/^\/api\/webhooks(\/|$)/.test(u.pathname)) return false; + + const host = u.hostname.toLowerCase(); + if (host === "moshcoding.com" || host.endsWith(".moshcoding.com")) return true; + try { + const self = new URL(process.env.APP_BASE_URL || "https://moshcoding.com").hostname.toLowerCase(); + return host === self; + } catch { return false; } +} + /* ---- SSRF guard: never POST to internal/loopback/link-local addresses ---- */ export function isInternalUrl(raw: string): boolean { let u: URL; diff --git a/lib/webhook-events.ts b/lib/webhook-events.ts index a315d55..83db858 100644 --- a/lib/webhook-events.ts +++ b/lib/webhook-events.ts @@ -1,8 +1,32 @@ const EVENT_TYPE_RE = /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,79}$/; +/** Header carrying how many times an event has already been relayed by us. */ +export const RELAY_HOP_HEADER = "x-moshcoding-hop"; + +/** Relays stop after this many hops, so a mis-pointed target can't loop forever. */ +export const MAX_RELAY_HOPS = 3; + export function normalizeInboundEventType(value: unknown): string | null { if (typeof value !== "string") return null; const eventType = value.trim(); if (!eventType || !EVENT_TYPE_RE.test(eventType)) return null; return eventType; } + +/** + * Event type used when relaying an inbound event to a domain's outbound targets. + * The `inbound.` prefix is applied at most once: a target pointed back at our own + * receiver would otherwise re-enter with `inbound.x` and relay `inbound.inbound.x`, + * growing a prefix chain on every hop. + */ +export function relayEventType(eventType: unknown): string { + const t = normalizeInboundEventType(eventType) || "event"; + return t.startsWith("inbound.") ? t : `inbound.${t}`; +} + +/** Hop count off an incoming relay; anything unparseable counts as a first hop. */ +export function parseRelayHop(value: unknown): number { + const n = Number.parseInt(typeof value === "string" ? value : "", 10); + if (!Number.isInteger(n) || n < 0) return 0; + return Math.min(n, MAX_RELAY_HOPS); +} diff --git a/lib/webhooks.ts b/lib/webhooks.ts index 41ffb0d..4ec711e 100644 --- a/lib/webhooks.ts +++ b/lib/webhooks.ts @@ -1,31 +1,41 @@ import crypto from "node:crypto"; import { db, activeDomainWebhooks } from "./db"; -import { isInternalUrl } from "./url-guard"; +import { isInternalUrl, isSelfWebhookUrl } from "./url-guard"; +import { RELAY_HOP_HEADER } from "./webhook-events"; export { newSecret, signWebhook, verifyWebhook } from "./webhook-signing"; import { signWebhook } from "./webhook-signing"; -export { isInternalUrl }; +export { isInternalUrl, isSelfWebhookUrl }; /* ---- per-domain outbound delivery (best-effort, no owner server needed) ---- */ /** * Fires a parked-domain event to every active target URL for that domain, * Standard-Webhooks-signed with each target's secret. Best-effort and * SSRF-guarded; never throws (so it can't break the triggering request). + * + * `opts.hop` is the hop count of the event that triggered this fan-out; each + * delivery carries hop+1 so a receiver can tell how far an event has travelled. */ -export async function fireDomainEvent(dn: string, type: string, data: unknown): Promise { +export async function fireDomainEvent(dn: string, type: string, data: unknown, opts: { hop?: number } = {}): Promise { let targets: { url: string; secret: string }[] = []; try { targets = await activeDomainWebhooks(dn); } catch { return; } if (!targets.length) return; const id = "evt_" + crypto.randomBytes(12).toString("hex"); const ts = Math.floor(Date.now() / 1000); + const hop = (opts.hop ?? 0) + 1; const body = JSON.stringify({ id, type, dn, data, created_at: new Date().toISOString() }); await Promise.allSettled( targets.map(async (t) => { - if (isInternalUrl(t.url)) return; + if (isInternalUrl(t.url) || isSelfWebhookUrl(t.url)) return; try { await fetch(t.url, { method: "POST", - headers: { "content-type": "application/json", ...signWebhook(id, ts, body, t.secret) }, + headers: { + "content-type": "application/json", + "user-agent": "moshcoding-webhooks/1", + [RELAY_HOP_HEADER]: String(hop), + ...signWebhook(id, ts, body, t.secret), + }, body, signal: AbortSignal.timeout(10_000), }); @@ -68,6 +78,10 @@ export async function deliverToEndpoint( await recordDelivery(endpointId, type, body, deliveryId, "dead_letter", 1, null, "blocked: internal url"); return { ok: false, error: "internal url blocked" }; } + if (isSelfWebhookUrl(url)) { + await recordDelivery(endpointId, type, body, deliveryId, "dead_letter", 1, null, "blocked: self-referential url"); + return { ok: false, error: "self-referential url blocked" }; + } const ts = Math.floor(Date.now() / 1000); const headers = { "content-type": "application/json", diff --git a/tests/url-guard.test.mjs b/tests/url-guard.test.mjs index f00aa7b..b07826f 100644 --- a/tests/url-guard.test.mjs +++ b/tests/url-guard.test.mjs @@ -1,7 +1,18 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { isInternalUrl } from "../lib/url-guard.ts"; +import { isInternalUrl, isSelfWebhookUrl } from "../lib/url-guard.ts"; + +test("self-target guard blocks our own inbound receivers", () => { + assert.equal(isSelfWebhookUrl("https://moshcoding.com/api/webhooks/example.com"), true); + assert.equal(isSelfWebhookUrl("https://www.moshcoding.com/api/webhooks/example.com"), true); + assert.equal(isSelfWebhookUrl("https://moshcoding.com/api/webhooks/inbound/abc123"), true); + + assert.equal(isSelfWebhookUrl("https://example.com/api/webhooks/example.com"), false); + assert.equal(isSelfWebhookUrl("https://moshcoding.com/api/projects"), false); + assert.equal(isSelfWebhookUrl("https://notmoshcoding.com/api/webhooks/x"), false); + assert.equal(isSelfWebhookUrl("not a url"), false); +}); test("SSRF guard blocks private IPv6 webhook targets", () => { assert.equal(isInternalUrl("http://[::]/hook"), true); diff --git a/tests/webhook-events.test.mjs b/tests/webhook-events.test.mjs index 3fb4d64..863c07b 100644 --- a/tests/webhook-events.test.mjs +++ b/tests/webhook-events.test.mjs @@ -1,7 +1,12 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { normalizeInboundEventType } from "../lib/webhook-events.ts"; +import { + MAX_RELAY_HOPS, + normalizeInboundEventType, + parseRelayHop, + relayEventType, +} from "../lib/webhook-events.ts"; test("inbound webhook event types accept compact event names", () => { assert.equal(normalizeInboundEventType(" payment.succeeded "), "payment.succeeded"); @@ -17,3 +22,22 @@ test("inbound webhook event types reject non-string and unsafe values", () => { assert.equal(normalizeInboundEventType("payment succeeded"), null); assert.equal(normalizeInboundEventType("x".repeat(81)), null); }); + +test("relayed event types are prefixed exactly once", () => { + assert.equal(relayEventType("payment.succeeded"), "inbound.payment.succeeded"); + assert.equal(relayEventType(null), "inbound.event"); + assert.equal(relayEventType("payment succeeded"), "inbound.event"); + + // A relay that re-enters our own receiver must not grow the prefix chain. + let type = relayEventType(null); + for (let i = 0; i < 12; i++) type = relayEventType(type); + assert.equal(type, "inbound.event"); +}); + +test("relay hop counter clamps to the loop cap", () => { + assert.equal(parseRelayHop("2"), 2); + assert.equal(parseRelayHop(null), 0); + assert.equal(parseRelayHop("not-a-number"), 0); + assert.equal(parseRelayHop("-3"), 0); + assert.equal(parseRelayHop("99"), MAX_RELAY_HOPS); +});