Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions backend/internal/api/handlers/settings_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1830,6 +1830,30 @@ func TestSettingsHandler_TestPublicURL_IPv6LocalhostBlocked(t *testing.T) {
// IPv6 loopback should be blocked
}

func TestSettingsHandler_TestPublicURL_ReservedRangesBlocked(t *testing.T) {
handler, _ := setupSettingsHandlerWithMail(t)

router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/settings/test-url", handler.TestPublicURL)

for _, target := range []string{"http://100.64.0.1", "http://198.18.0.1", "http://[2002::1]", "http://[64:ff9b::808:808]"} {
jsonBody, _ := json.Marshal(map[string]string{"url": target})
req, _ := http.NewRequest("POST", "/settings/test-url", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

assert.Equal(t, http.StatusOK, w.Code, target)
var resp map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp), target)
assert.False(t, resp["reachable"].(bool), target)
}
}

// TestUpdateSetting_EmptyValueIsAccepted guards the PR-1 fix: Value must NOT carry
// binding:"required". Gin treats "" as missing for string fields and returns 400 if
// the tag is present. Re-adding the tag would silently regress the CrowdSec enable
Expand Down
29 changes: 29 additions & 0 deletions backend/internal/crowdsec/hub_sync_policy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package crowdsec

import (
"context"
"errors"
"net/http"
"testing"
"time"

"github.com/Wikid82/charon/backend/internal/network"
)

func TestNewHubHTTPClient_RejectsReservedDestinations(t *testing.T) {
transport, ok := newHubHTTPClient(time.Second).Transport.(*http.Transport)
if !ok {
t.Fatal("unexpected transport type")
}
for _, addr := range []string{"100.64.0.1:9", "198.18.0.1:9", "[2002::1]:9", "10.0.0.1:9"} {
conn, err := transport.DialContext(context.Background(), "tcp", addr)
if err == nil {
_ = conn.Close()
t.Errorf("dial %s succeeded", addr)
continue
}
if !errors.Is(err, network.ErrBlockedAddress) {
t.Errorf("dial %s: expected policy rejection, got %v", addr, err)
}
}
}
112 changes: 112 additions & 0 deletions backend/internal/network/address_policy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package network

import (
"context"
"errors"
"net"
"testing"
"time"
)

func TestAddressPolicy_Blocked(t *testing.T) {
t.Parallel()
tests := []struct {
name string
ip string
policy AddressPolicy
want bool
}{
{"nil", "", AddressPolicy{}, true},
{"public", "8.8.8.8", AddressPolicy{}, false},
{"loopback zero value", "127.0.0.1", AddressPolicy{}, true},
{"loopback allowed", "::1", AddressPolicy{AllowLocalhost: true}, false},
{"rfc1918 zero value", "10.0.0.1", AddressPolicy{}, true},
{"rfc1918 allowed", "::ffff:10.0.0.1", AddressPolicy{AllowRFC1918: true}, false},
{"link-local never opened", "169.254.169.254", AddressPolicy{AllowLocalhost: true, AllowRFC1918: true, AllowCGNAT: true}, true},
{"shared space zero value", "100.64.0.1", AddressPolicy{}, true},
{"shared space upper edge", "100.127.255.255", AddressPolicy{}, true},
{"below shared space", "100.63.255.255", AddressPolicy{}, false},
{"above shared space", "100.128.0.0", AddressPolicy{}, false},
{"shared space mapped", "::ffff:100.64.0.1", AddressPolicy{}, true},
{"shared space allowed", "100.64.0.1", AddressPolicy{AllowCGNAT: true}, false},
{"metadata alias with allowance", "100.100.100.200", AddressPolicy{AllowCGNAT: true}, true},
{"metadata alias mapped with allowance", "::ffff:100.100.100.200", AddressPolicy{AllowCGNAT: true, AllowRFC1918: true}, true},
{"special-purpose v4", "192.0.0.1", AddressPolicy{}, true},
{"special-purpose v4 neighbour", "192.0.1.1", AddressPolicy{}, false},
{"benchmark range", "198.18.0.1", AddressPolicy{}, true},
{"benchmark range upper", "198.19.255.255", AddressPolicy{}, true},
{"benchmark range neighbour", "198.20.0.1", AddressPolicy{}, false},
{"translation v6", "64:ff9b::808:808", AddressPolicy{}, true},
{"translation v6 local", "64:ff9b:1::1", AddressPolicy{}, true},
{"tunnel v6", "2002:c000:204::1", AddressPolicy{}, true},
{"embedded v4 v6", "::1.2.3.4", AddressPolicy{}, true},
{"tunnel v6 prefix", "2001:0:4136:e378:8000:63bf:3fff:fdd2", AddressPolicy{}, true},
{"translation with every allowance", "64:ff9b::1", AddressPolicy{AllowLocalhost: true, AllowRFC1918: true, AllowCGNAT: true}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var ip net.IP
if tt.ip != "" {
ip = net.ParseIP(tt.ip)
}
if got := tt.policy.Blocked(ip); got != tt.want {
t.Errorf("Blocked(%q) = %v, want %v", tt.ip, got, tt.want)
}
})
}
}

// TestClientOptionsPolicy_EndState covers the policy derived from client options.
func TestClientOptionsPolicy_EndState(t *testing.T) {
t.Parallel()
tests := []struct {
name string
opts ClientOptions
ip string
want bool
}{
{"shared space default", ClientOptions{}, "100.64.0.1", true},
{"shared space allowed", ClientOptions{AllowCGNAT: true}, "100.64.0.1", false},
{"shared space with other allowances", ClientOptions{AllowLocalhost: true, AllowRFC1918: true}, "100.64.0.1", true},
{"metadata alias with allowance", ClientOptions{AllowCGNAT: true}, "100.100.100.200", true},
{"mapped metadata alias with allowance", ClientOptions{AllowCGNAT: true}, "::ffff:100.100.100.200", true},
{"benchmark range default", ClientOptions{}, "198.18.0.1", true},
{"benchmark range with every allowance", ClientOptions{AllowLocalhost: true, AllowRFC1918: true, AllowCGNAT: true}, "198.19.255.255", true},
{"tunnel range with every allowance", ClientOptions{AllowLocalhost: true, AllowRFC1918: true, AllowCGNAT: true}, "2002::1", true},
{"public with every allowance", ClientOptions{AllowLocalhost: true, AllowRFC1918: true, AllowCGNAT: true}, "8.8.8.8", false},
}
for _, tt := range tests {
opts := tt.opts
if got := opts.policy().Blocked(net.ParseIP(tt.ip)); got != tt.want {
t.Errorf("%s: Blocked(%s) = %v, want %v", tt.name, tt.ip, got, tt.want)
}
}
}

func TestAllowOverlayOption_SetsField(t *testing.T) {
t.Parallel()
cfg := defaultOptions()
WithAllowCGNAT()(&cfg)
if !cfg.AllowCGNAT {
t.Fatal("WithAllowCGNAT did not set AllowCGNAT")
}
}

func TestNewSafeHTTPClient_OverlayAllowanceWiredToDialer(t *testing.T) {
withResolver(t, map[string][]string{"overlay.example": {"100.64.0.9"}})

blocked := safeDialer(&ClientOptions{DialTimeout: time.Second})
if _, err := blocked(context.Background(), "tcp", "overlay.example:9"); !errors.Is(err, ErrBlockedAddress) {
t.Fatalf("default dialer: expected ErrBlockedAddress, got %v", err)
}

allowed := safeDialer(&ClientOptions{DialTimeout: 200 * time.Millisecond, AllowCGNAT: true})
conn, err := allowed(context.Background(), "tcp", "overlay.example:9")
if conn != nil {
_ = conn.Close()
}
if errors.Is(err, ErrBlockedAddress) {
t.Fatalf("allowing dialer still refused the address: %v", err)
}
}
84 changes: 48 additions & 36 deletions backend/internal/network/safeclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,21 +82,20 @@ var privateCIDRs = []string{
"fe80::/10",

// Cloud instance metadata alias, inside the CGNAT range (100.64.0.0/10).
// Blocked globally; the rest of the CGNAT range is only blocked by WithBlockCGNAT.
// Blocked even when the rest of the range is allowed.
"100.100.100.200/32",
}

