diff --git a/api/bun.lock b/api/bun.lock index d732ff8..fc510f1 100644 --- a/api/bun.lock +++ b/api/bun.lock @@ -6,6 +6,7 @@ "dependencies": { "dbmate": "2.37.0", "fastify": "5.12.7", + "ipaddr.js": "2.5.0", "postgres": "3.4.9", "zod": "4.6.7", }, diff --git a/api/package.json b/api/package.json index d95cb73..f049d56 100644 --- a/api/package.json +++ b/api/package.json @@ -16,6 +16,7 @@ "dependencies": { "dbmate": "2.37.0", "fastify": "5.12.7", + "ipaddr.js": "2.5.0", "postgres": "3.4.9", "zod": "4.6.7" }, diff --git a/api/src/app.ts b/api/src/app.ts index 06047c5..7b5396a 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -2,6 +2,7 @@ // It never listens on a port itself. server.ts does that, so tests can call // buildApp() and send fake requests with app.inject(). import Fastify, { LogController, type FastifyInstance } from "fastify"; +import { realLookup, type Lookup } from "./ssrf.ts"; import type { Sql } from "./db.ts"; import { v1Routes } from "./routes/v1.ts"; import type { Config } from "./config.ts"; @@ -10,7 +11,7 @@ import { healthRoutes } from "./routes/health.ts"; export function buildApp( config: Config, - deps: { sql: Sql }, + deps: { sql: Sql; lookup?: Lookup }, options: { logger?: boolean; logStream?: { write(line: string): void } } = {}, ): FastifyInstance { const app = Fastify({ @@ -42,7 +43,9 @@ export function buildApp( // Step 2: only the v1 plugin inherits the authentication hook. app.register(healthRoutes, { config }); - app.register(v1Routes, { prefix: "/v1", sql: deps.sql }); + app.register(v1Routes, { + prefix: "/v1", sql: deps.sql, lookup: deps.lookup ?? realLookup, allowOrigins: config.SSRF_ALLOW_ORIGINS, + }); return app; } diff --git a/api/src/config.test.ts b/api/src/config.test.ts index 4a71085..808dfa6 100644 --- a/api/src/config.test.ts +++ b/api/src/config.test.ts @@ -1,3 +1,4 @@ +// Checks configuration defaults and rejects unsafe operator settings. import { describe, expect, test } from "bun:test"; import { loadConfig } from "./config.ts"; @@ -7,6 +8,7 @@ const DB = { DATABASE_URL: "postgres://test@localhost/test" }; describe("loadConfig", () => { test("uses safe defaults when nothing else is set", () => { expect(loadConfig(DB)).toEqual({ + SSRF_ALLOW_ORIGINS: [], PORT: 8080, GIT_SHA: "dev", LOG_LEVEL: "info", @@ -43,3 +45,15 @@ describe("loadConfig", () => { } }); }); + +// Step 1: normalize safe origins and fail startup on anything more than an origin. +test("SSRF_ALLOW_ORIGINS defaults to empty and normalizes a comma list", () => { + expect(loadConfig({ ...DB, SSRF_ALLOW_ORIGINS: "" }).SSRF_ALLOW_ORIGINS).toEqual([]); + expect(loadConfig({ ...DB, SSRF_ALLOW_ORIGINS: " http://Receiver.X:8080/, ,https://EXAMPLE.com:443 " }) + .SSRF_ALLOW_ORIGINS).toEqual(["http://receiver.x:8080", "https://example.com"]); +}); +test("SSRF_ALLOW_ORIGINS rejects paths, schemes, credentials, queries and fragments", () => { + for (const origin of ["https://x.test/path", "ftp://x.test", "https://user:pw@x.test", "https://x.test/?a=1", "https://x.test/#a", "https://x.test/?", "https://x.test/#", "not a url"]) { + expect(() => loadConfig({ ...DB, SSRF_ALLOW_ORIGINS: origin })).toThrow(/SSRF_ALLOW_ORIGINS/); + } +}); diff --git a/api/src/config.ts b/api/src/config.ts index fe59bc2..59e09d2 100644 --- a/api/src/config.ts +++ b/api/src/config.ts @@ -5,8 +5,27 @@ import { z } from "zod"; +// Step 1: only operators set these exact origins; Phase 4 adds the demo receiver. +// Empty in production until then. Customers never control this exception list. +const AllowOrigins = z.string().default("").transform((raw, ctx) => { + const origins: string[] = []; + for (const item of raw.split(",").map((value) => value.trim()).filter(Boolean)) { + try { + const url = new URL(item); + if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || + url.pathname !== "/" || url.href.includes("?") || url.href.includes("#")) throw new Error("Invalid origin"); + origins.push(url.origin); + } catch { + ctx.addIssue({ code: "custom", message: "must contain only http or https origins without credentials, paths, query or hash" }); + return z.NEVER; + } + } + return origins; +}); + // The settings the API needs. Env vars are text, so PORT is converted and checked. const Env = z.object({ + SSRF_ALLOW_ORIGINS: AllowOrigins, PORT: z .string() diff --git a/api/src/errors.ts b/api/src/errors.ts index b0992aa..1af68f0 100644 --- a/api/src/errors.ts +++ b/api/src/errors.ts @@ -18,3 +18,8 @@ export const conflict = (code: string, message: string) => new AppError(409, cod // Step 1: every authentication failure has the same public answer. export const unauthorized = () => new AppError(401, "unauthorized", "A valid API key is required"); + +// Step 2: endpoint validation and owner limits use the shared error shape. +export const invalidEndpointUrl = (message: string) => new AppError(422, "invalid_endpoint_url", message); +export const endpointLimitReached = () => + new AppError(409, "endpoint_limit_reached", "An owner can have at most 20 endpoints"); diff --git a/api/src/repositories/endpoints.ts b/api/src/repositories/endpoints.ts new file mode 100644 index 0000000..cce20ad --- /dev/null +++ b/api/src/repositories/endpoints.ts @@ -0,0 +1,49 @@ +// Stores and lists endpoints with every read restricted to its owner. +import type postgres from "postgres"; +import type { Sql } from "../db.ts"; +import type { EndpointInput } from "../schemas/endpoints.ts"; + +type Tx = postgres.TransactionSql; +export type EndpointRow = { + id: string; name: string; url: string; event_types: string[]; created_at: Date; +}; +export type ListedEndpoint = EndpointRow & { status: "active" | "failing" }; + +// Step 1: creates of the same owner queue here; other owners do not wait. +// NO KEY UPDATE, not UPDATE: we never change the owner's id, and FOR UPDATE would also make +// every event insert of this owner wait (its foreign-key check takes FOR KEY SHARE). +export async function lockOwner(tx: Tx, ownerId: string): Promise { + await tx`SELECT 1 FROM owners WHERE id = ${ownerId} FOR NO KEY UPDATE`; +} +export async function countLiveEndpoints(tx: Tx, ownerId: string): Promise { + const [row] = await tx<{ count: number }[]>` + SELECT count(*)::int AS count FROM endpoints WHERE owner_id = ${ownerId} AND deleted_at IS NULL`; + return row!.count; +} +export async function insertEndpoint(tx: Tx, ownerId: string, input: EndpointInput, signingSecret: string): Promise { + const [row] = await tx` + INSERT INTO endpoints (owner_id, name, url, event_types, signing_secret) + VALUES (${ownerId}, ${input.name}, ${input.url}, ${tx.array(input.event_types)}, ${signingSecret}) + RETURNING id, name, url, event_types, created_at`; + if (!row) throw new Error("Endpoint insert returned no row"); + return row; +} + +// Step 2: the newest delivery determines status. Ids are UUIDv7 (ordered by time), so id DESC +// is newest first and reads one row from deliveries_endpoint_id; sorting by created_at would +// read every delivery the endpoint ever had. +// This relies on Phase 4 raising attempts when a try has finished, not when claimed. +export async function listLiveEndpoints(db: Sql, ownerId: string): Promise { + return db` + SELECT e.id, e.name, e.url, e.event_types, e.created_at, + CASE WHEN latest.status = 'dead' OR (latest.status = 'pending' AND latest.attempts > 0) + THEN 'failing' ELSE 'active' END AS status + FROM endpoints e + LEFT JOIN LATERAL ( + SELECT d.status, d.attempts FROM deliveries d + WHERE d.endpoint_id = e.id AND d.owner_id = e.owner_id + ORDER BY d.id DESC LIMIT 1 + ) latest ON true + WHERE e.owner_id = ${ownerId} AND e.deleted_at IS NULL + ORDER BY e.created_at DESC, e.id DESC`; +} diff --git a/api/src/routes/endpoints.ts b/api/src/routes/endpoints.ts new file mode 100644 index 0000000..f371760 --- /dev/null +++ b/api/src/routes/endpoints.ts @@ -0,0 +1,30 @@ +// Creates and lists endpoints, exposing a signing secret only in the create response. +import type { FastifyPluginAsync } from "fastify"; +import type { Sql } from "../db.ts"; +import { unauthorized } from "../errors.ts"; +import { EndpointCreate } from "../schemas/endpoints.ts"; +import { createEndpoint, listEndpoints } from "../services/endpoints.ts"; +import type { Lookup } from "../ssrf.ts"; + +type Options = { sql: Sql; lookup: Lookup; allowOrigins: string[] }; +export const endpointRoutes: FastifyPluginAsync = async (app, options) => { + // Step 1: validation throws to the shared error handler; successful creates return 201. + app.post("/endpoints", async (request, reply) => { + if (!request.owner) throw unauthorized(); + const input = EndpointCreate.parse(request.body); + const { endpoint: e, signingSecret } = await createEndpoint(options.sql, request.owner.id, input, options); + return reply.code(201).send({ + id: e.id, name: e.name, url: e.url, event_types: e.event_types, + status: "active", created_at: e.created_at.toISOString(), signing_secret: signingSecret, + }); + }); + // Step 2: build every public field explicitly; the secret never enters a list response. + app.get("/endpoints", async (request) => { + if (!request.owner) throw unauthorized(); + const endpoints = await listEndpoints(options.sql, request.owner.id); + return { data: endpoints.map((e) => ({ + id: e.id, name: e.name, url: e.url, event_types: e.event_types, + status: e.status, created_at: e.created_at.toISOString(), + })) }; + }); +}; diff --git a/api/src/routes/v1.ts b/api/src/routes/v1.ts index 6f69d13..bca414b 100644 --- a/api/src/routes/v1.ts +++ b/api/src/routes/v1.ts @@ -2,10 +2,13 @@ import type { FastifyPluginAsync } from "fastify"; import { authenticate } from "../auth/authenticate.ts"; import type { Sql } from "../db.ts"; +import type { Lookup } from "../ssrf.ts"; +import { endpointRoutes } from "./endpoints.ts"; import { meRoutes } from "./me.ts"; // Step 1: child routes inherit this hook through Fastify encapsulation. -export const v1Routes: FastifyPluginAsync<{ sql: Sql }> = async (app, { sql }) => { +export const v1Routes: FastifyPluginAsync<{ sql: Sql; lookup: Lookup; allowOrigins: string[] }> = async (app, { sql, lookup, allowOrigins }) => { app.addHook("onRequest", authenticate(sql)); app.register(meRoutes); + app.register(endpointRoutes, { sql, lookup, allowOrigins }); }; diff --git a/api/src/schemas/endpoints.test.ts b/api/src/schemas/endpoints.test.ts new file mode 100644 index 0000000..7483860 --- /dev/null +++ b/api/src/schemas/endpoints.test.ts @@ -0,0 +1,31 @@ +// Checks endpoint input independently of DNS and the database. +import { expect, test } from "bun:test"; +import { EndpointCreate } from "./endpoints.ts"; + +const valid = { name: "Acme", url: "https://example.com/hook" }; +// Step 1: reject malformed names, URLs, event lists and unknown fields, each for its own +// reason: the issue path proves the case failed on the field it is about ("" = the body itself). +const invalid: [string, object, string][] = [ + ["an empty name", { ...valid, name: "" }, "name"], + ["a 101-character name", { ...valid, name: "x".repeat(101) }, "name"], + ["a NUL in the name", { ...valid, name: "a\u0000b" }, "name"], + ["a missing url", { name: "Acme" }, "url"], + ["a 2049-character url", { ...valid, url: "x".repeat(2049) }, "url"], + ["empty event_types", { ...valid, event_types: [] }, "event_types"], + ["21 event types", { ...valid, event_types: Array(21).fill("a") }, "event_types"], + ["an event type with a space", { ...valid, event_types: ["Bad Type"] }, "event_types.0"], + ["an unknown field", { ...valid, unknown: true }, ""], +]; +for (const [label, input, path] of invalid) { + test(`rejects ${label}`, () => { + const result = EndpointCreate.safeParse(input); + expect(result.success).toBe(false); + expect([...new Set(result.error?.issues.map((issue) => issue.path.join(".")))]).toEqual([path]); + }); +} +// Step 2: defaults and duplicates are resolved before the service sees the input. +test("event types default to wildcard", () => expect(EndpointCreate.parse(valid).event_types).toEqual(["*"])); +test("event type duplicates keep the first occurrence", () => { + expect(EndpointCreate.parse({ ...valid, event_types: ["invoice.paid", "*", "invoice.paid", "a", "*"] }).event_types) + .toEqual(["invoice.paid", "*", "a"]); +}); diff --git a/api/src/schemas/endpoints.ts b/api/src/schemas/endpoints.ts new file mode 100644 index 0000000..128d8f4 --- /dev/null +++ b/api/src/schemas/endpoints.ts @@ -0,0 +1,12 @@ +// Validates endpoint input, applying defaults and keeping the first of duplicate event types. +import { z } from "zod"; +import { OwnerName } from "./owners.ts"; + +// Step 1: reject unknown fields before the service checks where the URL leads. +export const EndpointCreate = z.object({ + name: OwnerName, + url: z.string().max(2048), + event_types: z.array(z.union([z.literal("*"), z.string().regex(/^[a-z0-9][a-z0-9_.-]{0,99}$/)])) + .min(1).max(20).transform((types) => [...new Set(types)]).default(["*"]), +}).strict(); +export type EndpointInput = z.infer; diff --git a/api/src/services/endpoints.ts b/api/src/services/endpoints.ts new file mode 100644 index 0000000..3a5a2d5 --- /dev/null +++ b/api/src/services/endpoints.ts @@ -0,0 +1,34 @@ +// Checks endpoint destinations before saving them and coordinates owner-scoped endpoint rules. +import { randomBytes } from "node:crypto"; +import type { Sql } from "../db.ts"; +import { endpointLimitReached } from "../errors.ts"; +import { countLiveEndpoints, insertEndpoint, listLiveEndpoints, lockOwner } from "../repositories/endpoints.ts"; +import type { EndpointInput } from "../schemas/endpoints.ts"; +import { checkEndpointUrl, type Lookup } from "../ssrf.ts"; + +export const MAX_ENDPOINTS = 20; +export function generateSigningSecret(): string { + return "whsec_" + randomBytes(32).toString("base64"); +} + +export async function createEndpoint( + sql: Sql, ownerId: string, input: EndpointInput, options: { lookup: Lookup; allowOrigins: string[] }, +) { + // Step 1: DNS can take seconds; no lock or connection is held while waiting on the network. + await checkEndpointUrl(input.url, options); + // Step 2: generate the secret, shown only in the create response. + const secret = generateSigningSecret(); + // Step 3: the owner limit and insert belong in the same transaction. + return sql.begin(async (tx) => { + // Step 4: lock the owner's row first, so two creates of the same owner take turns. + await lockOwner(tx, ownerId); + // Step 5: count only after the lock, in its own statement, so it sees the other create's row. + if ((await countLiveEndpoints(tx, ownerId)) >= MAX_ENDPOINTS) throw endpointLimitReached(); + const endpoint = await insertEndpoint(tx, ownerId, input, secret); + return { endpoint, signingSecret: secret }; + }); +} + +export async function listEndpoints(sql: Sql, ownerId: string) { + return listLiveEndpoints(sql, ownerId); +} diff --git a/api/src/ssrf-edges.test.ts b/api/src/ssrf-edges.test.ts new file mode 100644 index 0000000..74ded8f --- /dev/null +++ b/api/src/ssrf-edges.test.ts @@ -0,0 +1,33 @@ +// Checks first and last IPv4 addresses in reserved ranges and ordinary public neighbours. +import { expect, test } from "bun:test"; +import { checkEndpointUrl } from "./ssrf.ts"; + +const options = { allowOrigins: [], lookup: async () => { throw new Error("Literal must not resolve"); } }; +// Step 1: both ends of every reserved block are refused. +const ranges = [ + ["0.0.0.0", "0.255.255.255"], ["10.0.0.0", "10.255.255.255"], + ["100.64.0.0", "100.127.255.255"], ["127.0.0.0", "127.255.255.255"], + ["169.254.0.0", "169.254.255.255"], ["172.16.0.0", "172.31.255.255"], + ["192.0.0.0", "192.0.0.255"], ["192.0.2.0", "192.0.2.255"], + ["192.88.99.0", "192.88.99.255"], ["192.168.0.0", "192.168.255.255"], + ["198.18.0.0", "198.19.255.255"], ["198.51.100.0", "198.51.100.255"], + ["203.0.113.0", "203.0.113.255"], ["224.0.0.0", "239.255.255.255"], + ["240.0.0.0", "255.255.255.255"], +]; +for (const address of ranges.flat()) { + test(`refuses reserved edge ${address}`, async () => { + await expect(checkEndpointUrl(`https://${address}/`, options)).rejects.toMatchObject({ + status: 422, code: "invalid_endpoint_url", message: "Endpoint URL must point only to public internet addresses", + }); + }); +} +// Step 2: ordinary addresses immediately outside the blocks are accepted. +for (const address of ["1.0.0.0", "9.255.255.255", "11.0.0.0", "100.63.255.255", "100.128.0.0", + "126.255.255.255", "128.0.0.0", "169.253.255.255", "169.255.0.0", "172.15.255.255", "172.32.0.0", + "191.255.255.255", "192.0.1.0", "192.0.1.255", "192.0.3.0", "192.88.98.255", "192.88.100.0", + "192.167.255.255", "192.169.0.0", "198.17.255.255", "198.20.0.0", "198.51.99.255", "198.51.101.0", + "203.0.112.255", "203.0.114.0", "223.255.255.255"]) { + test(`accepts public neighbour ${address}`, async () => { + await expect(checkEndpointUrl(`https://${address}/`, options)).resolves.toBeUndefined(); + }); +} diff --git a/api/src/ssrf.test.ts b/api/src/ssrf.test.ts new file mode 100644 index 0000000..3694c01 --- /dev/null +++ b/api/src/ssrf.test.ts @@ -0,0 +1,110 @@ +// Defines URL validation with fake DNS, apart from the explicit localhost resolver check. +import { expect, test } from "bun:test"; +import { allAddressesPublic, checkEndpointUrl, realLookup, type Lookup } from "./ssrf.ts"; + +const PUBLIC = "Endpoint URL must point only to public internet addresses"; +const fake: Lookup = async () => ["93.184.215.14"]; +async function refused(raw: string, message: string, lookup: Lookup = fake, timeoutMs?: number) { + await expect(checkEndpointUrl(raw, { lookup, timeoutMs, allowOrigins: [] })) + .rejects.toMatchObject({ status: 422, code: "invalid_endpoint_url", message }); +} + +// Step 1: shape and local names are rejected before DNS can leak information. +const shapes: [string, string][] = [ + ["http://example.com/", "Endpoint URL must use https"], + ["https://user:pw@example.com/", "Endpoint URL must not contain a user name or password"], + ["https://example.com:8443/", "Endpoint URL must use the default https port (443)"], + ["https://example.com/a\u0000b", "Endpoint URL must be a valid https URL"], + ["https://exa\tmple.com/", "Endpoint URL must be a valid https URL"], + ["not a url", "Endpoint URL must be a valid https URL"], +]; +for (const [raw, message] of shapes) test(`refuses shape ${JSON.stringify(raw)}`, () => refused(raw, message)); +for (const host of ["localhost", "LOCALHOST.", "intranet", "db.relay.svc.cluster.local", "x.internal", "x.local", "x.localhost", "x.svc"]) { + test(`refuses local name ${host} without lookup`, async () => { + let calls = 0; + await refused(`https://${host}/`, PUBLIC, async () => { calls++; return []; }); + expect(calls).toBe(0); + }); +} + +// Step 2: literals bypass DNS, including addresses normalized by the URL parser. +for (const host of ["127.0.0.1", "0x7f.1", "2130706433", "127.1", "0", "10.43.0.1", "169.254.169.254", + "100.64.0.1", "81.88.23.71", "[::1]", "[::]", "[::ffff:127.0.0.1]", "[::ffff:7f00:1]", + "[::127.0.0.1]", "[::10.0.0.1]", "[1::1]", "[4000::1]", "[fe80::1]", "[fd00::1]", + "[64:ff9b::7f00:1]", "[64:ff9b:1::1]", "[2002:7f00:1::]", "[2001::1]", "[2001:db8::1]", "[::ffff:81.88.23.71]"]) { + test(`refuses literal ${host} without lookup`, async () => { + let calls = 0; + await refused(`https://${host}/`, PUBLIC, async () => { calls++; return []; }); + expect(calls).toBe(0); + }); +} +test("mixed public/private DNS is refused", () => refused("https://example.com/", PUBLIC, async () => ["8.8.8.8", "10.0.0.5"])); +test("empty DNS is refused", () => refused("https://example.com/", PUBLIC, async () => [])); +test("DNS errors are refused", () => refused("https://example.com/", PUBLIC, async () => { + throw Object.assign(new Error("not found"), { code: "ENOTFOUND" }); +})); +test("DNS timeout refuses a lookup that never answers", () => + refused("https://example.com/", PUBLIC, () => new Promise(() => {}), 20)); +test("late lookup rejection is handled after the timeout", async () => { + await refused("https://example.com/", PUBLIC, () => new Promise((_, reject) => { + setTimeout(() => reject(new Error("late DNS failure")), 30); + }), 5); + await Bun.sleep(40); +}); +test("private and unresolved destinations have identical error bodies", async () => { + async function body(lookup: Lookup) { + try { await checkEndpointUrl("https://example.com/", { lookup, allowOrigins: [] }); } + catch (error) { + const e = error as { code: string; message: string }; + return { error: { code: e.code, message: e.message } }; + } + throw new Error("Expected rejection"); + } + expect(await body(async () => ["10.0.0.1"])).toEqual(await body(async () => { throw new Error("ENOTFOUND"); })); +}); + +// Step 3: valid public URLs remain red until allAddressesPublic is implemented. +for (const raw of ["https://8.8.8.8/", "https://[2606:4700::1111]/", "https://example.com/", "https://example.com:443/"]) { + test(`accepts public ${raw}`, async () => { + await expect(checkEndpointUrl(raw, { allowOrigins: [], lookup: async () => + ["93.184.215.14", "2606:2800:21f:cb07:6820:80da:af6b:8b2c"] })).resolves.toBeUndefined(); + }); +} +test("lookup receives the hostname with its final dot", async () => { + let received = ""; + await refused("https://example.com./", PUBLIC, async (hostname) => { received = hostname; return []; }); + expect(received).toBe("example.com."); +}); +test("realLookup localhost includes IPv4 loopback", async () => { + const addresses = await realLookup("localhost"); + expect(addresses).toContain("127.0.0.1"); + expect(addresses.every((address) => typeof address === "string")).toBe(true); +}); + +// Step 4: operators may allow only an exact origin, never credentials. +const origin = "http://receiver.relay-demo.svc.cluster.local:8080"; +test("exact operator origin skips DNS and address checks", async () => { + let calls = 0; + await expect(checkEndpointUrl(origin + "/hook", { allowOrigins: [origin], lookup: async () => { + calls++; return []; + } })).resolves.toBeUndefined(); + expect(calls).toBe(0); +}); +for (const raw of [origin.replace(":8080", ":8081"), origin.replace("http:", "https:"), + origin.replace("receiver.", "other."), origin.replace("http://", "http://user:pw@")]) { + test(`operator exception refuses ${raw}`, async () => { + await expect(checkEndpointUrl(raw, { allowOrigins: [origin], lookup: fake })) + .rejects.toMatchObject({ status: 422, code: "invalid_endpoint_url" }); + }); +} + +// Step 5: the address rule is safe on its own, not only behind checkEndpointUrl. +test("allAddressesPublic fails closed on its own", () => { + expect(allAddressesPublic([])).toBe(false); + expect(allAddressesPublic(["not-an-ip"])).toBe(false); + expect(allAddressesPublic(["8.8.8.8", "not-an-ip"])).toBe(false); +}); +test("allAddressesPublic judges a mapped address as the IPv4 it connects to", () => { + expect(allAddressesPublic(["::ffff:8.8.8.8"])).toBe(true); + expect(allAddressesPublic(["::ffff:7f00:1"])).toBe(false); +}); diff --git a/api/src/ssrf.ts b/api/src/ssrf.ts new file mode 100644 index 0000000..e821802 --- /dev/null +++ b/api/src/ssrf.ts @@ -0,0 +1,75 @@ +// Checks endpoint URL shape and resolved addresses before a customer URL can be stored. +import { lookup } from "node:dns/promises"; +import { isIP } from "node:net"; +import ipaddr from "ipaddr.js"; +import { invalidEndpointUrl } from "./errors.ts"; + +const VPS_ADDRESS = "81.88.23.71"; +const GLOBAL_UNICAST_V6 = ipaddr.IPv6.parseCIDR("2000::/3"); +const LOOKUP_TIMEOUT_MS = 3_000; +const PUBLIC_MESSAGE = "Endpoint URL must point only to public internet addresses"; +export type Lookup = (hostname: string) => Promise; +export const realLookup: Lookup = async (hostname) => + (await lookup(hostname, { all: true })).map((answer) => answer.address); + +export async function checkEndpointUrl( + raw: string, + options: { lookup: Lookup; allowOrigins: string[]; timeoutMs?: number }, +): Promise { + // Step 1: reject text the URL parser would silently alter before checking its origin. + if (/[\p{Cc}\s]/u.test(raw)) throw invalidEndpointUrl("Endpoint URL must be a valid https URL"); + let url: URL; + try { + url = new URL(raw); + } catch { + throw invalidEndpointUrl("Endpoint URL must be a valid https URL"); + } + if (url.username || url.password) throw invalidEndpointUrl("Endpoint URL must not contain a user name or password"); + if (options.allowOrigins.includes(url.origin)) return; + if (url.protocol !== "https:") throw invalidEndpointUrl("Endpoint URL must use https"); + if (url.port !== "") throw invalidEndpointUrl("Endpoint URL must use the default https port (443)"); + + // Step 2: literal addresses need no DNS; URL already normalizes unusual IPv4 spellings. + const hostname = url.hostname; + let addresses: string[]; + if (hostname.startsWith("[")) { + addresses = [hostname.slice(1, -1)]; + } else if (isIP(hostname) === 4) { + addresses = [hostname]; + } else { + // Step 3: compare without a final dot, but resolve the name exactly as written. + const name = hostname.replace(/\.$/, ""); + if (name === "localhost" || !name.includes(".") || + [".local", ".localhost", ".internal", ".svc", ".cluster.local"].some((suffix) => name.endsWith(suffix))) { + throw invalidEndpointUrl(PUBLIC_MESSAGE); + } + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("Lookup timed out")), options.timeoutMs ?? LOOKUP_TIMEOUT_MS); + }); + try { + addresses = await Promise.race([options.lookup(hostname), timeout]); + if (addresses.length === 0) throw invalidEndpointUrl(PUBLIC_MESSAGE); + } catch { + throw invalidEndpointUrl(PUBLIC_MESSAGE); + } finally { + clearTimeout(timer); + } + } + if (!allAddressesPublic(addresses)) throw invalidEndpointUrl(PUBLIC_MESSAGE); +} + +// Step 4: every address must be a plain internet address. +export function allAddressesPublic(addresses: string[]): boolean { + if (addresses.length === 0) return false; + for (const address of addresses) { + try { + const parsed = ipaddr.process(address); + if (parsed.range() !== "unicast" || parsed.toString() === VPS_ADDRESS) return false; + if (parsed instanceof ipaddr.IPv6 && !parsed.match(GLOBAL_UNICAST_V6)) return false; + } catch { + return false; + } + } + return true; +} diff --git a/api/test/integration/endpoints-fixture.ts b/api/test/integration/endpoints-fixture.ts new file mode 100644 index 0000000..d0f1d37 --- /dev/null +++ b/api/test/integration/endpoints-fixture.ts @@ -0,0 +1,41 @@ +// Gives each endpoint test file its own owners, app and cleanup without sharing customer rows. +import { afterAll, beforeAll } from "bun:test"; +import { buildApp } from "../../src/app.ts"; +import { loadConfig } from "../../src/config.ts"; +import { createOwnerWithKey } from "../../src/services/owners.ts"; +import type { Lookup } from "../../src/ssrf.ts"; +import { DATABASE_URL, testDb } from "./helpers.ts"; + +// Step 1: fixture ownership stays inside the calling test file. +export function endpointFixture() { + const sql = testDb(); + const owners: Awaited>[] = []; + const lines: string[] = []; + let lookup: Lookup = async () => ["93.184.215.14"]; + const app = buildApp(loadConfig({ DATABASE_URL }), { sql, lookup: (host) => lookup(host) }, { + logStream: { write: (line) => { lines.push(line); } }, + }); + async function owner() { + const created = await createOwnerWithKey(sql, `endpoints-${crypto.randomUUID()}`); + owners.push(created); + return created; + } + beforeAll(async () => { await app.ready(); }); + afterAll(async () => { + try { + await app.close(); + for (const created of owners) await sql`DELETE FROM owners WHERE id = ${created.owner.id}`; + } finally { + await sql.end({ timeout: 5 }); + } + }); + // Step 2: requests exercise the real authentication and error handler. + function post(key: string, input: object = {}) { + return app.inject({ method: "POST", url: "/v1/endpoints", headers: { authorization: `Bearer ${key}` }, + payload: { name: "Webhook", url: "https://example.com/hook", ...input } }); + } + function list(key: string) { + return app.inject({ url: "/v1/endpoints", headers: { authorization: `Bearer ${key}` } }); + } + return { sql, app, owner, post, list, lines, setLookup: (next: Lookup) => { lookup = next; } }; +} diff --git a/api/test/integration/endpoints-limit.test.ts b/api/test/integration/endpoints-limit.test.ts new file mode 100644 index 0000000..a911a0b --- /dev/null +++ b/api/test/integration/endpoints-limit.test.ts @@ -0,0 +1,80 @@ +// Checks endpoint limits with controlled database locks, without timing guesses. +import { afterAll, expect, test } from "bun:test"; +import { lockOwner } from "../../src/repositories/endpoints.ts"; +import { endpointFixture } from "./endpoints-fixture.ts"; +import { testDb } from "./helpers.ts"; + +const f = endpointFixture(); +const blocker = testDb({ poolMax: 1 }); +afterAll(() => blocker.end({ timeout: 5 })); +async function seed(key: string, count: number) { + for (let i = 0; i < count; i++) expect((await f.post(key, { name: `Webhook ${i}` })).statusCode).toBe(201); +} +// Step 1: only live endpoints consume an owner's quota. +test("20 creates succeed, the 21st is refused, and soft deletion frees a slot", async () => { + const a = await f.owner(); + await seed(a.key, 20); + const refused = await f.post(a.key); + expect(refused.statusCode).toBe(409); + expect(refused.json().error.code).toBe("endpoint_limit_reached"); + await f.sql`UPDATE endpoints SET deleted_at = now() + WHERE owner_id = ${a.owner.id} AND id = ( + SELECT id FROM endpoints WHERE owner_id = ${a.owner.id} AND deleted_at IS NULL ORDER BY id LIMIT 1)`; + expect((await f.post(a.key)).statusCode).toBe(201); +}, 15_000); + +// Step 2: freeze inserts so both contenders reach the lock before the blocker commits. +test("two simultaneous creates at 19 produce one 201, one 409 and 20 live endpoints", async () => { + const a = await f.owner(); + await seed(a.key, 19); + const ready = Promise.withResolvers(); + const release = Promise.withResolvers(); + const holding = blocker.begin(async (tx) => { + await tx`LOCK TABLE endpoints IN SHARE MODE`; + ready.resolve(); + await release.promise; + }); + // Handle a setup failure immediately, so it cannot become an unhandled rejection. + holding.catch((error) => ready.reject(error)); + await ready.promise; + const responses = Promise.all([f.post(a.key), f.post(a.key)]); + try { + const deadline = performance.now() + 5_000; + while (true) { + const [row] = await f.sql`SELECT count(*)::int AS count FROM pg_stat_activity + WHERE datname = current_database() AND application_name = 'relay-api' AND wait_event_type = 'Lock'`; + if (row && row.count >= 2) break; + if (performance.now() >= deadline) throw new Error("Two creates did not reach their database locks"); + await Bun.sleep(10); + } + } finally { + release.resolve(); + await holding; + await responses; + } + expect((await responses).map((res) => res.statusCode).sort()).toEqual([201, 409]); + const [row] = await f.sql`SELECT count(*)::int AS count FROM endpoints + WHERE owner_id = ${a.owner.id} AND deleted_at IS NULL`; + expect(row?.count).toBe(20); +}, 15_000); + +// Step 3: a different owner's create must not wait for the owner whose row is locked. +test("locking owner A still lets owner B create before the transaction ends", async () => { + const a = await f.owner(), b = await f.owner(); + await blocker.begin(async (tx) => { + await tx`SELECT 1 FROM owners WHERE id = ${a.owner.id} FOR UPDATE`; + expect((await f.post(b.key)).statusCode).toBe(201); + }); +}, 10_000); + +// Step 4: the owner lock queues endpoint creates only; that owner's events never wait for it. +test("the owner lock of an endpoint create doesn't block the owner's event inserts", async () => { + const a = await f.owner(); + await blocker.begin(async (tx) => { + await lockOwner(tx, a.owner.id); + await f.sql.begin(async (other) => { + await other`SET LOCAL lock_timeout = '1s'`; + await other`INSERT INTO events (owner_id, type, payload) VALUES (${a.owner.id}, 'test.lock', '{}'::jsonb)`; + }); + }); +}, 10_000); diff --git a/api/test/integration/endpoints-status.test.ts b/api/test/integration/endpoints-status.test.ts new file mode 100644 index 0000000..de9c875 --- /dev/null +++ b/api/test/integration/endpoints-status.test.ts @@ -0,0 +1,35 @@ +// Checks that the newest delivery determines endpoint health and lists are newest first. +import { expect, test } from "bun:test"; +import { endpointFixture } from "./endpoints-fixture.ts"; + +const f = endpointFixture(); +// Step 1: each insert gets a later UUIDv7, so the last one inserted is the newest delivery. +test("newest dead, delivered and pending deliveries determine endpoint status", async () => { + const a = await f.owner(); + const created = await f.post(a.key); + expect(created.statusCode).toBe(201); + const endpointId = created.json().id; + for (const [status, attempts, code, expected] of [ + ["dead", 8, 500, "failing"], ["delivered", 1, 200, "active"], + ["pending", 1, 500, "failing"], ["pending", 0, null, "active"], + ] as const) { + const [event] = await f.sql`INSERT INTO events (owner_id, type, payload) + VALUES (${a.owner.id}, 'test.status', '{}'::jsonb) RETURNING id`; + await f.sql`INSERT INTO deliveries (owner_id, event_id, endpoint_id, status, attempts, last_status_code) + VALUES (${a.owner.id}, ${event!.id}, ${endpointId}, ${status}, ${attempts}, ${code})`; + const listed = await f.list(a.key); + expect(listed.statusCode).toBe(200); + expect(listed.json().data[0].status).toBe(expected); + } +}); +// Step 2: deleted rows are hidden and creation order is deterministic. +test("lists live endpoints newest first", async () => { + const a = await f.owner(); + const first = await f.post(a.key, { name: "First" }); + const second = await f.post(a.key, { name: "Second" }); + expect(first.statusCode).toBe(201); + expect(second.statusCode).toBe(201); + expect((await f.list(a.key)).json().data.map((e: { name: string }) => e.name)).toEqual(["Second", "First"]); + await f.sql`UPDATE endpoints SET deleted_at = now() WHERE owner_id = ${a.owner.id} AND id = ${second.json().id}`; + expect((await f.list(a.key)).json().data.map((e: { name: string }) => e.name)).toEqual(["First"]); +}); diff --git a/api/test/integration/endpoints.test.ts b/api/test/integration/endpoints.test.ts new file mode 100644 index 0000000..16b42b5 --- /dev/null +++ b/api/test/integration/endpoints.test.ts @@ -0,0 +1,85 @@ +// Checks endpoint creation, validation, owner separation and safe failure responses. +import { expect, test } from "bun:test"; +import { endpointFixture } from "./endpoints-fixture.ts"; + +const f = endpointFixture(); +// Step 1: create returns the secret once, while lists expose only public fields. +test("201 creates an active endpoint and GET hides its signing secret", async () => { + const a = await f.owner(); + const res = await f.post(a.key); + expect(res.statusCode).toBe(201); + const e = res.json(); + expect(e.signing_secret).toMatch(/^whsec_[A-Za-z0-9+/]{43}=$/); + expect(e.event_types).toEqual(["*"]); + expect(e.status).toBe("active"); + expect(new Date(e.created_at).toISOString()).toBe(e.created_at); + const listed = await f.list(a.key); + expect(listed.statusCode).toBe(200); + const { signing_secret, ...publicFields } = e; + expect(listed.json()).toEqual({ data: [publicFields] }); + expect(listed.body).not.toContain("signing_secret"); + expect(listed.body).not.toContain(signing_secret); +}); +test("event arrays round-trip after removing duplicates", async () => { + const a = await f.owner(); + const res = await f.post(a.key, { event_types: ["invoice.paid", "*", "invoice.paid", "order.created"] }); + expect(res.statusCode).toBe(201); + expect(res.json().event_types).toEqual(["invoice.paid", "*", "order.created"]); + expect((await f.list(a.key)).json().data[0].event_types).toEqual(["invoice.paid", "*", "order.created"]); +}); + +// Step 2: invalid bodies and destinations must use the one public error shape. +for (const input of [{ extra: true }, { name: "x".repeat(101) }]) { + test(`invalid body ${Object.keys(input)[0]} returns 422 validation_failed`, async () => { + const a = await f.owner(); + const res = await f.post(a.key, input); + expect(res.statusCode).toBe(422); + expect(res.json().error.code).toBe("validation_failed"); + }); +} +test("http URL returns 422 invalid_endpoint_url", async () => { + const a = await f.owner(); + const res = await f.post(a.key, { url: "http://example.com/" }); + expect(res.statusCode).toBe(422); + expect(res.json().error.code).toBe("invalid_endpoint_url"); +}); +test("private DNS returns 422 invalid_endpoint_url", async () => { + const a = await f.owner(); + f.setLookup(async () => ["10.0.0.5"]); + try { + const res = await f.post(a.key); + expect(res.statusCode).toBe(422); + expect(res.json().error.code).toBe("invalid_endpoint_url"); + } finally { f.setLookup(async () => ["93.184.215.14"]); } +}); +test("requests without a key return 401", async () => { + for (const method of ["GET", "POST"] as const) { + expect((await f.app.inject({ method, url: "/v1/endpoints" })).statusCode).toBe(401); + } +}); +test("owner B cannot list owner A's endpoint", async () => { + const a = await f.owner(), b = await f.owner(); + expect((await f.post(a.key)).statusCode).toBe(201); + const res = await f.list(b.key); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ data: [] }); +}); + +// Step 3: an owner deleted after DNS must not leave a row or leak the generated secret. +test("failure after DNS saves nothing and never logs a signing secret", async () => { + const a = await f.owner(); + f.setLookup(async () => { + await f.sql`DELETE FROM owners WHERE id = ${a.owner.id}`; + return ["93.184.215.14"]; + }); + const start = f.lines.length; + try { + const res = await f.post(a.key); + expect(res.statusCode).toBe(500); + expect(res.json().error.code).toBe("internal_error"); + expect(res.body).not.toContain("whsec_"); + expect(f.lines.slice(start).join("")).not.toContain("whsec_"); + const [row] = await f.sql`SELECT count(*)::int AS count FROM endpoints WHERE owner_id = ${a.owner.id}`; + expect(row?.count).toBe(0); + } finally { f.setLookup(async () => ["93.184.215.14"]); } +}); diff --git a/docs/api.md b/docs/api.md index df373e9..53e2624 100644 --- a/docs/api.md +++ b/docs/api.md @@ -168,10 +168,15 @@ Body: | Field | Rule | |---|---| -| `name` | string, 1–100 characters | -| `url` | `https` URL, at most 2048 characters, must resolve only to public addresses | +| `name` | string, 1–100 characters, no control characters | +| `url` | `https` on the default port, no user name or password, no spaces or control characters, at most 2048 characters, must point only to public internet addresses | | `event_types` | optional, 1–20 strings, each an exact event type or `"*"`; default `["*"]` | +Unknown fields → 422 `validation_failed`. Every refusal based on where the URL leads uses +one message: "Endpoint URL must point only to public internet addresses". +The operator allow-list (`SSRF_ALLOW_ORIGINS`) permits exact origins, is set only by us, +and is empty by default. + 201 → `EndpointCreated` (with `signing_secret`, shown only here). An owner can have at most 20 endpoints at a time; deleted ones don't count. Errors: 401, 409 `endpoint_limit_reached`, 422 `validation_failed`, 422 `invalid_endpoint_url`.