Skip to content
Open
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
51 changes: 51 additions & 0 deletions docs/webhook-url-policy.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 20 additions & 0 deletions package-lock.json

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

8 changes: 8 additions & 0 deletions src/__tests__/webhookService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
27 changes: 13 additions & 14 deletions src/controllers/indexerController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions src/services/webhookService.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -245,6 +246,10 @@ async function postWebhook(
body: string,
signature: string | undefined,
): Promise<Response> {
// 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);
Expand Down
90 changes: 90 additions & 0 deletions src/utils/__tests__/ssrfGuard.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading