Add an egress CIDR allowlist to the SSRF guard (closes #204) (#217)
All checks were successful
check / check (push) Successful in 3m1s
All checks were successful
check / check (push) Successful in 3m1s
This commit was merged in pull request #217.
This commit is contained in:
562
internal/delivery/ssrf_allowlist_test.go
Normal file
562
internal/delivery/ssrf_allowlist_test.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user