fix(gateway): harden fallback CIDR policy and share allowlist
This commit is contained in:
parent
69f8a1f630
commit
bf89c4101f
8 changed files with 398 additions and 81 deletions
|
|
@ -57,13 +57,13 @@ When `gateway.host` is a loopback address (`127.0.0.1`, `::1`, or `localhost`) a
|
|||
|
||||
1. Falls back to bind on `0.0.0.0`.
|
||||
2. Enforces a CIDR allowlist for gateway HTTP endpoints.
|
||||
3. Discovers local interface CIDRs only when `gateway.allowed_cidrs` is empty.
|
||||
3. Discovers private local interface CIDRs only when `gateway.allowed_cidrs` is empty.
|
||||
|
||||
CIDR sources in fallback mode:
|
||||
|
||||
- If `gateway.allowed_cidrs` is configured, that list is used.
|
||||
- If `gateway.allowed_cidrs` is empty, discovered local CIDRs are used.
|
||||
- If no non-loopback CIDR can be discovered, gateway startup fails.
|
||||
- If `gateway.allowed_cidrs` is empty, discovered private CIDRs are used (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `100.64.0.0/10`, `fc00::/7`).
|
||||
- If no private non-loopback CIDR can be discovered, gateway startup fails. On public-only hosts, configure `gateway.allowed_cidrs` explicitly.
|
||||
|
||||
Loopback clients are always allowed for local administration.
|
||||
|
||||
|
|
|
|||
|
|
@ -222,7 +222,7 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error
|
|||
runningServices.HealthServer.SetReloadFunc(reloadTrigger)
|
||||
agentLoop.SetReloadFunc(reloadTrigger)
|
||||
|
||||
if runningServices.ListenHost == gatewayFallbackBindHost {
|
||||
if isWildcardBindHost(runningServices.ListenHost) {
|
||||
fmt.Printf("✓ Gateway started (all interfaces, port %d)\n", pidData.Port)
|
||||
} else {
|
||||
fmt.Printf("✓ Gateway started on %s\n", runningServices.ListenAddr)
|
||||
|
|
@ -450,7 +450,7 @@ func setupAndStartServices(
|
|||
voiceAgent.Start(vaCtx)
|
||||
}
|
||||
|
||||
if runningServices.ListenHost == gatewayFallbackBindHost {
|
||||
if isWildcardBindHost(runningServices.ListenHost) {
|
||||
fmt.Printf(
|
||||
"✓ Health endpoints available on all interfaces at port %d (/health, /ready and /reload POST)\n",
|
||||
cfg.Gateway.Port,
|
||||
|
|
|
|||
|
|
@ -10,11 +10,13 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/netpolicy"
|
||||
)
|
||||
|
||||
const (
|
||||
gatewayDefaultLoopbackHost = "127.0.0.1"
|
||||
gatewayFallbackBindHost = "0.0.0.0"
|
||||
gatewayFallbackBindHostV4 = "0.0.0.0"
|
||||
gatewayFallbackBindHostV6 = "::"
|
||||
)
|
||||
|
||||
type gatewayListenDecision struct {
|
||||
|
|
@ -27,6 +29,14 @@ type gatewayListenDecision struct {
|
|||
var (
|
||||
probeGatewayBind = probeTCPBind
|
||||
discoverGatewayCIDRs = discoverLocalInterfaceCIDRs
|
||||
|
||||
fallbackPrivateCIDRs = mustParseCIDRs(
|
||||
"10.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
"100.64.0.0/10",
|
||||
"fc00::/7",
|
||||
)
|
||||
)
|
||||
|
||||
func resolveGatewayListenDecision(
|
||||
|
|
@ -71,7 +81,7 @@ func resolveGatewayListenDecision(
|
|||
}
|
||||
if len(discoveredCIDRs) == 0 {
|
||||
return nil, fmt.Errorf(
|
||||
"loopback bind %s:%d failed: %w; no non-loopback interface CIDRs discovered",
|
||||
"loopback bind %s:%d failed: %w; no private non-loopback interface CIDRs discovered",
|
||||
host,
|
||||
port,
|
||||
bindErr,
|
||||
|
|
@ -87,32 +97,73 @@ func resolveGatewayListenDecision(
|
|||
return nil, fmt.Errorf("loopback bind %s:%d failed: %w; fallback allowlist is empty", host, port, bindErr)
|
||||
}
|
||||
|
||||
if fallbackBindErr := probeGatewayBind(gatewayFallbackBindHost, port); fallbackBindErr != nil {
|
||||
fallbackCandidates := fallbackBindCandidates(host)
|
||||
fallbackHost, fallbackBindErr := probeFallbackBindCandidates(fallbackCandidates, port)
|
||||
if fallbackBindErr != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"loopback bind %s:%d failed: %w; fallback bind %s:%d failed: %v",
|
||||
"loopback bind %s:%d failed: %w; fallback bind candidates %v failed: %v",
|
||||
host,
|
||||
port,
|
||||
bindErr,
|
||||
gatewayFallbackBindHost,
|
||||
port,
|
||||
fallbackCandidates,
|
||||
fallbackBindErr,
|
||||
)
|
||||
}
|
||||
|
||||
return &gatewayListenDecision{
|
||||
BindHost: gatewayFallbackBindHost,
|
||||
BindHost: fallbackHost,
|
||||
AllowedCIDRs: fallbackCIDRs,
|
||||
AutoFallback: true,
|
||||
FallbackReason: fmt.Sprintf(
|
||||
"loopback bind %s:%d failed, fallback to %s:%d with CIDR allowlist",
|
||||
host,
|
||||
port,
|
||||
gatewayFallbackBindHost,
|
||||
fallbackHost,
|
||||
port,
|
||||
),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func fallbackBindCandidates(configuredLoopbackHost string) []string {
|
||||
lower := strings.TrimSpace(strings.ToLower(configuredLoopbackHost))
|
||||
if lower == "localhost" {
|
||||
return []string{gatewayFallbackBindHostV6, gatewayFallbackBindHostV4}
|
||||
}
|
||||
|
||||
ip := net.ParseIP(lower)
|
||||
if ip != nil && ip.To4() == nil {
|
||||
return []string{gatewayFallbackBindHostV6, gatewayFallbackBindHostV4}
|
||||
}
|
||||
|
||||
return []string{gatewayFallbackBindHostV4, gatewayFallbackBindHostV6}
|
||||
}
|
||||
|
||||
func probeFallbackBindCandidates(candidates []string, port int) (string, error) {
|
||||
errList := make([]error, 0, len(candidates))
|
||||
for _, host := range candidates {
|
||||
err := probeGatewayBind(host, port)
|
||||
if err == nil {
|
||||
return host, nil
|
||||
}
|
||||
errList = append(errList, fmt.Errorf("%s:%d: %w", host, port, err))
|
||||
}
|
||||
|
||||
if len(errList) == 0 {
|
||||
return "", fmt.Errorf("no fallback bind candidates")
|
||||
}
|
||||
|
||||
return "", errors.Join(errList...)
|
||||
}
|
||||
|
||||
func isWildcardBindHost(host string) bool {
|
||||
switch strings.TrimSpace(host) {
|
||||
case gatewayFallbackBindHostV4, gatewayFallbackBindHostV6:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeAndValidateCIDRs(cidrs []string) ([]string, error) {
|
||||
if len(cidrs) == 0 {
|
||||
return nil, nil
|
||||
|
|
@ -180,6 +231,9 @@ func discoverLocalInterfaceCIDRs() ([]string, error) {
|
|||
continue
|
||||
}
|
||||
ip = normalizeIPForMask(ip, mask)
|
||||
if !isSafeFallbackIP(ip) {
|
||||
continue
|
||||
}
|
||||
|
||||
masked := ip.Mask(mask)
|
||||
if masked == nil {
|
||||
|
|
@ -201,6 +255,30 @@ func discoverLocalInterfaceCIDRs() ([]string, error) {
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func mustParseCIDRs(cidrs ...string) []*net.IPNet {
|
||||
parsed := make([]*net.IPNet, 0, len(cidrs))
|
||||
for _, cidr := range cidrs {
|
||||
_, ipNet, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("invalid built-in fallback CIDR %q: %v", cidr, err))
|
||||
}
|
||||
parsed = append(parsed, ipNet)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func isSafeFallbackIP(ip net.IP) bool {
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
for _, ipNet := range fallbackPrivateCIDRs {
|
||||
if ipNet.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeIPForMask(ip net.IP, mask net.IPMask) net.IP {
|
||||
switch len(mask) {
|
||||
case net.IPv4len:
|
||||
|
|
@ -255,47 +333,23 @@ func isLoopbackHost(host string) bool {
|
|||
func newCIDRAllowlistMiddleware(allowedCIDRs []string) channels.HTTPMiddleware {
|
||||
effectiveCIDRs := append([]string(nil), allowedCIDRs...)
|
||||
return func(next http.Handler) (http.Handler, error) {
|
||||
if len(effectiveCIDRs) == 0 {
|
||||
allowlist, err := netpolicy.NewIPAllowlist(effectiveCIDRs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if allowlist.IsOpen() {
|
||||
return next, nil
|
||||
}
|
||||
|
||||
nets := make([]*net.IPNet, 0, len(effectiveCIDRs))
|
||||
for _, cidr := range effectiveCIDRs {
|
||||
_, ipNet, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid CIDR %q: %w", cidr, err)
|
||||
}
|
||||
nets = append(nets, ipNet)
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := clientIPFromRemoteAddr(r.RemoteAddr)
|
||||
if ip == nil {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
// Loopback is always allowed for local administration.
|
||||
// When deployed behind a local reverse proxy/tunnel, forwarded
|
||||
// external traffic may still appear as loopback at this layer.
|
||||
if ip.IsLoopback() {
|
||||
if allowlist.AllowsRemoteAddr(r.RemoteAddr) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
for _, ipNet := range nets {
|
||||
if ipNet.Contains(ip) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
}), nil
|
||||
}
|
||||
}
|
||||
|
||||
func clientIPFromRemoteAddr(remoteAddr string) net.IP {
|
||||
host := remoteAddr
|
||||
if h, _, err := net.SplitHostPort(remoteAddr); err == nil {
|
||||
host = h
|
||||
}
|
||||
return net.ParseIP(strings.TrimSpace(host))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ func TestResolveGatewayListenDecisionLoopbackFallbackUsesConfiguredCIDRs(t *test
|
|||
switch host {
|
||||
case "127.0.0.1":
|
||||
return errors.New("loopback unavailable")
|
||||
case gatewayFallbackBindHost:
|
||||
case gatewayFallbackBindHostV4, gatewayFallbackBindHostV6:
|
||||
return nil
|
||||
default:
|
||||
return nil
|
||||
|
|
@ -60,8 +60,8 @@ func TestResolveGatewayListenDecisionLoopbackFallbackUsesConfiguredCIDRs(t *test
|
|||
if !decision.AutoFallback {
|
||||
t.Fatal("decision.AutoFallback = false, want true")
|
||||
}
|
||||
if decision.BindHost != gatewayFallbackBindHost {
|
||||
t.Fatalf("decision.BindHost = %q, want %q", decision.BindHost, gatewayFallbackBindHost)
|
||||
if decision.BindHost != gatewayFallbackBindHostV4 {
|
||||
t.Fatalf("decision.BindHost = %q, want %q", decision.BindHost, gatewayFallbackBindHostV4)
|
||||
}
|
||||
wantCIDRs := []string{"10.0.0.0/8"}
|
||||
if !reflect.DeepEqual(decision.AllowedCIDRs, wantCIDRs) {
|
||||
|
|
@ -84,7 +84,7 @@ func TestResolveGatewayListenDecisionLoopbackFallbackUsesDiscoveredCIDRs(t *test
|
|||
switch host {
|
||||
case "localhost":
|
||||
return errors.New("loopback unavailable")
|
||||
case gatewayFallbackBindHost:
|
||||
case gatewayFallbackBindHostV4, gatewayFallbackBindHostV6:
|
||||
return nil
|
||||
default:
|
||||
return nil
|
||||
|
|
@ -146,7 +146,7 @@ func TestResolveGatewayListenDecisionLoopbackFailureNoDiscoveredCIDRs(t *testing
|
|||
switch host {
|
||||
case "127.0.0.1":
|
||||
return errors.New("loopback unavailable")
|
||||
case gatewayFallbackBindHost:
|
||||
case gatewayFallbackBindHostV4, gatewayFallbackBindHostV6:
|
||||
return nil
|
||||
default:
|
||||
return nil
|
||||
|
|
@ -160,11 +160,77 @@ func TestResolveGatewayListenDecisionLoopbackFailureNoDiscoveredCIDRs(t *testing
|
|||
if err == nil {
|
||||
t.Fatal("resolveGatewayListenDecision() expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no non-loopback interface CIDRs discovered") {
|
||||
if !strings.Contains(err.Error(), "no private non-loopback interface CIDRs discovered") {
|
||||
t.Fatalf("error = %q, want no-interface-cidr failure", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveGatewayListenDecisionLoopbackFallbackIPv6Preferred(t *testing.T) {
|
||||
origProbe := probeGatewayBind
|
||||
origDiscover := discoverGatewayCIDRs
|
||||
t.Cleanup(func() {
|
||||
probeGatewayBind = origProbe
|
||||
discoverGatewayCIDRs = origDiscover
|
||||
})
|
||||
|
||||
probeGatewayBind = func(host string, _ int) error {
|
||||
switch host {
|
||||
case "::1":
|
||||
return errors.New("loopback unavailable")
|
||||
case gatewayFallbackBindHostV6:
|
||||
return nil
|
||||
case gatewayFallbackBindHostV4:
|
||||
return errors.New("should not probe IPv4 when IPv6 fallback succeeds")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
discoverGatewayCIDRs = func() ([]string, error) {
|
||||
return []string{"192.168.1.0/24"}, nil
|
||||
}
|
||||
|
||||
decision, err := resolveGatewayListenDecision("::1", 18790, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveGatewayListenDecision() error = %v", err)
|
||||
}
|
||||
if decision.BindHost != gatewayFallbackBindHostV6 {
|
||||
t.Fatalf("decision.BindHost = %q, want %q", decision.BindHost, gatewayFallbackBindHostV6)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveGatewayListenDecisionLoopbackFallbackTriesSecondaryWildcard(t *testing.T) {
|
||||
origProbe := probeGatewayBind
|
||||
origDiscover := discoverGatewayCIDRs
|
||||
t.Cleanup(func() {
|
||||
probeGatewayBind = origProbe
|
||||
discoverGatewayCIDRs = origDiscover
|
||||
})
|
||||
|
||||
probeGatewayBind = func(host string, _ int) error {
|
||||
switch host {
|
||||
case "::1":
|
||||
return errors.New("loopback unavailable")
|
||||
case gatewayFallbackBindHostV6:
|
||||
return errors.New("ipv6 wildcard unavailable")
|
||||
case gatewayFallbackBindHostV4:
|
||||
return nil
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
discoverGatewayCIDRs = func() ([]string, error) {
|
||||
return []string{"192.168.1.0/24"}, nil
|
||||
}
|
||||
|
||||
decision, err := resolveGatewayListenDecision("::1", 18790, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveGatewayListenDecision() error = %v", err)
|
||||
}
|
||||
if decision.BindHost != gatewayFallbackBindHostV4 {
|
||||
t.Fatalf("decision.BindHost = %q, want %q", decision.BindHost, gatewayFallbackBindHostV4)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCIDRAllowlistMiddleware(t *testing.T) {
|
||||
mw := newCIDRAllowlistMiddleware([]string{"192.168.1.0/24"})
|
||||
h, err := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -220,3 +286,53 @@ func TestNormalizeIPForMaskIPv4MappedIPv6(t *testing.T) {
|
|||
t.Fatalf("masked network = %q, want %q", got, "192.168.10.0/24")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallbackBindCandidates(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
host string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "ipv4 loopback",
|
||||
host: "127.0.0.1",
|
||||
want: []string{gatewayFallbackBindHostV4, gatewayFallbackBindHostV6},
|
||||
},
|
||||
{name: "ipv6 loopback", host: "::1", want: []string{gatewayFallbackBindHostV6, gatewayFallbackBindHostV4}},
|
||||
{name: "localhost", host: "localhost", want: []string{gatewayFallbackBindHostV6, gatewayFallbackBindHostV4}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := fallbackBindCandidates(tt.host)
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("fallbackBindCandidates() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSafeFallbackIP(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ip string
|
||||
want bool
|
||||
}{
|
||||
{name: "rfc1918 a", ip: "10.1.2.3", want: true},
|
||||
{name: "rfc1918 b", ip: "172.20.1.1", want: true},
|
||||
{name: "rfc1918 c", ip: "192.168.1.1", want: true},
|
||||
{name: "cgnat", ip: "100.64.2.3", want: true},
|
||||
{name: "ipv6 ula", ip: "fd12::1", want: true},
|
||||
{name: "public ipv4", ip: "8.8.8.8", want: false},
|
||||
{name: "public ipv6", ip: "2001:4860:4860::8888", want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ip := net.ParseIP(tt.ip)
|
||||
if got := isSafeFallbackIP(ip); got != tt.want {
|
||||
t.Fatalf("isSafeFallbackIP(%q) = %v, want %v", tt.ip, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
81
pkg/netpolicy/allowlist.go
Normal file
81
pkg/netpolicy/allowlist.go
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
package netpolicy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// IPAllowlist evaluates whether a remote address is allowed by CIDR policy.
|
||||
// Loopback addresses are always allowed for local administration.
|
||||
type IPAllowlist struct {
|
||||
nets []*net.IPNet
|
||||
}
|
||||
|
||||
// NewIPAllowlist parses CIDR rules and constructs an allowlist checker.
|
||||
// Empty CIDRs means unrestricted policy.
|
||||
func NewIPAllowlist(allowedCIDRs []string) (*IPAllowlist, error) {
|
||||
if len(allowedCIDRs) == 0 {
|
||||
return &IPAllowlist{}, nil
|
||||
}
|
||||
|
||||
nets := make([]*net.IPNet, 0, len(allowedCIDRs))
|
||||
for _, cidr := range allowedCIDRs {
|
||||
_, ipNet, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid CIDR %q: %w", cidr, err)
|
||||
}
|
||||
nets = append(nets, ipNet)
|
||||
}
|
||||
|
||||
return &IPAllowlist{nets: nets}, nil
|
||||
}
|
||||
|
||||
// IsOpen reports whether the allowlist has no restrictions.
|
||||
func (a *IPAllowlist) IsOpen() bool {
|
||||
return a == nil || len(a.nets) == 0
|
||||
}
|
||||
|
||||
// AllowsRemoteAddr checks whether RemoteAddr is permitted.
|
||||
func (a *IPAllowlist) AllowsRemoteAddr(remoteAddr string) bool {
|
||||
if a.IsOpen() {
|
||||
return true
|
||||
}
|
||||
|
||||
ip := ClientIPFromRemoteAddr(remoteAddr)
|
||||
return a.AllowsIP(ip)
|
||||
}
|
||||
|
||||
// AllowsIP checks whether an IP is permitted.
|
||||
func (a *IPAllowlist) AllowsIP(ip net.IP) bool {
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
if ip.IsLoopback() {
|
||||
return true
|
||||
}
|
||||
if a.IsOpen() {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, ipNet := range a.nets {
|
||||
if ipNet.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ClientIPFromRemoteAddr parses the IP component from net/http RemoteAddr.
|
||||
func ClientIPFromRemoteAddr(remoteAddr string) net.IP {
|
||||
host := strings.TrimSpace(remoteAddr)
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = h
|
||||
}
|
||||
// Strip IPv6 zone identifier (for example: fe80::1%eth0).
|
||||
if i := strings.LastIndex(host, "%"); i != -1 {
|
||||
host = host[:i]
|
||||
}
|
||||
|
||||
return net.ParseIP(strings.TrimSpace(host))
|
||||
}
|
||||
71
pkg/netpolicy/allowlist_test.go
Normal file
71
pkg/netpolicy/allowlist_test.go
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
package netpolicy
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIPAllowlistOpenPolicy(t *testing.T) {
|
||||
allowlist, err := NewIPAllowlist(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewIPAllowlist() error = %v", err)
|
||||
}
|
||||
if !allowlist.IsOpen() {
|
||||
t.Fatal("allowlist should be open for empty CIDRs")
|
||||
}
|
||||
if !allowlist.AllowsRemoteAddr("203.0.113.7:1234") {
|
||||
t.Fatal("open policy should allow any remote address")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPAllowlistAllowsInsideCIDR(t *testing.T) {
|
||||
allowlist, err := NewIPAllowlist([]string{"192.168.1.0/24"})
|
||||
if err != nil {
|
||||
t.Fatalf("NewIPAllowlist() error = %v", err)
|
||||
}
|
||||
|
||||
if !allowlist.AllowsRemoteAddr("192.168.1.8:1234") {
|
||||
t.Fatal("allowlist should allow address inside CIDR")
|
||||
}
|
||||
if allowlist.AllowsRemoteAddr("10.0.0.8:1234") {
|
||||
t.Fatal("allowlist should reject address outside CIDR")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPAllowlistAlwaysAllowsLoopback(t *testing.T) {
|
||||
allowlist, err := NewIPAllowlist([]string{"192.168.1.0/24"})
|
||||
if err != nil {
|
||||
t.Fatalf("NewIPAllowlist() error = %v", err)
|
||||
}
|
||||
|
||||
if !allowlist.AllowsRemoteAddr("127.0.0.1:1234") {
|
||||
t.Fatal("loopback should always be allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIPFromRemoteAddrIPv6Zone(t *testing.T) {
|
||||
ip := ClientIPFromRemoteAddr("[fe80::1%eth0]:1234")
|
||||
if ip == nil {
|
||||
t.Fatal("ClientIPFromRemoteAddr() returned nil")
|
||||
}
|
||||
if got := ip.String(); got != "fe80::1" {
|
||||
t.Fatalf("ClientIPFromRemoteAddr() = %q, want %q", got, "fe80::1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewIPAllowlistInvalidCIDR(t *testing.T) {
|
||||
_, err := NewIPAllowlist([]string{"bad-cidr"})
|
||||
if err == nil {
|
||||
t.Fatal("NewIPAllowlist() expected error for invalid CIDR")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPAllowlistWithZoneAddressInCIDR(t *testing.T) {
|
||||
allowlist, err := NewIPAllowlist([]string{"fe80::/10"})
|
||||
if err != nil {
|
||||
t.Fatalf("NewIPAllowlist() error = %v", err)
|
||||
}
|
||||
|
||||
if !allowlist.AllowsRemoteAddr("[fe80::2%eth0]:1234") {
|
||||
t.Fatal("allowlist should accept IPv6 link-local with zone")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,58 +1,35 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/netpolicy"
|
||||
)
|
||||
|
||||
// IPAllowlist restricts access to requests from configured CIDR ranges.
|
||||
// Loopback addresses are always allowed for local administration.
|
||||
// Empty CIDR list means no restriction.
|
||||
func IPAllowlist(allowedCIDRs []string, next http.Handler) (http.Handler, error) {
|
||||
if len(allowedCIDRs) == 0 {
|
||||
allowlist, err := netpolicy.NewIPAllowlist(allowedCIDRs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if allowlist.IsOpen() {
|
||||
return next, nil
|
||||
}
|
||||
|
||||
nets := make([]*net.IPNet, 0, len(allowedCIDRs))
|
||||
for _, cidr := range allowedCIDRs {
|
||||
_, ipNet, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid CIDR %q: %w", cidr, err)
|
||||
}
|
||||
nets = append(nets, ipNet)
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := clientIPFromRemoteAddr(r.RemoteAddr)
|
||||
if ip == nil {
|
||||
rejectByPolicy(w, r)
|
||||
return
|
||||
}
|
||||
if ip.IsLoopback() {
|
||||
if allowlist.AllowsRemoteAddr(r.RemoteAddr) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
for _, ipNet := range nets {
|
||||
if ipNet.Contains(ip) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
rejectByPolicy(w, r)
|
||||
}), nil
|
||||
}
|
||||
|
||||
func clientIPFromRemoteAddr(remoteAddr string) net.IP {
|
||||
host := remoteAddr
|
||||
if h, _, err := net.SplitHostPort(remoteAddr); err == nil {
|
||||
host = h
|
||||
}
|
||||
return net.ParseIP(host)
|
||||
}
|
||||
|
||||
func rejectByPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
|
|
|||
|
|
@ -84,3 +84,21 @@ func TestIPAllowlist_InvalidCIDR(t *testing.T) {
|
|||
t.Fatal("IPAllowlist() expected error for invalid CIDR")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPAllowlist_AllowsIPv6ZoneAddress(t *testing.T) {
|
||||
h, err := IPAllowlist([]string{"fe80::/10"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("IPAllowlist() error = %v", err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.RemoteAddr = "[fe80::1%eth0]:1234"
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue