---
title: Custom pixels without the SSRF footgun
url: https://honeybound.co/blog/custom-pixels-without-the-ssrf-footgun
date: 2026-04-29
summary: Letting merchants define their own webhook destination sounds like a checkbox feature. It's actually a security project. Here's what a naive implementation lets attackers do, and the validation layer that stops it.
tags: security, ssrf, shopify
---

A merchant adds a custom destination to your app. The URL field accepts any
string. They paste `http://169.254.169.254/latest/meta-data/iam/security-credentials/`.
Your worker dutifully POSTs to it, gets back AWS IAM credentials, and
forwards them to a Slack webhook the same merchant configured five minutes
ago.

Game over — for you, not them.

## The naive version

Most "custom webhook" features start here: a text field for the URL, a text
field for the auth header, drop both into `http.Post(...)`. It works in the
demo. It ships Friday. It wakes someone up on Monday.

Every field a user can type into is an attack surface. A URL field is a
remote-code-reach-ability field.

## What an attacker can reach

Without validation, the full blast radius:

- **Cloud metadata endpoints.** AWS `169.254.169.254`, GCP
  `metadata.google.internal`, Azure equivalents. These return IAM credentials
  to anything that can make a TCP connection from inside the VM.
- **Internal services on the same Docker network.** `http://postgres:5432`,
  `http://redis:6379`, anything resolvable by compose service name.
- **Localhost.** Your admin endpoints, debug routes, profilers, anything bound
  to 127.0.0.1.
- **Private RFC1918 ranges.** `10.0.0.0/8`, `172.16.0.0/12`,
  `192.168.0.0/16`. Whatever's on your private network.
- **IPv6 link-local and unique-local.** `fe80::/10`, `fc00::/7`. Easy to
  forget if your denylist only covers IPv4.
- **DNS rebinding.** The URL resolves to a public IP on the first lookup, an
  internal IP on the second. Validation that only checks once gets bypassed
  by the actual request.

## What the validation actually has to do

Layered, in order:

1. **Resolve the hostname before the HTTP request.** Reject if any resolved
   IP matches a denylist — private ranges, loopback, link-local, multicast,
   plus the cloud metadata IPs explicitly. Check both A and AAAA records.
2. **Bind the HTTP client to the resolved IP, not the hostname.** Dial the IP
   you validated, pass the original hostname in the `Host` header and SNI.
   The OS cannot re-resolve mid-request. DNS rebinding closed.
3. **Disallow redirects, or re-validate every redirect target.** A 302 back
   to `169.254.169.254` should not bypass step 1.
4. **Cap response body size.** A malicious endpoint can stream gigabytes just
   to tie up your worker and egress budget.
5. **Aggressive timeouts.** 5s connect, 15s total is a reasonable starting
   point. Custom destinations aren't latency-sensitive.
6. **Restrict schemes to `http` and `https`.** No `file://`, no `gopher://`,
   no `ftp://`. Most libraries default to everything.

## The eventabee implementation

The SSRF guard lives once, in the connector layer, shared between the
custom-pixel connector and the generic webhook connector. One source of
truth, one place to audit, one place to add the next IP range we didn't
think of yet.

The field-mapper pattern matters here too. Merchants map event fields to
destination fields through dropdowns, not a template DSL. There is no
arbitrary template execution, no shell expansion, no "interesting" string
interpolation. The Business-tier raw JSON template is sandboxed to
event-derived field substitution — no code, no functions, no escapes.

Validation runs at config save *and* at every send. If DNS resolves
differently between Tuesday and Wednesday, the send-time check catches it.

## What you still can't prevent

Merchants pointing the destination at themselves and racking up egress
bandwidth on your dime. Per-merchant rate limits and a circuit breaker on
consecutive 5xx are not optional. Not glamorous. Required.

---

If your app lets users define an outbound HTTP destination and you didn't
write SSRF validation in the first sprint, assume it's exploitable. Then go
check. We built this into [eventabee](/work/eventabee) before the feature
shipped — not because it was hard, but because adding it after a merchant
pastes the metadata URL is the wrong time to learn.

