Almost every integration feature in business software ends up asking the customer for a URL. Send a webhook here. Fetch the product image from there. Import this CSV. Crawl this help centre. Stream audit events to that SIEM. Each of those is a request your server makes, from inside your network, to an address a stranger chose. That is the whole attack, and it has a name.
This post covers where user-supplied URLs enter a platform, why the obvious defence fails, and which controls hold up.
What SSRF is, precisely
Server-side request forgery is an attack in which an application fetches an attacker-controlled URL from its own network position. The attacker never touches the target directly. Your server does it for them, carrying whatever trust the network grants it: no authentication prompt for the internal metrics dashboard, no firewall rule between the app tier and the database, no credential needed for the cloud metadata service.
The vulnerability is not that your server makes outbound requests. It is that the destination is chosen by input and the request inherits internal trust. Both halves have to be true for the attack to work, which is also why both halves suggest a fix.
Where user-supplied URLs get in
The webhook configuration field is the obvious one, and it is rarely the only one. In a platform with CRM, support, commerce and finance modules, the surfaces multiply:
| Surface | What the user supplies | Why it is easy to miss |
|---|---|---|
| Outbound webhooks | The delivery endpoint | Treated as configuration, so it is validated once and trusted forever |
| HTTP-call automation blocks | A full URL, method and body | The feature exists to call arbitrary endpoints, so validation feels like it defeats the point |
| Image and file fetchers | A remote image or attachment URL | The response is binary and never shown as text, which hides the leak but not the reachability |
| Knowledge-base or site crawlers | A starting URL, then every link it finds | The crawler expands the input set on its own |
| Import from URL | A CSV or JSON location | Ships as a convenience feature and skips the review the webhook got |
| Audit or SIEM streaming | A collector endpoint | Runs as a background job with no user watching the error |
| Mailbox and service setup | An IMAP, SMTP or API host | Not shaped like a URL, so the URL guard never runs on it |
| Link previews and unfurling | Any link pasted into a conversation | Anyone who can send a message can trigger a fetch |
The pattern worth extracting: any field that becomes a hostname is an SSRF surface, whether or not it looks like a URL bar.
The failure case: a webhook that was checked at the wrong moment
Here is a concrete sequence that has burned real teams. An operator configures an outbound webhook pointing at https://hooks.example-attacker.net/collect. The platform validates the URL at save time, resolves the hostname, sees a public address, and stores the configuration. Everything is correct so far.
Two hours later an event fires. The delivery worker takes the stored URL string and hands it to an HTTP client, which resolves the hostname again. This time the authoritative DNS server, which has been serving a one-second TTL, answers 169.254.169.254. The worker opens a connection to the cloud instance metadata service, requests the IAM credentials path, and, because webhook delivery logs the response body for debugging, writes a set of temporary cloud credentials into a log the attacker can read through the delivery-history screen.
Three separate mistakes compound here. Validation ran against the string rather than the connection. The DNS answer was allowed to change between check and use. And the response body was echoed back to the caller. Fixing any one of them breaks the chain, which is a good argument for fixing all three.
Why a blocklist of IP literals does not work
The first instinct is a deny list: reject 127.0.0.1, localhost, 10.*, 192.168.*, 169.254.169.254. It fails for reasons that are all boring and all fatal.
Hostnames are not addresses. internal.attacker.net is a perfectly ordinary public hostname that resolves to 127.0.0.1. No string check sees anything wrong with it.
Addresses have many spellings. 127.0.0.1 is also 2130706433 in decimal, 0x7f000001 in hex, 017700000001 in octal, and 127.1 in the shortened dotted form most resolvers still accept. IPv6 adds ::1, ::ffff:127.0.0.1 and 0:0:0:0:0:ffff:7f00:1. A regular expression over the user’s text is playing a game it cannot win.
The special ranges are longer than people remember. Beyond loopback and RFC 1918, an honest check has to cover link-local 169.254.0.0/16 (where every major cloud parks its metadata service), carrier-grade NAT 100.64.0.0/10, 0.0.0.0/8, the IPv6 unique-local range fc00::/7, IPv6 link-local fe80::/10, and whatever internal ranges your own VPC uses.
Redirects move the target after the check. A URL can point at a compliant public host that answers 302 Location: http://169.254.169.254/. If the HTTP client follows redirects automatically, the validated URL is not the URL that gets fetched.
DNS answers change. This is the rebinding case above. Any design that validates a name and later connects by name has a window, and the window is exactly as long as the attacker wants it to be.
The controls that hold
Four mechanisms, in order of how much they buy you.
Allowlist the destination where the feature allows it. If an integration only ever talks to one vendor’s API, the destination belongs in code or configuration, not in a user field. Most SSRF surfaces turn out not to need arbitrary URLs at all. The ones that genuinely do, such as a customer’s own webhook receiver, are a small minority worth handling carefully.
Resolve, validate, then pin. Resolve the hostname yourself. Check every address the resolver returns, not just the first. Reject the whole set if any address falls in a forbidden range. Then connect to the specific address you validated, carrying the original hostname in the Host header and TLS SNI so certificate validation still works. Pinning is what closes the rebinding window, because the name is never resolved a second time.
Re-run the check on every redirect hop. Disable your HTTP client’s automatic redirect following. Handle each 3xx yourself, apply the full resolve-and-pin check to the new location, and enforce a hop ceiling so a redirect loop cannot hold a worker open.
Route outbound traffic through an egress proxy. A single outbound chokepoint with an allowlist enforces the boundary at the network layer, below any application code path that might forget. It also gives you one place to apply timeouts, response-size caps and logging.
Two supporting rules matter as much as the four above. Cap the response size and the wall-clock timeout, so a slow or enormous target cannot exhaust your workers. And never return the raw response body or a detailed connection error to the user, because a blind SSRF becomes an exploitable one the moment the error message distinguishes “connection refused” from “connection timed out”.
How Oneop handles outbound requests
Oneop’s rule is that every surface where a customer can supply a URL goes through one SSRF guard: webhook blocks, API-call blocks in the chatbot, the crawler, the SIEM audit stream and IMAP connection setup. The guard resolves the hostname and rejects private, loopback and link-local addresses, and it runs again on every redirect hop rather than once at configuration time, which is the part that closes the DNS-rebinding window described above. Redirects are followed manually with a hop ceiling, and every outbound request carries a timeout. The security page states each of those controls by name.
The inbound direction is governed by the same posture. Every webhook Oneop accepts is signature-verified and fails closed, so an event that arrives without a valid signature, or when the signing secret is not configured, is rejected rather than trusted. The per-source verification table lives on the integrations page alongside what each integration syncs and in which direction.
Outbound request handling is one of those controls that is invisible when it works, which is why it is worth asking any vendor about specifically. The architectural reason Oneop can enforce it in one place is the same reason it can enforce stock and ledger invariants in one place, an argument laid out in why Oneop and, for AI specifically, in why your AI agent should live inside your system of record. Guardrails that only exist in some code paths are not guardrails, a point we make about automation generally in when not to automate.