Add an egress CIDR allowlist to the SSRF guard (closes #204)
All checks were successful
check / check (push) Successful in 3m3s

The SSRF blocklist had no escape hatch, so the thing webhooker is
mostly for — taking a public webhook and forwarding it to something
on your own network — could not be configured at all. Every private
address, Docker sibling and loopback service was permanently
unreachable as a delivery destination.

ALLOWED_EGRESS_CIDRS (default empty) names blocks that delivery
targets may reach despite the default blocklist. It is an allowlist
and only ever adds destinations: there is no boolean, and no value
disables SSRF protection wholesale. Empty, it adds nothing and the
guard permits and refuses the same addresses it did before, save
the two spellings named below.

A small set of addresses is refused before the allowlist is
consulted, so no supplied CIDR opens one — not the exact address,
not a supernet, not 0.0.0.0/0 or ::/0. alwaysBlockedNetworks in
internal/delivery/ssrf.go is the authoritative list and states the
membership criterion in full; it is deliberately not copied here,
because a copy drifts out of date. In short: the provider fixes the
address, so a host route for it collides with nothing the operator
runs, and reaching it discloses credentials or user data. A
publicly routable address never qualifies however well it meets
both — nothing in this set can be reopened, so blocking one here
would leave the operator no escape hatch at all. Those belong in
blockedNetworks, which an allowlist can override.

Every entry is already inside the default blocklist, which is what
makes it unconditional rather than newly blocked, with two
exceptions that are the one behaviour change visible when the
allowlist is unset: ::a9fe:a9fe and 64:ff9b::a9fe:a9fe, the
IPv4-compatible and NAT64 spellings of 169.254.169.254, were
reachable before and are refused now. net.IPNet.Contains normalises
only the IPv4-mapped form via To4(), so 169.254.0.0/16 never
matched those two. The ten it does cover now report a metadata
error rather than the generic private-range one.

The policy now lives in one function, Guard.checkIP, which both
target-creation validation and the delivery dialer call. The two
paths previously decided separately, which is how they came to
disagree about a destination. The guard is built once from config
and injected via fx into both the handlers and the delivery engine,
so there is a single instance and a single answer.

A set-but-unparseable value aborts startup naming the variable,
reusing the existing envPrefixList parser. A non-empty list is
logged at startup with the blocks spelled out, not counted, so the
hole is visible in the log of any deployment that has one.

Tests: an allowlisted loopback CIDR both validates and delivers to a
live server (and the same URL still fails without the allowlist); a
private address outside the listed block stays refused on both
paths; every unconditionally blocked address stays refused on both
paths under an allowlist that covers it, and the set itself is
pinned entry by entry; public addresses are unaffected either way;
and config coverage for parsing, startup abort, and the warning's
contents.
This commit is contained in:
2026-08-20 04:13:06 +00:00
parent f0512f1c3c
commit a969657536
17 changed files with 1244 additions and 61 deletions

View File

@@ -0,0 +1,562 @@
package delivery_test
import (
"context"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"net/url"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/delivery"
)
// Addresses the SSRF tests in this package share.
const (
// metadataIP is the cloud instance metadata address, and
// metadataURL an endpoint on it. The guard must never reach
// either, whatever an operator lists.
metadataIP = "169.254.169.254"
metadataURL = "http://" + metadataIP + "/latest/meta-data/"
// loopbackHookURL is a target on this host: blocked by
// default, reachable only once an operator allowlists
// loopback.
loopbackHookURL = "http://127.0.0.1/hook"
// publicIP is an ordinary public address, which the guard
// permits with or without an allowlist.
publicIP = "93.184.216.34"
// allowAllIPv4 and allowAllIPv6 are the widest allowlist
// entries expressible: the whole internet, in each family.
// Nothing unconditionally blocked may be reachable under
// them.
allowAllIPv4 = "0.0.0.0/0"
allowAllIPv6 = "::/0"
// allowAllULA is the ordinary ULA block an operator lists to
// reach their own IPv6 network. Several providers park a
// metadata endpoint inside it.
allowAllULA = "fd00::/8"
// metadataRefusalClause is the part of the refusal that only
// alwaysBlockedNetworks produces. Asserting it, rather than
// the bare word "blocked", is what proves the unconditional
// set did the refusing and not the default blocklist.
metadataRefusalClause = "ALLOWED_EGRESS_CIDRS cannot open it"
)
// TestGuardAllowlist_PermittedCIDRDelivers proves the escape
// hatch actually works end to end: with 127.0.0.0/8 allowed, the
// guard's own transport connects to a loopback server and gets a
// response back. The default guard, given the identical URL,
// refuses it — so the delivery succeeds because of the allowlist
// and nothing else.
func TestGuardAllowlist_PermittedCIDRDelivers(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
},
))
t.Cleanup(srv.Close)
// httptest listens on loopback, which the default blocklist
// covers: exactly the "forward to a service on this host"
// case the allowlist exists for.
requireLoopback(t, srv.URL)
guard := delivery.NewTestGuard(
netip.MustParsePrefix("127.0.0.0/8"),
)
require.NoError(t,
guard.ValidateTargetURL(context.Background(), srv.URL),
"an allowlisted loopback target must pass validation",
)
client := &http.Client{
Timeout: 5 * time.Second,
Transport: guard.NewSSRFSafeTransport(),
}
req, err := http.NewRequestWithContext(
context.Background(), http.MethodPost, srv.URL, nil,
)
require.NoError(t, err)
resp, err := client.Do(req)
require.NoError(t,
err, "an allowlisted loopback target must be deliverable",
)
defer func() { _ = resp.Body.Close() }()
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
// The same URL through the default guard must still fail, or
// this test would pass without the allowlist doing anything.
assert.Error(t,
delivery.NewTestGuard().ValidateTargetURL(
context.Background(), srv.URL,
),
"without the allowlist the same target must be refused",
)
}
// TestGuardAllowlist_UnlistedPrivateStillRefused proves the
// allowlist grants only what it names. A guard that opens one
// private block must keep refusing every other one, at both the
// validation and the delivery entry point.
func TestGuardAllowlist_UnlistedPrivateStillRefused(t *testing.T) {
t.Parallel()
// Only 10.1.0.0/16 is open — a narrow block inside a much
// wider private range, so the test can tell "permits the
// listed block" from "permits anything private".
guard := delivery.NewTestGuard(
netip.MustParsePrefix("10.1.0.0/16"),
)
refused := []string{
"http://192.168.1.10/hook",
"http://172.16.0.1/hook",
loopbackHookURL,
"http://[fc00::1]/hook",
"http://100.64.0.1/hook",
// Private, adjacent to the allowed block, outside it.
"http://10.2.0.1/hook",
}
for _, target := range refused {
t.Run(target, func(t *testing.T) {
t.Parallel()
err := guard.ValidateTargetURL(
context.Background(), target,
)
require.Error(t,
err, "%s is not allowlisted and must be refused",
target,
)
assert.Contains(t, err.Error(), "blocked")
assertDialRefused(t, guard, target)
})
}
// The block that is listed must in fact be permitted, so the
// refusals above are selective rather than a guard that
// ignores its allowlist entirely.
assert.NoError(t,
guard.ValidateTargetURL(
context.Background(), "http://10.1.2.3/hook",
),
"the allowlisted block must be permitted",
)
}
// TestGuardAllowlist_MetadataAlwaysRefused is the load-bearing
// case: cloud instance metadata endpoints are credential theft
// rather than delivery to an internal service, so no allowlist
// reaches one. Every guard below names a CIDR that covers its
// target — including 0.0.0.0/0, ::/0, and the ordinary ULA and
// CGNAT blocks an operator would really list — and the address
// must stay refused anyway, on both the validation and the
// delivery path.
func TestGuardAllowlist_MetadataAlwaysRefused(t *testing.T) {
t.Parallel()
for _, tt := range metadataAlwaysRefusedCases() {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
guard := delivery.NewTestGuard(
netip.MustParsePrefix(tt.allow),
)
err := guard.ValidateTargetURL(
context.Background(), tt.target,
)
require.Error(t,
err,
"%s must stay blocked even though %s covers it",
tt.target, tt.allow,
)
assert.Contains(t,
err.Error(),
metadataRefusalClause,
"the refusal must say why it cannot be opened",
)
// The metadata clause, not just "blocked": that is
// what distinguishes the unconditional set from the
// ordinary blocklist.
assertDialRefusedWith(
t, guard, tt.target, metadataRefusalClause,
)
})
}
}
// metadataAlwaysRefusedCase is one (allowlist, target) pair that
// must be refused: allow covers target, and target must stay
// blocked regardless.
type metadataAlwaysRefusedCase struct {
name string
allow string
target string
}
// metadataAlwaysRefusedCases enumerates every unconditionally
// blocked address together with an allowlist entry that would
// otherwise reach it. Split by family of address only to stay
// under the function-length limit.
func metadataAlwaysRefusedCases() []metadataAlwaysRefusedCase {
cases := linkLocalRefusedCases()
cases = append(cases, ulaMetadataRefusedCases()...)
cases = append(cases, ipv4MetadataRefusedCases()...)
return append(cases, encodedMetadataRefusedCases()...)
}
// linkLocalRefusedCases covers the link-local blocks, including
// an operator naming the metadata address outright.
func linkLocalRefusedCases() []metadataAlwaysRefusedCase {
return []metadataAlwaysRefusedCase{
{
name: "exact metadata host",
allow: "169.254.169.254/32",
target: metadataURL,
},
{
name: "whole link-local block",
allow: "169.254.0.0/16",
target: metadataURL,
},
{
name: "supernet covering link-local",
allow: "169.0.0.0/8",
target: metadataURL,
},
{
name: "the entire IPv4 internet",
allow: allowAllIPv4,
target: metadataURL,
},
{
name: "other link-local address",
allow: allowAllIPv4,
target: "http://169.254.1.1/",
},
{
name: "IPv6 link-local",
allow: allowAllIPv6,
target: "http://[fe80::1]/",
},
}
}
// ulaMetadataRefusedCases covers the metadata endpoints parked
// in ULA space. Every one is opened by the single ordinary
// allowlist entry fd00::/8, which is the whole reason they need
// their own /128 host routes: fe80::/10 does not cover a ULA,
// whatever the vendor's documentation calls the address.
func ulaMetadataRefusedCases() []metadataAlwaysRefusedCase {
return []metadataAlwaysRefusedCase{
{
name: "AWS IPv6 IMDS under an allowlisted ULA block",
allow: allowAllULA,
target: "http://[fd00:ec2::254]/latest/meta-data/",
},
{
// A second AWS credential endpoint, distinct from
// IMDS. AWS's own docs call this one "localhost".
name: "AWS EKS Pod Identity under an allowlisted ULA block",
allow: allowAllULA,
target: "http://[fd00:ec2::23]/v1/credentials",
},
{
name: "GCP IPv6 metadata under an allowlisted ULA block",
allow: allowAllULA,
target: "http://[fd20:ce::254]/computeMetadata/v1/",
},
{
name: "Oracle OCI IPv6 IMDS under an allowlisted ULA block",
allow: allowAllULA,
target: "http://[fd00:c1::a9fe:a9fe]/opc/v2/instance/",
},
{
name: "Scaleway IPv6 metadata under an allowlisted ULA block",
allow: allowAllULA,
target: "http://[fd00:42::42]/conf",
},
{
// Akamai's docs call this "link-local"; it is a ULA,
// so fe80::/10 does not cover it.
name: "Linode IPv6 metadata under an allowlisted ULA block",
allow: allowAllULA,
target: "http://[fd00:a9fe:a9fe::1]/v1/instance",
},
}
}
// ipv4MetadataRefusedCases covers the IPv4 metadata endpoints
// that sit outside link-local: one in CGNAT and one in the
// blocked 192.0.0.0/24, each reachable only through an allowlist
// that this set overrides.
func ipv4MetadataRefusedCases() []metadataAlwaysRefusedCase {
return []metadataAlwaysRefusedCase{
{
// Tailscale uses 100.64.0.0/10, so an operator
// forwarding to a Tailscale peer lists exactly this.
name: "Alibaba metadata under allowlisted CGNAT",
allow: "100.64.0.0/10",
target: "http://100.100.100.200/latest/meta-data/",
},
{
// Inside the already-blocked 192.0.0.0/24, so only
// an allowlist can reach it — and must not.
name: "Oracle Cloud Classic metadata under 0.0.0.0/0",
allow: allowAllIPv4,
target: "http://192.0.0.192/latest/meta-data/",
},
}
}
// encodedMetadataRefusedCases covers the alternate IPv6
// encodings of 169.254.169.254.
func encodedMetadataRefusedCases() []metadataAlwaysRefusedCase {
return []metadataAlwaysRefusedCase{
{
// To4() does not normalise the IPv4-compatible form,
// so this needs its own always-blocked entry.
name: "IPv4-compatible IPv6 form of the metadata IP",
allow: allowAllIPv6,
target: "http://[::a9fe:a9fe]/latest/meta-data/",
},
{
// Nor the NAT64 well-known prefix form.
name: "NAT64 form of the metadata IP",
allow: allowAllIPv6,
target: "http://[64:ff9b::a9fe:a9fe]/latest/meta-data/",
},
{
// Already refused before this change: IPNet.Contains
// calls To4() first, so the mapped form matches
// 169.254.0.0/16. Pinned so it cannot regress.
//
// Allowed under 0.0.0.0/0 rather than ::/0: allows()
// unmaps before matching, so ::/0 would not cover the
// unmapped v4 address and the case would not prove
// the allowlist was overridden.
name: "IPv4-mapped IPv6 form of the metadata IP",
allow: allowAllIPv4,
target: "http://[::ffff:169.254.169.254]/latest/meta-data/",
},
}
}
// TestGuardAllowlist_PublicUnaffected asserts the allowlist does
// not narrow anything: public addresses were reachable before it
// existed and stay reachable, whether or not a list is set.
func TestGuardAllowlist_PublicUnaffected(t *testing.T) {
t.Parallel()
guards := map[string]*delivery.Guard{
"default": delivery.NewTestGuard(),
"with allowlist": delivery.NewTestGuard(
netip.MustParsePrefix("10.0.0.0/8"),
),
}
for name, guard := range guards {
t.Run(name, func(t *testing.T) {
t.Parallel()
assert.NoError(t,
guard.ValidateTargetURL(
context.Background(),
"http://"+publicIP+"/webhook",
),
)
})
}
}
// TestGuardCheckIP_BothPathsShareOneDecision asserts that the
// validator and the dialer are not two policies that happen to
// agree: both are defined in terms of checkIP, so the exported
// decision function is the whole answer for a given address.
func TestGuardCheckIP_BothPathsShareOneDecision(t *testing.T) {
t.Parallel()
guard := delivery.NewTestGuard(
netip.MustParsePrefix("10.0.0.0/8"),
)
tests := []struct {
ip string
allowed bool
}{
{"10.1.2.3", true},
{publicIP, true},
{"192.168.1.1", false},
{"127.0.0.1", false},
{metadataIP, false},
}
for _, tt := range tests {
t.Run(tt.ip, func(t *testing.T) {
t.Parallel()
ip := net.ParseIP(tt.ip)
require.NotNil(t, ip)
decision := guard.ExportCheckIP(ip)
validation := guard.ValidateTargetURL(
context.Background(), "http://"+hostFor(tt.ip)+"/x",
)
if tt.allowed {
require.NoError(t, decision)
require.NoError(t, validation)
return
}
require.Error(t, decision)
require.Error(t, validation,
"validation must refuse what checkIP refuses",
)
})
}
}
// TestAlwaysBlockedNetworks_PinnedSet pins the unconditional set
// exactly, so it cannot quietly grow or shrink.
//
// It stays deliberately small. Everything else in the default
// blocklist is an operator's own network and must remain
// openable, or the escape hatch would not work — which is why
// the metadata endpoints outside the link-local range are host
// routes rather than the blocks that contain them.
func TestAlwaysBlockedNetworks_PinnedSet(t *testing.T) {
t.Parallel()
nets := delivery.ExportAlwaysBlockedNetworks()
got := make([]string, 0, len(nets))
for _, n := range nets {
got = append(got, n.String())
}
want := []string{
// IPv4 link-local: the 169.254.169.254 metadata
// service on AWS, Azure and others.
"169.254.0.0/16",
// IPv6 link-local.
"fe80::/10",
// AWS IPv6 IMDS, inside the ULA space an operator may
// legitimately allowlist.
"fd00:ec2::254/128",
// AWS EKS Pod Identity Agent, likewise ULA.
"fd00:ec2::23/128",
// GCP metadata for IPv6-only instances, likewise ULA.
"fd20:ce::254/128",
// Oracle OCI IMDS over IPv6, likewise ULA.
"fd00:c1::a9fe:a9fe/128",
// Scaleway metadata over IPv6, likewise ULA.
"fd00:42::42/128",
// Linode/Akamai metadata over IPv6, likewise ULA.
"fd00:a9fe:a9fe::1/128",
// Alibaba Cloud metadata, inside CGNAT.
"100.100.100.200/32",
// Oracle Cloud Classic metadata, inside the blocked
// 192.0.0.0/24.
"192.0.0.192/32",
// 169.254.169.254 as an IPv4-compatible IPv6 address.
"::a9fe:a9fe/128",
// 169.254.169.254 behind the NAT64 well-known prefix.
"64:ff9b::a9fe:a9fe/128",
}
assert.Equal(t, want, got)
}
// requireLoopback fails the test unless rawURL's host is a
// loopback address, so the allowlist test cannot silently stop
// exercising a blocked range.
func requireLoopback(t *testing.T, rawURL string) {
t.Helper()
parsed, err := url.Parse(rawURL)
require.NoError(t, err)
ip := net.ParseIP(parsed.Hostname())
require.NotNil(t, ip, "test server host must be an IP literal")
require.True(t, ip.IsLoopback(),
"test server must listen on loopback, got %s", ip,
)
}
// assertDialRefused asserts the guard's transport refuses to
// connect to target, which is the delivery-time half of the
// policy. It never reaches the network: the guard checks the
// resolved address before dialling.
func assertDialRefused(
t *testing.T, guard *delivery.Guard, target string,
) {
t.Helper()
assertDialRefusedWith(t, guard, target, "blocked")
}
// assertDialRefusedWith is assertDialRefused with the refusal
// text pinned. Callers testing the unconditional set pass
// metadataRefusalClause so the subtest cannot pass on an
// ordinary blocklist refusal instead.
func assertDialRefusedWith(
t *testing.T, guard *delivery.Guard, target, clause string,
) {
t.Helper()
client := &http.Client{
Timeout: 5 * time.Second,
Transport: guard.NewSSRFSafeTransport(),
}
req, err := http.NewRequestWithContext(
context.Background(), http.MethodPost, target, nil,
)
require.NoError(t, err)
resp, err := client.Do(req)
if resp != nil {
_ = resp.Body.Close()
}
require.Error(t, err,
"delivery to %s must be refused by the dialer", target,
)
assert.Contains(t, err.Error(), clause,
"the refusal must come from the SSRF guard",
)
}
// hostFor renders an IP as it appears in a URL host, bracketing
// IPv6 literals.
func hostFor(ip string) string {
if net.ParseIP(ip).To4() == nil {
return "[" + ip + "]"
}
return ip
}