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
5 changes: 4 additions & 1 deletion app/api/domain-webhooks/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 });
}
15 changes: 13 additions & 2 deletions app/api/webhooks/[dn]/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 });
}
20 changes: 20 additions & 0 deletions lib/url-guard.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
24 changes: 24 additions & 0 deletions lib/webhook-events.ts
Original file line number Diff line number Diff line change
@@ -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);
}
24 changes: 19 additions & 5 deletions lib/webhooks.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
export async function fireDomainEvent(dn: string, type: string, data: unknown, opts: { hop?: number } = {}): Promise<void> {
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),
});
Expand Down Expand Up @@ -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",
Expand Down
13 changes: 12 additions & 1 deletion tests/url-guard.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
Expand Down
26 changes: 25 additions & 1 deletion tests/webhook-events.test.mjs
Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -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);
});
Loading