Compare commits

1 Commits

Author SHA1 Message Date
71a3c3cf75 Add an egress CIDR allowlist to the SSRF guard (closes #204)
All checks were successful
check / check (push) Successful in 5m25s
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, the guard behaves exactly
as before.

Link-local (169.254.0.0/16, fe80::/10) is refused before the
allowlist is consulted, so no supplied CIDR can open it — not the
exact address, not a supernet, not 0.0.0.0/0. Reaching cloud
instance metadata is credential theft rather than delivery to an
internal service.

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; metadata stays refused under six different covering CIDRs;
public addresses are unaffected either way; and config coverage for
parsing, startup abort, and the warning's contents.
2026-08-20 04:13:09 +00:00
16 changed files with 886 additions and 61 deletions

View File

@@ -114,6 +114,62 @@ TTY detection, and security headers are always applied.
| `SESSION_IDLE_TIMEOUT` | Idle session timeout (Go duration) | `24h` |
| `RECEIVER_RATE_LIMIT` | Receiver requests/minute per IP per entrypoint (10x that per IP across the route) | `120` |
| `TRUSTED_PROXIES` | CIDRs whose forwarded headers are trusted (unset: all clients behind a proxy share one rate-limit bucket; a correct login password is never throttled either way) | `""` (none) |
| `ALLOWED_EGRESS_CIDRS` | CIDRs that delivery targets may reach despite the SSRF blocklist. Read [Allowing egress to your own network](#allowing-egress-to-your-own-network) before setting it | `""` (none) |
#### Allowing egress to your own network
By default every delivery target must resolve to a public address.
The private and reserved ranges — RFC 1918, loopback, CGNAT,
link-local and the rest — are refused, which stops a target from being
used to make webhooker probe the network it sits in.
That default is also inconvenient for the thing webhooker is mostly
for: taking a public webhook and forwarding it to something on your own
network. A container on the same Docker network, a box on `10.x`, a
service on `127.0.0.1` — all refused, until you name them.
`ALLOWED_EGRESS_CIDRS` is a comma-separated list of CIDR blocks (a bare
address such as `10.0.0.7` is accepted and treated as a single host),
for example `10.0.0.0/8, 172.17.0.0/16`. Addresses inside those blocks
become valid delivery destinations. Everything outside them keeps the
default answer, so this only ever adds destinations — it never removes
any, and it cannot narrow what was already reachable.
**The risk, plainly.** Each block you list is a network that anyone who
can create a delivery target can now make this process issue requests
into, and read the response body back out of via the delivery log. That
is server-side request forgery, deliberately enabled and scoped by you.
A webhooker admin account is therefore as trusted as the narrowest
thing on those networks: an unauthenticated admin panel, a database
listening without a password, or an internal API that trusts its
network position is reachable through it. List the smallest blocks that
cover the destinations you actually deliver to — prefer
`10.1.2.3/32` over `10.0.0.0/8` — and never list a block wider than the
network you are willing to expose.
Two things this setting cannot do:
- **It cannot turn the guard off.** There is no boolean, and no value
that disables SSRF protection wholesale. The guard is always on and
the list is always an allowlist; an empty list (the default) means
every private and reserved range stays refused.
- **It cannot open link-local.** `169.254.0.0/16` and `fe80::/10` stay
blocked no matter what you list, including when you list them
outright or list a supernet such as `0.0.0.0/0`. That range serves
cloud instance metadata (`169.254.169.254`), where reaching it is
credential theft rather than delivery to an internal service.
The list is applied at one place in the code, which both target
creation and delivery consult, so a URL that the target form accepts is
one that delivery will actually attempt — the two cannot disagree.
Delivery re-resolves and re-checks the destination at dial time, so a
hostname that resolves to an allowed address during validation and a
different one later (DNS rebinding) is still refused unless the new
address is also allowed.
A set but unparseable value aborts startup. When the list is non-empty
webhooker logs it at startup, blocks and all, so the hole is visible in
the log of any deployment that has one.
#### Trusted proxies
@@ -226,8 +282,9 @@ additionally be a number in the range 165535,
`RECEIVER_RATE_LIMIT` must be at least 1,
`RETENTION_SWEEP_INTERVAL` must be greater than zero (it is a ticker
period, so `0s` or a negative value would crash the reaper after
startup), and every entry in `TRUSTED_PROXIES` must be a CIDR block or
a bare IP address. `SESSION_IDLE_TIMEOUT` is the exception: a
startup), and every entry in `TRUSTED_PROXIES` and
`ALLOWED_EGRESS_CIDRS` must be a CIDR block or a bare IP address.
`SESSION_IDLE_TIMEOUT` is the exception: a
non-positive value there means idle expiry is disabled, not invalid.
Boolean variables (`DEBUG`, `MAINTENANCE_MODE`) accept exactly the
@@ -1920,7 +1977,12 @@ check, see [The login endpoint](#the-login-endpoint).
ranges (RFC 1918, loopback, link-local, cloud metadata) are blocked
both at target creation time (URL validation) and at delivery time
(custom HTTP transport with SSRF-safe dialer that validates resolved
IPs before connecting, preventing DNS rebinding attacks)
IPs before connecting, preventing DNS rebinding attacks). Both paths
route through a single decision function, so they cannot disagree
about a destination. An operator can permit specific blocks with
[`ALLOWED_EGRESS_CIDRS`](#allowing-egress-to-your-own-network); the
guard cannot be switched off, and link-local stays blocked whatever
is listed
- **Login limiting is inverted, deliberately.** The login `POST` has
no pre-emptive rate limiter in front of it. Credentials are
verified first and only a _failed_ attempt spends budget, so a

View File

@@ -75,6 +75,10 @@ func newApp() *fx.App {
session.New,
handlers.New,
middleware.New,
// The one SSRF guard both target-creation validation
// and the delivery dialer consult, so they cannot
// disagree about a destination.
delivery.NewGuard,
delivery.New,
delivery.NewArchiveSweeper,
// Wire *delivery.Engine as delivery.Notifier so the

View File

@@ -114,6 +114,17 @@ type Config struct {
// clients.
TrustedProxies []netip.Prefix
// AllowedEgressCIDRs is the set of networks a delivery target
// may reach even though the SSRF guard's default blocklist
// covers them. It is empty unless ALLOWED_EGRESS_CIDRS is set,
// and empty means every private/reserved range stays refused.
//
// This only ever adds destinations to what the guard would
// otherwise refuse. The guard itself is always on: there is no
// setting that disables SSRF protection, and link-local stays
// blocked no matter what is listed here.
AllowedEgressCIDRs []netip.Prefix
params *ConfigParams
log *slog.Logger
}
@@ -406,6 +417,11 @@ func loadFromEnv() (*Config, error) {
return nil, err
}
allowedEgressCIDRs, err := envPrefixList("ALLOWED_EGRESS_CIDRS")
if err != nil {
return nil, err
}
return &Config{
DataDir: envString("DATA_DIR"),
Debug: debug,
@@ -419,9 +435,48 @@ func loadFromEnv() (*Config, error) {
SessionIdleTimeout: sessionIdleTimeout,
ReceiverRateLimit: receiverRateLimit,
TrustedProxies: trustedProxies,
AllowedEgressCIDRs: allowedEgressCIDRs,
}, nil
}
// PrefixStrings renders a prefix list as its CIDR strings, for
// logging a list an operator has to be able to read back.
func PrefixStrings(prefixes []netip.Prefix) []string {
out := make([]string, 0, len(prefixes))
for _, prefix := range prefixes {
out = append(out, prefix.String())
}
return out
}
// warnEgressAllowlist logs the effective ALLOWED_EGRESS_CIDRS
// whenever it is non-empty.
//
// It prints the blocks themselves rather than a count, because
// this is the one setting that lets a delivery target reach the
// host's own network: an operator reading the startup log has to
// be able to see exactly which hole is open. Silence means the
// list is empty and the SSRF guard is refusing every
// private/reserved range, which is the default.
func (c *Config) warnEgressAllowlist(log *slog.Logger) {
if len(c.AllowedEgressCIDRs) == 0 {
return
}
log.Warn(
"ALLOWED_EGRESS_CIDRS lets delivery targets reach these "+
"otherwise-blocked private/reserved networks. Anyone "+
"who can create a delivery target can now make this "+
"process issue requests into them, and read back the "+
"response. Link-local (cloud instance metadata) stays "+
"blocked regardless.",
"allowedEgressCIDRs",
strings.Join(PrefixStrings(c.AllowedEgressCIDRs), ","),
)
}
// warnSharedRateLimitBucket logs a startup warning whenever
// TRUSTED_PROXIES is empty, in any environment.
//
@@ -511,12 +566,14 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
"sessionIdleTimeout", s.SessionIdleTimeout.String(),
"receiverRateLimit", s.ReceiverRateLimit,
"trustedProxies", len(s.TrustedProxies),
"allowedEgressCIDRs", len(s.AllowedEgressCIDRs),
"hasSentryDSN", s.SentryDSN != "",
"hasMetricsAuth",
s.MetricsUsername != "" && s.MetricsPassword != "",
)
s.warnSharedRateLimitBucket(log)
s.warnEgressAllowlist(log)
return s, nil
}

View File

@@ -627,6 +627,183 @@ func testTrustedProxiesSuccess(
assert.Equal(t, expected, got)
}
// TestAllowedEgressCIDRs covers ALLOWED_EGRESS_CIDRS, the escape
// hatch that lets a self-hosted deployment forward to its own
// network. Unset it must stay empty, so the SSRF guard keeps
// refusing every private/reserved range; a set-but-unparseable
// value must abort startup naming the variable rather than
// silently running with a list the operator did not write.
func TestAllowedEgressCIDRs(t *testing.T) {
tests := []struct {
name string
set bool
value string
expected []string
expectError bool
}{
{
name: caseUnsetUsesDefault,
set: false,
expected: []string{},
},
{
name: "empty value yields empty list",
set: true,
value: "",
expected: []string{},
},
{
name: caseValidValueParsed,
set: true,
value: cidrPrivateV4,
expected: []string{cidrPrivateV4},
},
{
name: "multiple blocks with whitespace",
set: true,
value: " 10.0.0.0/8 , 127.0.0.0/8 ",
expected: []string{cidrPrivateV4, "127.0.0.0/8"},
},
{
name: "bare address becomes a single host",
set: true,
value: "172.17.0.5",
expected: []string{"172.17.0.5/32"},
},
{
name: caseUnparseableFails,
set: true,
value: cidrPrivateV4 + ",not-an-address",
expectError: true,
},
{
name: "out-of-range prefix length fails startup",
set: true,
value: "10.0.0.0/33",
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests.
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
if tt.set {
t.Setenv("ALLOWED_EGRESS_CIDRS", tt.value)
} else {
require.NoError(
t, os.Unsetenv("ALLOWED_EGRESS_CIDRS"),
)
}
if tt.expectError {
expectStartupErrorFor(
t, "ALLOWED_EGRESS_CIDRS", config.ErrInvalidCIDR,
)
} else {
testAllowedEgressCIDRsSuccess(t, tt.expected)
}
})
}
}
func testAllowedEgressCIDRsSuccess(
t *testing.T,
expected []string,
) {
t.Helper()
var cfg *config.Config
app := fxtest.New(
t,
fx.Provide(
globals.New,
logger.New,
config.New,
),
fx.Populate(&cfg),
)
require.NoError(t, app.Err())
app.RequireStart()
defer app.RequireStop()
assert.Equal(
t, expected, config.PrefixStrings(cfg.AllowedEgressCIDRs),
)
}
// TestEgressAllowlistWarning covers the startup log that shows an
// operator the hole ALLOWED_EGRESS_CIDRS opened. It must stay
// silent on the default (empty) list and, when set, print the
// blocks themselves rather than a count.
func TestEgressAllowlistWarning(t *testing.T) {
tests := []struct {
name string
allowed string
expectWarning bool
}{
{
name: "empty allowlist is quiet",
expectWarning: false,
},
{
name: "non-empty allowlist warns",
allowed: "10.0.0.0/8,127.0.0.0/8",
expectWarning: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests.
t.Setenv("WEBHOOKER_ENVIRONMENT", config.EnvironmentDev)
if tt.allowed == "" {
require.NoError(
t, os.Unsetenv("ALLOWED_EGRESS_CIDRS"),
)
} else {
t.Setenv("ALLOWED_EGRESS_CIDRS", tt.allowed)
}
var buf bytes.Buffer
log := slog.New(slog.NewJSONHandler(
&buf, &slog.HandlerOptions{
Level: slog.LevelDebug,
},
))
require.NoError(
t, config.WarnEgressAllowlistForTest(log),
)
if !tt.expectWarning {
assert.Empty(t, buf.String())
return
}
logged := buf.String()
assert.Contains(t, logged, `"level":"WARN"`)
assert.Contains(t, logged, "ALLOWED_EGRESS_CIDRS")
// The blocks themselves, not a count: the operator has
// to be able to read back which networks are open.
assert.Contains(t, logged, "10.0.0.0/8")
assert.Contains(t, logged, "127.0.0.0/8")
// The warning must keep saying what stays shut.
assert.Contains(t, logged, "Link-local")
})
}
}
// TestSharedRateLimitBucketWarning covers the startup warning that
// tells an operator a deployment behind a reverse proxy shares one
// rate-limit bucket between every client, which turns the receiver