// cgnatCIDRs is the shared-address space used by carrier-grade NAT and many
// overlay networks. It is NOT part of IsPrivateIP; callers opt in to blocking
// it with WithBlockCGNAT.
// overlay networks. It is NOT part of IsPrivateIP; AddressPolicy blocks it unless
// AllowCGNAT is set.
var cgnatCIDRs = []string{
"100.64.0.0/10",
}

// transitionCIDRs lists IPv4/IPv6 transition, translation and special-purpose
// ranges that have no legitimate use as an outbound HTTP destination. They are
// NOT part of IsPrivateIP; callers opt in to blocking them with
// WithBlockTransitionRanges.
// NOT part of IsPrivateIP; AddressPolicy always blocks them.
var transitionCIDRs = []string{
"192.0.0.0/24", // IETF protocol assignments
"198.18.0.0/15", // Benchmarking
Expand Down Expand Up @@ -150,6 +149,10 @@ func initRFC1918Blocks() {
// - Reserved ranges: 0.0.0.0/8, 240.0.0.0/4, 255.255.255.255/32
// - IPv6 unique local addresses: fc00::/7
//
// It does NOT cover the shared-address space (other than the one metadata alias)
// or the transition and special-purpose ranges. Use AddressPolicy.Blocked, the
// single policy entry point, to apply those rules.
//
// IPv4-mapped IPv6 addresses (::ffff:x.x.x.x) are correctly handled by extracting
// the IPv4 portion and validating it.
//
Expand Down Expand Up @@ -278,13 +281,8 @@ type ClientOptions struct {
// cloud metadata (169.254.x.x), and reserved — remain blocked regardless.
AllowRFC1918 bool

// BlockCGNAT rejects the carrier-grade NAT range (100.64.0.0/10). Off by
// default so existing callers are unchanged.
BlockCGNAT bool

// BlockTransitionRanges rejects IPv4/IPv6 transition and special-purpose
// ranges (see IsTransitionRange). Off by default.
BlockTransitionRanges bool
// AllowCGNAT permits the shared address space used by overlay networks.
AllowCGNAT bool

// keepAlive, when true, enables HTTP connection pooling on the SSRF-safe
// client. When false (the default) the client keeps its historical
Expand Down Expand Up @@ -360,51 +358,63 @@ func WithAllowRFC1918() Option {
}
}

// WithBlockCGNAT rejects destinations in the carrier-grade NAT range
// (100.64.0.0/10) in addition to the ranges blocked by IsPrivateIP.
func WithBlockCGNAT() Option {
// WithAllowCGNAT permits destinations in the shared address space used by
// overlay networks. The cloud metadata alias inside it stays blocked.
func WithAllowCGNAT() Option {
return func(opts *ClientOptions) {
opts.BlockCGNAT = true
opts.AllowCGNAT = true
}
}

// WithBlockTransitionRanges rejects destinations in IPv4/IPv6 transition and
// special-purpose ranges (see IsTransitionRange).
func WithBlockTransitionRanges() Option {
return func(opts *ClientOptions) {
opts.BlockTransitionRanges = true
}
// AddressPolicy is the single outbound address policy. The zero value is the
// strictest policy: only public addresses are allowed. Every opt-in is explicit.
type AddressPolicy struct {
// AllowLocalhost permits loopback addresses.
AllowLocalhost bool
// AllowRFC1918 permits the three RFC 1918 private ranges.
AllowRFC1918 bool
// AllowCGNAT permits the shared address space used by overlay networks.
// The cloud metadata alias inside it stays blocked.
AllowCGNAT bool
}

// blockedByPolicy is the single address-policy predicate shared by the dialer
// (both its validation and selection passes) and the redirect check. It reports
// whether ip must NOT be connected to under opts.
// Blocked reports whether ip must NOT be connected to under the policy. It is
// shared by every dial-time, redirect and URL pre-validation check.
//
// The always-on rules (IsPrivateIP, which includes loopback and link-local) can
// only be relaxed by the explicit AllowLocalhost / AllowRFC1918 branches, and
// those branches only match loopback and RFC 1918 addresses, so no other
// restricted address is reachable through them.
func blockedByPolicy(ip net.IP, opts *ClientOptions) bool {
func (p AddressPolicy) Blocked(ip net.IP) bool {
if ip == nil {
return true
}
// Transition ranges are matched on the raw address first: IPv6-only forms
// do not survive the To4 normalisation below.
if opts.BlockTransitionRanges && IsTransitionRange(ip) {
// do not survive the To4 normalisation in IsPrivateIP.
if IsTransitionRange(ip) {
return true
}
if opts.AllowLocalhost && ip.IsLoopback() {
if p.AllowLocalhost && ip.IsLoopback() {
return false
}
if opts.AllowRFC1918 && IsRFC1918(ip) {
if p.AllowRFC1918 && IsRFC1918(ip) {
return false
}
if opts.BlockCGNAT && IsCGNAT(ip) {
if IsCGNAT(ip) && !p.AllowCGNAT {
return true
}
return IsPrivateIP(ip)
}

// policy derives the address policy from the client options.
func (o *ClientOptions) policy() AddressPolicy {
return AddressPolicy{
AllowLocalhost: o.AllowLocalhost,
AllowRFC1918: o.AllowRFC1918,
AllowCGNAT: o.AllowCGNAT,
}
}

// WithKeepAlive enables connection pooling (HTTP keep-alives) on the SSRF-safe
// client. Without this option the client's transport is byte-for-byte identical
// to today: keep-alives disabled, a single idle connection, IdleConnTimeout
Expand Down Expand Up @@ -441,6 +451,7 @@ func WithKeepAlive(maxIdle, perHost int, idleTimeout time.Duration) Option {
// This approach defeats Time-of-Check to Time-of-Use (TOCTOU) attacks where
// DNS could return different IPs between validation and connection.
func safeDialer(opts *ClientOptions) func(ctx context.Context, network, addr string) (net.Conn, error) {
policy := opts.policy()
return func(ctx context.Context, network, addr string) (net.Conn, error) {
// Parse host:port from address
host, port, err := net.SplitHostPort(addr)
Expand Down Expand Up @@ -470,7 +481,7 @@ func safeDialer(opts *ClientOptions) func(ctx context.Context, network, addr str
// This prevents attackers from using DNS load balancing to mix private/public IPs.
// The error deliberately omits the resolved address.
for _, ip := range ips {
if blockedByPolicy(ip.IP, opts) {
if policy.Blocked(ip.IP) {
return nil, fmt.Errorf("connection to private IP blocked for host %s: %w", host, ErrBlockedAddress)
}
}
Expand All @@ -479,7 +490,7 @@ func safeDialer(opts *ClientOptions) func(ctx context.Context, network, addr str
// blocked address can never be chosen even if the loops drift apart.
var selectedIP net.IP
for _, ip := range ips {
if !blockedByPolicy(ip.IP, opts) {
if !policy.Blocked(ip.IP) {
selectedIP = ip.IP
break
}
Expand All @@ -496,7 +507,7 @@ func safeDialer(opts *ClientOptions) func(ctx context.Context, network, addr str
}

// validateRedirectTarget checks if a redirect URL is safe to follow.
// It applies the same address policy as the dialer (blockedByPolicy), so the
// It applies the same address policy as the dialer (AddressPolicy.Blocked), so the
// AllowLocalhost, AllowRFC1918 and range-blocking options behave identically.
func validateRedirectTarget(req *http.Request, opts *ClientOptions) error {
host := req.URL.Hostname()
Expand All @@ -516,13 +527,14 @@ func validateRedirectTarget(req *http.Request, opts *ClientOptions) error {
ctx, cancel := context.WithTimeout(context.Background(), opts.DialTimeout)
defer cancel()

policy := opts.policy()
ips, err := lookupIPAddr(ctx, host)
if err != nil {
return fmt.Errorf("DNS resolution failed for redirect target %s: %w", host, err)
}

for _, ip := range ips {
if blockedByPolicy(ip.IP, opts) {
if policy.Blocked(ip.IP) {
return fmt.Errorf("redirect to private IP blocked for host %s: %w", host, ErrBlockedAddress)
}
}
Expand All @@ -541,7 +553,7 @@ func validateRedirectTarget(req *http.Request, opts *ClientOptions) error {
// - 10 second timeout
// - No redirects (returns http.ErrUseLastResponse)
// - Keep-alives disabled
// - Private IPs blocked
// - Private, shared-address and reserved ranges blocked
//
// Use functional options to customize behavior:
//
Expand Down
Loading
Loading