> ## Documentation Index
> Fetch the complete documentation index at: https://docs.useduro.com/llms.txt
> Use this file to discover all available pages before exploring further.

# SSRF & Egress

> Why merchant-controlled URLs are dangerous, and exactly how Duro neutralises them.

Duro makes outbound HTTP to two kinds of destination: ones **we** control (the WhatsApp Cloud API, at a fixed `graph.facebook.com` URL) and ones the **merchant** controls (webhook endpoints). The second kind is the dangerous one, and it gets a dedicated guard.

## The threat

A webhook URL is typed by the merchant, and the delivery's response body is **persisted and rendered** in the dashboard's delivery inspector. That combination is a classic read-SSRF:

```mermaid theme={null}
flowchart LR
    ATT["malicious merchant"] --> SET["set webhook url =<br/>http://169.254.169.254/<br/>latest/meta-data/iam/…"]
    SET --> EVENT["trigger any event"]
    EVENT --> FETCH["worker fetches the URL<br/>from inside the VPC"]
    FETCH --> STORE["response body stored"]
    STORE --> READ["merchant reads cloud<br/>IAM credentials in the<br/>delivery inspector"]
    style READ fill:#fee,stroke:#c33
```

The fetch originates from inside the trust boundary, so it can reach internal services and cloud metadata that the merchant never could directly — and then read the response back out.

## The guard, in two layers

`WebhookUrlGuard` blocks this at creation **and** at delivery, because a single check is insufficient against DNS rebinding.

### Layer 1 — format validation at write time

`isFormatSafe(url)` rejects anything that isn't a public https endpoint:

* protocol must be `https`
* host must not be `localhost`, `*.localhost`, `*.internal`, `*.local`, or `metadata.google.internal`
* if the host is an IP literal, it must not be private/reserved

The private-range checks cover the full set, for IPv4 and bracketed IPv6:

| Range                             | Why it's blocked                          |
| --------------------------------- | ----------------------------------------- |
| `10/8`, `172.16/12`, `192.168/16` | RFC-1918 private                          |
| `127/8`, `::1`                    | loopback                                  |
| `169.254/16`                      | link-local + **cloud metadata**           |
| `100.64/10`                       | CGNAT                                     |
| `198.18/15`                       | benchmarking                              |
| `0/8`, `255/8`                    | unspecified / broadcast                   |
| `fc00::/7`, `fe80::/10`           | IPv6 ULA + link-local                     |
| `::ffff:0:0/96`                   | IPv4-mapped (extract and re-check the v4) |

### Layer 2 — DNS resolution at delivery time

A hostname that passed Layer 1 can still resolve to a private IP — that's **DNS rebinding**: register `evil.com`, point it at a public IP to pass creation, then flip it to `169.254.169.254` before delivery. So at delivery, `assertResolvable(url)` resolves the host and rejects if **any** returned address is private. A blocked delivery fails terminally with `blocked destination` and is not retried.

```mermaid theme={null}
flowchart TD
    SAVE["save endpoint"] --> L1{"isFormatSafe?"}
    L1 -->|"no"| R1["422 at creation"]
    L1 -->|"yes"| OK1["saved"]
    SEND["deliver"] --> L2{"assertResolvable?<br/>(DNS → private?)"}
    L2 -->|"private"| R2["fail: blocked destination"]
    L2 -->|"public"| SENT["POST"]
```

## Outbound we control is fixed

The WhatsApp client only ever calls `https://graph.facebook.com/{version}/{phoneNumberId}/messages` — a constant URL with no user-controlled host — so it carries no SSRF surface. The guard exists specifically for the merchant-controlled case.

The guard is unit-tested across the whole matrix above (https/non-https, loopback, metadata, private ranges, bracketed IPv6, garbage input). It's the concrete answer to "what stops a malicious merchant from turning your webhook system into a credential reader."

That's the system. Jump to the [API Reference](/api-reference/introduction) to start firing requests.