View File

@@ -21,6 +21,21 @@ func WarnSharedRateLimitBucketForTest(log *slog.Logger) error {
return nil
}
// WarnEgressAllowlistForTest loads a Config from the current
// environment and emits its egress-allowlist startup warning to
// log, so a test can assert both that the warning fires only when
// the list is non-empty and that it names the blocks it opened.
func WarnEgressAllowlistForTest(log *slog.Logger) error {
c, err := loadFromEnv()
if err != nil {
return err
}
c.warnEgressAllowlist(log)
return nil
}
// EnvBoolForTest exposes envBool.
func EnvBoolForTest(key string, defaultValue bool) (bool, error) {
return envBool(key, defaultValue)

View File

@@ -19,7 +19,8 @@ func newSSRFTestEngine() *delivery.Engine {
client := &http.Client{
Timeout: 30 * time.Second,
Transport: delivery.NewSSRFSafeTransport(),
Transport: delivery.NewTestGuard().
NewSSRFSafeTransport(),
}
return delivery.NewTestEngine(log, client, 1)
@@ -36,8 +37,8 @@ func TestClientForConfig_TimeoutKeepsSSRFGuard(t *testing.T) {
engine := newSSRFTestEngine()
blocked := []string{
"http://127.0.0.1/hook",
"http://169.254.169.254/latest/meta-data/",
loopbackHookURL,
metadataURL,
"http://[fe80::1]/hook",
}

View File

@@ -120,6 +120,7 @@ type EngineParams struct {
DB *database.Database
DBManager *database.WebhookDBManager
Logger *logger.Logger
SSRFGuard *Guard
}
// Engine processes queued deliveries in the background
@@ -168,7 +169,7 @@ func New(
e.initTargets(&http.Client{
Timeout: httpClientTimeout,
Transport: NewSSRFSafeTransport(),
Transport: params.SSRFGuard.NewSSRFSafeTransport(),
})
e.registerHooks(lc)

View File

@@ -5,6 +5,7 @@ import (
"log/slog"
"net"
"net/http"
"net/netip"
"time"
"go.uber.org/fx"
@@ -31,6 +32,26 @@ func ExportIsBlockedIP(ip net.IP) bool {
return isBlockedIP(ip)
}
// NewTestGuard builds an SSRF Guard from an explicit egress
// allowlist, without going through config. Passing no prefixes
// yields the default guard, which blocks every private/reserved
// range.
func NewTestGuard(allowed ...netip.Prefix) *Guard {
return &Guard{allowed: allowed}
}
// ExportCheckIP exposes the guard's single decision point, so a
// test can assert the policy both the validator and the dialer
// inherit without needing a live destination.
func (g *Guard) ExportCheckIP(ip net.IP) error {
return g.checkIP(ip)
}
// ExportAlwaysBlockedNetworks exposes alwaysBlockedNetworks.
func ExportAlwaysBlockedNetworks() []*net.IPNet {
return alwaysBlockedNetworks
}
// ExportBlockedNetworks exposes blockedNetworks.
func ExportBlockedNetworks() []*net.IPNet {
return blockedNetworks

View File

@@ -6,8 +6,11 @@ import (
"fmt"
"net"
"net/http"
"net/netip"
"net/url"
"time"
"sneak.berlin/go/webhooker/internal/config"
)
const (
@@ -25,20 +28,39 @@ var (
errBlockedIP = errors.New(
"blocked private/reserved IP range",
)
errBlockedLinkLocal = errors.New(
"blocked link-local range, which serves cloud instance " +
"metadata: ALLOWED_EGRESS_CIDRS cannot open it",
)
errInvalidScheme = errors.New(
"only http and https are allowed",
)
)
// blockedNetworks contains all private/reserved IP ranges
// that should be blocked to prevent SSRF attacks.
// that should be blocked to prevent SSRF attacks. An operator
// can permit specific blocks out of this set with
// ALLOWED_EGRESS_CIDRS; see Guard.
//
//nolint:gochecknoglobals // package-level network list is appropriate here
var blockedNetworks []*net.IPNet
// alwaysBlockedNetworks are the ranges no configuration can
// open. They are the link-local blocks, which carry the cloud
// instance metadata services (169.254.169.254 and its IPv6
// equivalents). Reaching one is credential theft rather than
// delivery to an internal service, so a supplied CIDR that
// covers a link-local address still leaves it blocked.
//
// These addresses are also in blockedNetworks; this list is what
// makes them unconditional.
//
//nolint:gochecknoglobals // package-level network list is appropriate here
var alwaysBlockedNetworks []*net.IPNet
//nolint:gochecknoinits // init is the idiomatic way to parse CIDRs once at startup
func init() {
cidrs := []string{
blockedNetworks = mustParseCIDRs([]string{
"127.0.0.0/8",
"10.0.0.0/8",
"172.16.0.0/12",
@@ -56,8 +78,20 @@ func init() {
"::1/128",
"fc00::/7",
"fe80::/10",
})
alwaysBlockedNetworks = mustParseCIDRs([]string{
"169.254.0.0/16",
"fe80::/10",
})
}
// mustParseCIDRs parses a list of CIDR literals, panicking on a
// bad one. The inputs are compile-time constants, so a failure
// is a programming error rather than a runtime condition.
func mustParseCIDRs(cidrs []string) []*net.IPNet {
networks := make([]*net.IPNet, 0, len(cidrs))
for _, cidr := range cidrs {
_, network, err := net.ParseCIDR(cidr)
if err != nil {
@@ -67,16 +101,15 @@ func init() {
))
}
blockedNetworks = append(
blockedNetworks, network,
)
}
networks = append(networks, network)
}
// isBlockedIP checks whether an IP address falls within
// any blocked private/reserved network range.
func isBlockedIP(ip net.IP) bool {
for _, network := range blockedNetworks {
return networks
}
// matchesAny reports whether ip falls inside any of networks.
func matchesAny(networks []*net.IPNet, ip net.IP) bool {
for _, network := range networks {
if network.Contains(ip) {
return true
}
@@ -85,9 +118,40 @@ func isBlockedIP(ip net.IP) bool {
return false
}
// isBlockedIP checks whether an IP address falls within
// any blocked private/reserved network range, before any
// operator allowlist is considered.
func isBlockedIP(ip net.IP) bool {
return matchesAny(blockedNetworks, ip)
}
// Guard makes every SSRF decision in the process.
//
// It holds the operator's ALLOWED_EGRESS_CIDRS allowlist and
// applies it in exactly one place, checkIP, which both the
// target-creation validator (ValidateTargetURL) and the delivery
// dialer call. Routing both through the same function is the
// point: when the two paths decided separately they drifted and
// disagreed, which is what made a target creatable but
// undeliverable.
//
// The guard is always on. The allowlist only ever adds specific
// networks to what the default blocklist refuses, and no
// configuration turns the guard off wholesale.
type Guard struct {
// allowed is the operator's ALLOWED_EGRESS_CIDRS. Empty
// (the default) means the default blocklist stands as-is.
allowed []netip.Prefix
}
// NewGuard builds the process-wide SSRF guard from configuration.
func NewGuard(cfg *config.Config) *Guard {
return &Guard{allowed: cfg.AllowedEgressCIDRs}
}
// ValidateTargetURL checks that an HTTP delivery target
// URL is safe from SSRF attacks.
func ValidateTargetURL(
func (g *Guard) ValidateTargetURL(
ctx context.Context, targetURL string,
) error {
parsed, err := url.Parse(targetURL)
@@ -111,36 +175,78 @@ func ValidateTargetURL(
}
if ip := net.ParseIP(host); ip != nil {
return checkBlockedIP(ip)
return g.checkIP(ip)
}
return validateHostname(ctx, host)
return g.validateHostname(ctx, host)
}
func validateScheme(scheme string) error {
if scheme != "http" && scheme != "https" {
// NewSSRFSafeTransport creates an http.Transport with a
// custom DialContext that refuses connections to any address
// this guard blocks. It resolves and checks at dial time, so a
// name that passed validation but now answers with a blocked
// address (DNS rebinding) is still refused.
func (g *Guard) NewSSRFSafeTransport() *http.Transport {
return &http.Transport{
DialContext: g.ssrfDialContext,
}
}
// allows reports whether ip falls inside the operator's
// configured egress allowlist.
func (g *Guard) allows(ip net.IP) bool {
if len(g.allowed) == 0 {
return false
}
addr, ok := netip.AddrFromSlice(ip)
if !ok {
return false
}
// Config unmaps every parsed prefix, so an IPv4-mapped
// address has to be unmapped too or it would never match.
addr = addr.Unmap()
for _, prefix := range g.allowed {
if prefix.Contains(addr) {
return true
}
}
return false
}
// checkIP is the single point at which SSRF policy is decided.
//
// The order is the policy:
//
// 1. Link-local is refused before the allowlist is consulted,
// so no configured CIDR can reach cloud instance metadata.
// 2. The allowlist is consulted next, so a listed private
// network becomes reachable.
// 3. Everything else keeps the default blocklist's answer.
func (g *Guard) checkIP(ip net.IP) error {
if matchesAny(alwaysBlockedNetworks, ip) {
return fmt.Errorf(
"unsupported URL scheme %q: %w",
scheme, errInvalidScheme,
"target IP %s: %w", ip, errBlockedLinkLocal,
)
}
if g.allows(ip) {
return nil
}
func checkBlockedIP(ip net.IP) error {
if isBlockedIP(ip) {
return fmt.Errorf(
"target IP %s is in a blocked "+
"private/reserved range: %w",
ip, errBlockedIP,
"target IP %s: %w", ip, errBlockedIP,
)
}
return nil
}
func validateHostname(
func (g *Guard) validateHostname(
ctx context.Context, host string,
) error {
dnsCtx, cancel := context.WithTimeout(
@@ -165,11 +271,11 @@ func validateHostname(
}
for _, ipAddr := range ips {
if isBlockedIP(ipAddr.IP) {
err = g.checkIP(ipAddr.IP)
if err != nil {
return fmt.Errorf(
"hostname %q resolves to blocked "+
"IP %s: %w",
host, ipAddr.IP, errBlockedIP,
"hostname %q resolves to a blocked address: %w",
host, err,
)
}
}
@@ -177,16 +283,7 @@ func validateHostname(
return nil
}
// NewSSRFSafeTransport creates an http.Transport with a
// custom DialContext that blocks connections to
// private/reserved IP addresses.
func NewSSRFSafeTransport() *http.Transport {
return &http.Transport{
DialContext: ssrfDialContext,
}
}
func ssrfDialContext(
func (g *Guard) ssrfDialContext(
ctx context.Context,
network, addr string,
) (net.Conn, error) {
@@ -209,11 +306,11 @@ func ssrfDialContext(
}
for _, ipAddr := range ips {
if isBlockedIP(ipAddr.IP) {
err = g.checkIP(ipAddr.IP)
if err != nil {
return nil, fmt.Errorf(
"ssrf: connection to %s (%s) "+
"blocked: %w",
host, ipAddr.IP, errBlockedIP,
"ssrf: connection to %s blocked: %w",
host, err,
)
}
}
@@ -225,3 +322,14 @@ func ssrfDialContext(
net.JoinHostPort(ips[0].IP.String(), port),
)
}
func validateScheme(scheme string) error {
if scheme != "http" && scheme != "https" {
return fmt.Errorf(
"unsupported URL scheme %q: %w",
scheme, errInvalidScheme,
)
}
return nil
}

View File

@@ -0,0 +1,370 @@
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"
)
// 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: link-local carries cloud instance metadata, so reaching
// it is credential theft rather than delivery to an internal
// service. Every one of these guards names a CIDR that covers
// 169.254.169.254 — including 0.0.0.0/0 — and the address must
// stay refused anyway, on both paths.
func TestGuardAllowlist_MetadataAlwaysRefused(t *testing.T) {
t.Parallel()
tests := []struct {
name string
allow string
target string
}{
{
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: "0.0.0.0/0",
target: metadataURL,
},
{
name: "other link-local address",
allow: "0.0.0.0/0",
target: "http://169.254.1.1/",
},
{
name: "IPv6 link-local",
allow: "::/0",
target: "http://[fe80::1]/",
},
}
for _, tt := range tests {
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(), "link-local",
"the refusal must say why it cannot be opened",
)
assertDialRefused(t, guard, tt.target)
})
}
}
// 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_AreLinkLocal pins the unconditional
// set to link-local only. It is 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.
func TestAlwaysBlockedNetworks_AreLinkLocal(t *testing.T) {
t.Parallel()
nets := delivery.ExportAlwaysBlockedNetworks()
got := make([]string, 0, len(nets))
for _, n := range nets {
got = append(got, n.String())
}
assert.Equal(t,
[]string{"169.254.0.0/16", "fe80::/10"}, 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()
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(), "blocked",
"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
}

View File

@@ -31,10 +31,10 @@ func TestIsBlockedIP_PrivateRanges(t *testing.T) {
{"192.168.0.1", "192.168.0.1", true},
{"192.168.255.255", "192.168.255.255", true},
{"169.254.0.1", "169.254.0.1", true},
{"169.254.169.254", "169.254.169.254", true},
{metadataIP, metadataIP, true},
{"8.8.8.8", "8.8.8.8", false},
{"1.1.1.1", "1.1.1.1", false},
{"93.184.216.34", "93.184.216.34", false},
{publicIP, publicIP, false},
{"::1", "::1", true},
{"fd00::1", "fd00::1", true},
{"fc00::1", "fc00::1", true},
@@ -72,12 +72,12 @@ func TestValidateTargetURL_Blocked(t *testing.T) {
t.Parallel()
blockedURLs := []string{
"http://127.0.0.1/hook",
loopbackHookURL,
"http://127.0.0.1:8080/hook",
"https://10.0.0.1/hook",
"http://192.168.1.1/webhook",
"http://172.16.0.1/api",
"http://169.254.169.254/latest/meta-data/",
metadataURL,
"http://[::1]/hook",
"http://[fc00::1]/hook",
"http://[fe80::1]/hook",
@@ -88,7 +88,7 @@ func TestValidateTargetURL_Blocked(t *testing.T) {
t.Run(u, func(t *testing.T) {
t.Parallel()
err := delivery.ValidateTargetURL(
err := delivery.NewTestGuard().ValidateTargetURL(
context.Background(), u,
)
@@ -112,7 +112,7 @@ func TestValidateTargetURL_Allowed(t *testing.T) {
t.Run(u, func(t *testing.T) {
t.Parallel()
err := delivery.ValidateTargetURL(
err := delivery.NewTestGuard().ValidateTargetURL(
context.Background(), u,
)
@@ -126,7 +126,7 @@ func TestValidateTargetURL_Allowed(t *testing.T) {
func TestValidateTargetURL_InvalidScheme(t *testing.T) {
t.Parallel()
err := delivery.ValidateTargetURL(
err := delivery.NewTestGuard().ValidateTargetURL(
context.Background(), "ftp://example.com/hook",
)
@@ -140,7 +140,7 @@ func TestValidateTargetURL_InvalidScheme(t *testing.T) {
func TestValidateTargetURL_EmptyHost(t *testing.T) {
t.Parallel()
err := delivery.ValidateTargetURL(
err := delivery.NewTestGuard().ValidateTargetURL(
context.Background(), "http:///path",
)
@@ -150,7 +150,7 @@ func TestValidateTargetURL_EmptyHost(t *testing.T) {
func TestValidateTargetURL_InvalidURL(t *testing.T) {
t.Parallel()
err := delivery.ValidateTargetURL(
err := delivery.NewTestGuard().ValidateTargetURL(
context.Background(), "://invalid",
)

View File

@@ -185,7 +185,7 @@ func TestDoHTTPRequest_TransportErrorMasksURL(t *testing.T) {
func TestValidateTargetURL_UnparsableURLIsMasked(t *testing.T) {
t.Parallel()
err := delivery.ValidateTargetURL(
err := delivery.NewTestGuard().ValidateTargetURL(
context.TODO(),
"https://hooks.slack.com"+maskSecretPath+"\n",
)

View File

@@ -59,6 +59,7 @@ type HandlersParams struct {
Middleware *middleware.Middleware
Notifier delivery.Notifier
Evictor delivery.WebhookEvictor
SSRFGuard *delivery.Guard
}
// Handlers provides HTTP handler methods for all application
@@ -75,6 +76,11 @@ type Handlers struct {
evictor delivery.WebhookEvictor
templates map[string]*template.Template
// ssrf validates submitted target URLs. It is the same guard
// the delivery engine dials through, so a URL accepted here
// is one delivery will actually attempt.
ssrf *delivery.Guard
// dummyVerifications counts the equivalent-cost verifications
// charged for usernames that do not exist. It exists so a test
// can prove that path runs without measuring wall-clock time.
@@ -114,6 +120,7 @@ func New(
s.mw = params.Middleware
s.notifier = params.Notifier
s.evictor = params.Evictor
s.ssrf = params.SSRFGuard
// Parse all page templates once at startup
s.templates = map[string]*template.Template{

View File

@@ -84,6 +84,7 @@ func newTestApp(
return r
},
middleware.New,
delivery.NewGuard,
handlers.New,
),
fx.Populate(targets...),

View File

@@ -1160,7 +1160,7 @@ func (h *Handlers) buildURLTargetConfig(
return "", errMissingURL
}
err := delivery.ValidateTargetURL(
err := h.ssrf.ValidateTargetURL(
r.Context(), targetURL,
)
if err != nil {

View File

@@ -97,6 +97,7 @@ func newTestEnv(t *testing.T) *testEnv {
func() delivery.Notifier { return &noopNotifier{} },
func() delivery.WebhookEvictor { return &noopEvictor{} },
middleware.New,
delivery.NewGuard,
handlers.New,
),
fx.Populate(&log, &cfg, &mw, &hnd, &sess, &db, &dbMgr),