diff --git a/docs/webhook-url-policy.md b/docs/webhook-url-policy.md new file mode 100644 index 0000000..260c638 --- /dev/null +++ b/docs/webhook-url-policy.md @@ -0,0 +1,51 @@ +# Webhook Callback URL Policy + +To prevent Server-Side Request Forgery (SSRF), webhook callback URLs are +validated both **at registration time** (`POST /api/indexer/webhooks`) and +**at delivery time** (in `webhookService`). The delivery-time check re-resolves +the host to defend against DNS rebinding. + +Validation is implemented in `src/utils/ssrfGuard.ts`. + +## Allowed + +- Scheme must be `http` or `https`. +- The host must resolve **only** to public, globally routable IP addresses. + +## Blocked + +A callback URL is rejected (HTTP `400` at registration; delivery is aborted and +logged) when its scheme is not http/https, when its host cannot be resolved, or +when **any** resolved address falls in a reserved range: + +### IPv4 + +| Range | Description | +| ---------------- | -------------------------------------------------- | +| `0.0.0.0/8` | "This" network | +| `10.0.0.0/8` | RFC1918 private | +| `100.64.0.0/10` | Carrier-grade NAT | +| `127.0.0.0/8` | Loopback (e.g. `127.0.0.1`) | +| `169.254.0.0/16` | Link-local, incl. cloud metadata `169.254.169.254` | +| `172.16.0.0/12` | RFC1918 private | +| `192.0.0.0/24` | IETF protocol assignments | +| `192.0.2.0/24` | TEST-NET-1 | +| `192.168.0.0/16` | RFC1918 private | +| `198.18.0.0/15` | Benchmarking | +| `224.0.0.0/4` | Multicast | +| `240.0.0.0/4` | Reserved, incl. `255.255.255.255` | + +### IPv6 + +| Range | Description | +| --------------- | ------------------------------------------------ | +| `::/128` | Unspecified | +| `::1/128` | Loopback | +| `fc00::/7` | Unique local addresses | +| `fe80::/10` | Link-local | +| `::ffff:0:0/96` | IPv4-mapped — evaluated against IPv4 rules above | + +## Out of scope + +- Allowlist UI for trusted destinations. +- Routing outbound webhook traffic through an egress proxy. diff --git a/package-lock.json b/package-lock.json index ea9cd34..0de072f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -133,6 +133,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2490,6 +2491,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -2511,6 +2513,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.6.0.tgz", "integrity": "sha512-L8UyDwqpTcbkIK5cgwDRDYDoEhQoj8wp8BwsO19w3LB1Z41yEQm2VJyNfAi9DrLP/YTqXqWpKHyZfR9/tFYo1Q==", "license": "Apache-2.0", + "peer": true, "engines": { "node": "^18.19.0 || >=20.6.0" }, @@ -2523,6 +2526,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.0.tgz", "integrity": "sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -2942,6 +2946,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.0.tgz", "integrity": "sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.6.0", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -2958,6 +2963,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.0.tgz", "integrity": "sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.6.0", "@opentelemetry/resources": "2.6.0", @@ -2975,6 +2981,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz", "integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=14" } @@ -3087,6 +3094,7 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.11.0.tgz", "integrity": "sha512-GHoprlNQD51Xq2Ztd94HHV94MdFZQ3CVrpA04Fz8MVoHM0B7SlbmPEVIjwTbcv58z8QyjnrOuikS0rWF03k5dQ==", "license": "MIT", + "peer": true, "dependencies": { "cluster-key-slot": "1.1.2" }, @@ -3644,6 +3652,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.0.tgz", "integrity": "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3825,6 +3834,7 @@ "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -4074,6 +4084,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4545,6 +4556,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5699,6 +5711,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5755,6 +5768,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -6177,6 +6191,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -10729,6 +10744,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.11.0", "pg-pool": "^3.11.0", @@ -10920,6 +10936,7 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -12155,6 +12172,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12313,6 +12331,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -12557,6 +12576,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/__tests__/webhookService.test.ts b/src/__tests__/webhookService.test.ts index f68d0c8..cbe2d3d 100644 --- a/src/__tests__/webhookService.test.ts +++ b/src/__tests__/webhookService.test.ts @@ -13,6 +13,14 @@ jest.unstable_mockModule("../db/connection.js", () => ({ closePool: jest.fn(), })); +// Bypass SSRF DNS resolution so delivery tests don't depend on real DNS. +jest.unstable_mockModule("../utils/ssrfGuard.js", () => ({ + assertCallbackUrlAllowed: jest.fn(async () => undefined), + parseCallbackUrl: jest.fn(), + isBlockedIp: jest.fn(), + SsrfValidationError: class SsrfValidationError extends Error {}, +})); + const { WebhookService, getRetryDelayMs } = await import("../services/webhookService.js"); const { default: logger } = await import("../utils/logger.js"); diff --git a/src/controllers/indexerController.ts b/src/controllers/indexerController.ts index dfd41ae..6a64df6 100644 --- a/src/controllers/indexerController.ts +++ b/src/controllers/indexerController.ts @@ -17,6 +17,10 @@ import { parseQueryParams, } from "../utils/pagination.js"; import { parseCappedLimit } from "../utils/queryHelpers.js"; +import { + assertCallbackUrlAllowed, + SsrfValidationError, +} from "../utils/ssrfGuard.js"; import logger from "../utils/logger.js"; import { getStellarRpcUrl } from "../config/stellar.js"; import { sorobanService } from "../services/sorobanService.js"; @@ -506,21 +510,16 @@ export const createWebhookSubscription = async ( }); } - let parsedUrl: URL; try { - parsedUrl = new URL(callbackUrl); - } catch { - return res.status(400).json({ - success: false, - message: "callbackUrl must be a valid URL", - }); - } - - if (!["http:", "https:"].includes(parsedUrl.protocol)) { - return res.status(400).json({ - success: false, - message: "callbackUrl must use http or https", - }); + await assertCallbackUrlAllowed(callbackUrl); + } catch (error) { + if (error instanceof SsrfValidationError) { + return res.status(400).json({ + success: false, + message: error.message, + }); + } + throw error; } const normalizedEventTypes = Array.isArray(eventTypes) diff --git a/src/services/webhookService.ts b/src/services/webhookService.ts index 964c14c..2bd19ab 100644 --- a/src/services/webhookService.ts +++ b/src/services/webhookService.ts @@ -1,6 +1,7 @@ import crypto from "node:crypto"; import { query } from "../db/connection.js"; import logger from "../utils/logger.js"; +import { assertCallbackUrlAllowed } from "../utils/ssrfGuard.js"; export const SUPPORTED_WEBHOOK_EVENT_TYPES = [ "LoanRequested", @@ -245,6 +246,10 @@ async function postWebhook( body: string, signature: string | undefined, ): Promise { + // Re-validate the resolved address at delivery time to defend against DNS + // rebinding (the host may resolve differently than at registration time). + await assertCallbackUrlAllowed(callbackUrl); + const timeoutMs = getWebhookRequestTimeoutMs(); const controller = new AbortController(); const timeoutHandle = setTimeout(() => controller.abort(), timeoutMs); diff --git a/src/utils/__tests__/ssrfGuard.test.ts b/src/utils/__tests__/ssrfGuard.test.ts new file mode 100644 index 0000000..e6c5637 --- /dev/null +++ b/src/utils/__tests__/ssrfGuard.test.ts @@ -0,0 +1,90 @@ +import { jest } from "@jest/globals"; + +const mockLookup = + jest.fn<(host: string, opts: unknown) => Promise<{ address: string }[]>>(); + +jest.unstable_mockModule("node:dns/promises", () => ({ + default: { lookup: mockLookup }, + lookup: mockLookup, +})); + +const { assertCallbackUrlAllowed, isBlockedIp, SsrfValidationError } = + await import("../ssrfGuard.js"); + +describe("ssrfGuard", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("isBlockedIp", () => { + it.each([ + "127.0.0.1", + "169.254.169.254", + "10.1.2.3", + "172.16.0.1", + "192.168.1.1", + "0.0.0.0", + "100.64.0.1", + "255.255.255.255", + "::1", + "::", + "fd00::1", + "fe80::1", + "::ffff:127.0.0.1", + ])("blocks %s", (ip) => { + expect(isBlockedIp(ip)).toBe(true); + }); + + it.each(["8.8.8.8", "1.1.1.1", "93.184.216.34", "2606:2800:220:1::"])( + "allows public address %s", + (ip) => { + expect(isBlockedIp(ip)).toBe(false); + }, + ); + }); + + describe("assertCallbackUrlAllowed", () => { + it("rejects non-http(s) schemes", async () => { + await expect( + assertCallbackUrlAllowed("ftp://example.com"), + ).rejects.toBeInstanceOf(SsrfValidationError); + }); + + it("rejects an IP literal in a private range without DNS lookup", async () => { + await expect( + assertCallbackUrlAllowed("http://127.0.0.1/hook"), + ).rejects.toBeInstanceOf(SsrfValidationError); + expect(mockLookup).not.toHaveBeenCalled(); + }); + + it("rejects the cloud metadata IP literal", async () => { + await expect( + assertCallbackUrlAllowed("http://169.254.169.254/latest/meta-data"), + ).rejects.toBeInstanceOf(SsrfValidationError); + }); + + it("rejects a host that resolves to a 10.x address", async () => { + mockLookup.mockResolvedValueOnce([{ address: "10.0.0.5" }]); + await expect( + assertCallbackUrlAllowed("https://internal.example.com/hook"), + ).rejects.toBeInstanceOf(SsrfValidationError); + }); + + it("allows a host that resolves to a public address", async () => { + mockLookup.mockResolvedValueOnce([{ address: "93.184.216.34" }]); + await expect( + assertCallbackUrlAllowed("https://consumer.example.com/hook"), + ).resolves.toBeUndefined(); + }); + + it("rejects when any resolved address is private", async () => { + mockLookup.mockResolvedValueOnce([ + { address: "93.184.216.34" }, + { address: "127.0.0.1" }, + ]); + await expect( + assertCallbackUrlAllowed("https://rebind.example.com/hook"), + ).rejects.toBeInstanceOf(SsrfValidationError); + }); + }); +}); diff --git a/src/utils/ssrfGuard.ts b/src/utils/ssrfGuard.ts new file mode 100644 index 0000000..27f9616 --- /dev/null +++ b/src/utils/ssrfGuard.ts @@ -0,0 +1,161 @@ +import dns from "node:dns/promises"; +import net from "node:net"; + +/** + * SSRF guard for outbound webhook callback URLs. + * + * See docs/webhook-url-policy.md for the full allowed/blocked policy. + * + * Blocks callback URLs that: + * - use a scheme other than http/https + * - resolve to loopback, link-local, RFC1918 (private), or other reserved + * IP ranges (e.g. cloud metadata at 169.254.169.254). + */ + +export class SsrfValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "SsrfValidationError"; + } +} + +const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]); + +/** + * Returns true when the given IP literal falls inside a blocked range. + * Covers loopback, link-local, RFC1918, and other reserved/non-routable space + * for both IPv4 and IPv6 (including IPv4-mapped IPv6 addresses). + */ +export function isBlockedIp(ip: string): boolean { + const type = net.isIP(ip); + if (type === 4) { + return isBlockedIpv4(ip); + } + if (type === 6) { + return isBlockedIpv6(ip); + } + // Not a valid IP literal — treat as blocked to be safe. + return true; +} + +function isBlockedIpv4(ip: string): boolean { + const octets = ip.split(".").map((part) => Number.parseInt(part, 10)); + if (octets.length !== 4 || octets.some((o) => Number.isNaN(o))) { + return true; + } + const [a, b] = octets as [number, number, number, number]; + + // 0.0.0.0/8 — "this" network + if (a === 0) return true; + // 10.0.0.0/8 — RFC1918 private + if (a === 10) return true; + // 100.64.0.0/10 — carrier-grade NAT + if (a === 100 && b >= 64 && b <= 127) return true; + // 127.0.0.0/8 — loopback + if (a === 127) return true; + // 169.254.0.0/16 — link-local (incl. 169.254.169.254 metadata) + if (a === 169 && b === 254) return true; + // 172.16.0.0/12 — RFC1918 private + if (a === 172 && b >= 16 && b <= 31) return true; + // 192.0.0.0/24 & 192.0.2.0/24 — IETF protocol assignments / TEST-NET-1 + if (a === 192 && b === 0) return true; + // 192.168.0.0/16 — RFC1918 private + if (a === 192 && b === 168) return true; + // 198.18.0.0/15 — benchmarking + if (a === 198 && (b === 18 || b === 19)) return true; + // 224.0.0.0/4 multicast and 240.0.0.0/4 reserved (incl. 255.255.255.255) + if (a >= 224) return true; + + return false; +} + +function isBlockedIpv6(ip: string): boolean { + const normalized = ip.toLowerCase().split("%")[0] ?? ""; + + // Unspecified :: + if (normalized === "::") return true; + // Loopback ::1 + if (normalized === "::1") return true; + // IPv4-mapped (::ffff:a.b.c.d) and IPv4-compatible — re-check as IPv4. + const mapped = normalized.match(/^::(?:ffff:)?(\d+\.\d+\.\d+\.\d+)$/); + if (mapped?.[1]) { + return isBlockedIpv4(mapped[1]); + } + // Unique local addresses fc00::/7 + if (normalized.startsWith("fc") || normalized.startsWith("fd")) return true; + // Link-local fe80::/10 + if ( + normalized.startsWith("fe8") || + normalized.startsWith("fe9") || + normalized.startsWith("fea") || + normalized.startsWith("feb") + ) { + return true; + } + + return false; +} + +/** + * Parses and validates the callback URL scheme/shape only. + * Throws SsrfValidationError on an invalid or disallowed URL. + */ +export function parseCallbackUrl(callbackUrl: string): URL { + let parsed: URL; + try { + parsed = new URL(callbackUrl); + } catch { + throw new SsrfValidationError("callbackUrl must be a valid URL"); + } + + if (!ALLOWED_PROTOCOLS.has(parsed.protocol)) { + throw new SsrfValidationError("callbackUrl must use http or https"); + } + + return parsed; +} + +/** + * Fully validates a callback URL: scheme check, then DNS resolution with a + * block on any resolved address in a reserved/private range. + * + * Call this both at registration time and immediately before delivery + * (the delivery-time call defends against DNS rebinding). + * + * Throws SsrfValidationError when the URL or any resolved address is disallowed. + */ +export async function assertCallbackUrlAllowed( + callbackUrl: string, +): Promise { + const parsed = parseCallbackUrl(callbackUrl); + const hostname = parsed.hostname.replace(/^\[|\]$/g, ""); + + // If the host is already an IP literal, validate it directly. + if (net.isIP(hostname)) { + if (isBlockedIp(hostname)) { + throw new SsrfValidationError( + "callbackUrl resolves to a blocked (private/reserved) address", + ); + } + return; + } + + let addresses: { address: string }[]; + try { + addresses = await dns.lookup(hostname, { all: true }); + } catch { + throw new SsrfValidationError("callbackUrl host could not be resolved"); + } + + if (addresses.length === 0) { + throw new SsrfValidationError("callbackUrl host could not be resolved"); + } + + for (const { address } of addresses) { + if (isBlockedIp(address)) { + throw new SsrfValidationError( + "callbackUrl resolves to a blocked (private/reserved) address", + ); + } + } +}