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
1 change: 1 addition & 0 deletions api/bun.lock

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

1 change: 1 addition & 0 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
7 changes: 5 additions & 2 deletions api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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({
Expand Down Expand Up @@ -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;
}
14 changes: 14 additions & 0 deletions api/src/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// Checks configuration defaults and rejects unsafe operator settings.
import { describe, expect, test } from "bun:test";
import { loadConfig } from "./config.ts";

Expand All @@ -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",
Expand Down Expand Up @@ -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/);
}
});
19 changes: 19 additions & 0 deletions api/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
5 changes: 5 additions & 0 deletions api/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
49 changes: 49 additions & 0 deletions api/src/repositories/endpoints.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
await tx`SELECT 1 FROM owners WHERE id = ${ownerId} FOR NO KEY UPDATE`;
}
export async function countLiveEndpoints(tx: Tx, ownerId: string): Promise<number> {
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<EndpointRow> {
const [row] = await tx<EndpointRow[]>`
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<ListedEndpoint[]> {
return db<ListedEndpoint[]>`
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`;
}
30 changes: 30 additions & 0 deletions api/src/routes/endpoints.ts
Original file line number Diff line number Diff line change
@@ -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<Options> = 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(),
})) };
});
};
5 changes: 4 additions & 1 deletion api/src/routes/v1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
};
31 changes: 31 additions & 0 deletions api/src/schemas/endpoints.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
12 changes: 12 additions & 0 deletions api/src/schemas/endpoints.ts
Original file line number Diff line number Diff line change
@@ -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<typeof EndpointCreate>;
34 changes: 34 additions & 0 deletions api/src/services/endpoints.ts
Original file line number Diff line number Diff line change
@@ -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);
}
33 changes: 33 additions & 0 deletions api/src/ssrf-edges.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
}
Loading
Loading