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. The last two are public unicast, // so nothing in the default blocklist covers them and this set // is the only thing refusing them. 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/", }, { name: "Azure WireServer under 0.0.0.0/0", allow: allowAllIPv4, target: "http://168.63.129.16/machine/", }, { name: "Equinix Metal metadata under 0.0.0.0/0", allow: allowAllIPv4, target: "http://147.75.207.243/metadata", }, } } // 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", // Azure WireServer, public unicast. "168.63.129.16/32", // Equinix Metal metadata, public unicast. "147.75.207.243/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 }