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`.
|
1. Falls back to bind on `0.0.0.0`.
|
||||||
2. Enforces a CIDR allowlist for gateway HTTP endpoints.
|
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:
|
CIDR sources in fallback mode:
|
||||||
|
|
||||||
- If `gateway.allowed_cidrs` is configured, that list is used.
|
- If `gateway.allowed_cidrs` is configured, that list is used.
|
||||||
- If `gateway.allowed_cidrs` is empty, discovered local CIDRs are used.
|
- 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 non-loopback CIDR can be discovered, gateway startup fails.
|
- 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.
|
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)
|
runningServices.HealthServer.SetReloadFunc(reloadTrigger)
|
||||||
agentLoop.SetReloadFunc(reloadTrigger)
|
agentLoop.SetReloadFunc(reloadTrigger)
|
||||||
|
|
||||||
if runningServices.ListenHost == gatewayFallbackBindHost {
|
if isWildcardBindHost(runningServices.ListenHost) {
|
||||||
fmt.Printf("✓ Gateway started (all interfaces, port %d)\n", pidData.Port)
|
fmt.Printf("✓ Gateway started (all interfaces, port %d)\n", pidData.Port)
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("✓ Gateway started on %s\n", runningServices.ListenAddr)
|
fmt.Printf("✓ Gateway started on %s\n", runningServices.ListenAddr)
|
||||||
|
|
@ -450,7 +450,7 @@ func setupAndStartServices(
|
||||||
voiceAgent.Start(vaCtx)
|
voiceAgent.Start(vaCtx)
|
||||||
}
|
}
|
||||||
|
|
||||||
if runningServices.ListenHost == gatewayFallbackBindHost {
|
if isWildcardBindHost(runningServices.ListenHost) {
|
||||||
fmt.Printf(
|
fmt.Printf(
|
||||||
"✓ Health endpoints available on all interfaces at port %d (/health, /ready and /reload POST)\n",
|
"✓ Health endpoints available on all interfaces at port %d (/health, /ready and /reload POST)\n",
|
||||||
cfg.Gateway.Port,
|
cfg.Gateway.Port,
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,13 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/channels"
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/netpolicy"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
gatewayDefaultLoopbackHost = "127.0.0.1"
|
gatewayDefaultLoopbackHost = "127.0.0.1"
|
||||||
gatewayFallbackBindHost = "0.0.0.0"
|
gatewayFallbackBindHostV4 = "0.0.0.0"
|
||||||
|
gatewayFallbackBindHostV6 = "::"
|
||||||
)
|
)
|
||||||
|
|
||||||
type gatewayListenDecision struct {
|
type gatewayListenDecision struct {
|
||||||
|
|
@ -27,6 +29,14 @@ type gatewayListenDecision struct {
|
||||||
var (
|
var (
|
||||||
probeGatewayBind = probeTCPBind
|
probeGatewayBind = probeTCPBind
|
||||||
discoverGatewayCIDRs = discoverLocalInterfaceCIDRs
|
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(
|
func resolveGatewayListenDecision(
|
||||||
|
|
@ -71,7 +81,7 @@ func resolveGatewayListenDecision(
|
||||||
}
|
}
|
||||||
if len(discoveredCIDRs) == 0 {
|
if len(discoveredCIDRs) == 0 {
|
||||||
return nil, fmt.Errorf(
|
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,
|
host,
|
||||||
port,
|
port,
|
||||||
bindErr,
|
bindErr,
|
||||||
|
|
@ -87,32 +97,73 @@ func resolveGatewayListenDecision(
|
||||||
return nil, fmt.Errorf("loopback bind %s:%d failed: %w; fallback allowlist is empty", host, port, bindErr)
|
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(
|
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,
|
host,
|
||||||
port,
|
port,
|
||||||
bindErr,
|
bindErr,
|
||||||
gatewayFallbackBindHost,
|
fallbackCandidates,
|
||||||
port,
|
|
||||||
fallbackBindErr,
|
fallbackBindErr,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &gatewayListenDecision{
|
return &gatewayListenDecision{
|
||||||
BindHost: gatewayFallbackBindHost,
|
BindHost: fallbackHost,
|
||||||
AllowedCIDRs: fallbackCIDRs,
|
AllowedCIDRs: fallbackCIDRs,
|
||||||
AutoFallback: true,
|
AutoFallback: true,
|
||||||
FallbackReason: fmt.Sprintf(
|
FallbackReason: fmt.Sprintf(
|
||||||
"loopback bind %s:%d failed, fallback to %s:%d with CIDR allowlist",
|
"loopback bind %s:%d failed, fallback to %s:%d with CIDR allowlist",
|
||||||
host,
|
host,
|
||||||
port,
|
port,
|
||||||
gatewayFallbackBindHost,
|
fallbackHost,
|
||||||
port,
|
port,
|
||||||
),
|
),
|
||||||
}, nil
|
}, 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) {
|
func normalizeAndValidateCIDRs(cidrs []string) ([]string, error) {
|
||||||
if len(cidrs) == 0 {
|
if len(cidrs) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
|
@ -180,6 +231,9 @@ func discoverLocalInterfaceCIDRs() ([]string, error) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
ip = normalizeIPForMask(ip, mask)
|
ip = normalizeIPForMask(ip, mask)
|
||||||
|
if !isSafeFallbackIP(ip) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
masked := ip.Mask(mask)
|
masked := ip.Mask(mask)
|
||||||
if masked == nil {
|
if masked == nil {
|
||||||
|
|
@ -201,6 +255,30 @@ func discoverLocalInterfaceCIDRs() ([]string, error) {
|
||||||
return out, nil
|
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 {
|
func normalizeIPForMask(ip net.IP, mask net.IPMask) net.IP {
|
||||||
switch len(mask) {
|
switch len(mask) {
|
||||||
case net.IPv4len:
|
case net.IPv4len:
|
||||||
|
|
@ -255,47 +333,23 @@ func isLoopbackHost(host string) bool {
|
||||||
func newCIDRAllowlistMiddleware(allowedCIDRs []string) channels.HTTPMiddleware {
|
func newCIDRAllowlistMiddleware(allowedCIDRs []string) channels.HTTPMiddleware {
|
||||||
effectiveCIDRs := append([]string(nil), allowedCIDRs...)
|
effectiveCIDRs := append([]string(nil), allowedCIDRs...)
|
||||||
return func(next http.Handler) (http.Handler, error) {
|
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
|
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) {
|
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.
|
// Loopback is always allowed for local administration.
|
||||||
// When deployed behind a local reverse proxy/tunnel, forwarded
|
// When deployed behind a local reverse proxy/tunnel, forwarded
|
||||||
// external traffic may still appear as loopback at this layer.
|
// external traffic may still appear as loopback at this layer.
|
||||||
if ip.IsLoopback() {
|
if allowlist.AllowsRemoteAddr(r.RemoteAddr) {
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, ipNet := range nets {
|
|
||||||
if ipNet.Contains(ip) {
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
}), nil
|
}), 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 {
|
switch host {
|
||||||
case "127.0.0.1":
|
case "127.0.0.1":
|
||||||
return errors.New("loopback unavailable")
|
return errors.New("loopback unavailable")
|
||||||
case gatewayFallbackBindHost:
|
case gatewayFallbackBindHostV4, gatewayFallbackBindHostV6:
|
||||||
return nil
|
return nil
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -60,8 +60,8 @@ func TestResolveGatewayListenDecisionLoopbackFallbackUsesConfiguredCIDRs(t *test
|
||||||
if !decision.AutoFallback {
|
if !decision.AutoFallback {
|
||||||
t.Fatal("decision.AutoFallback = false, want true")
|
t.Fatal("decision.AutoFallback = false, want true")
|
||||||
}
|
}
|
||||||
if decision.BindHost != gatewayFallbackBindHost {
|
if decision.BindHost != gatewayFallbackBindHostV4 {
|
||||||
t.Fatalf("decision.BindHost = %q, want %q", decision.BindHost, gatewayFallbackBindHost)
|
t.Fatalf("decision.BindHost = %q, want %q", decision.BindHost, gatewayFallbackBindHostV4)
|
||||||
}
|
}
|
||||||
wantCIDRs := []string{"10.0.0.0/8"}
|
wantCIDRs := []string{"10.0.0.0/8"}
|
||||||
if !reflect.DeepEqual(decision.AllowedCIDRs, wantCIDRs) {
|
if !reflect.DeepEqual(decision.AllowedCIDRs, wantCIDRs) {
|
||||||
|
|
@ -84,7 +84,7 @@ func TestResolveGatewayListenDecisionLoopbackFallbackUsesDiscoveredCIDRs(t *test
|
||||||
switch host {
|
switch host {
|
||||||
case "localhost":
|
case "localhost":
|
||||||
return errors.New("loopback unavailable")
|
return errors.New("loopback unavailable")
|
||||||
case gatewayFallbackBindHost:
|
case gatewayFallbackBindHostV4, gatewayFallbackBindHostV6:
|
||||||
return nil
|
return nil
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -146,7 +146,7 @@ func TestResolveGatewayListenDecisionLoopbackFailureNoDiscoveredCIDRs(t *testing
|
||||||
switch host {
|
switch host {
|
||||||
case "127.0.0.1":
|
case "127.0.0.1":
|
||||||
return errors.New("loopback unavailable")
|
return errors.New("loopback unavailable")
|
||||||
case gatewayFallbackBindHost:
|
case gatewayFallbackBindHostV4, gatewayFallbackBindHostV6:
|
||||||
return nil
|
return nil
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -160,11 +160,77 @@ func TestResolveGatewayListenDecisionLoopbackFailureNoDiscoveredCIDRs(t *testing
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("resolveGatewayListenDecision() expected error")
|
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())
|
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) {
|
func TestCIDRAllowlistMiddleware(t *testing.T) {
|
||||||
mw := newCIDRAllowlistMiddleware([]string{"192.168.1.0/24"})
|
mw := newCIDRAllowlistMiddleware([]string{"192.168.1.0/24"})
|
||||||
h, err := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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")
|
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
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/netpolicy"
|
||||||
)
|
)
|
||||||
|
|
||||||
// IPAllowlist restricts access to requests from configured CIDR ranges.
|
// IPAllowlist restricts access to requests from configured CIDR ranges.
|
||||||
// Loopback addresses are always allowed for local administration.
|
// Loopback addresses are always allowed for local administration.
|
||||||
// Empty CIDR list means no restriction.
|
// Empty CIDR list means no restriction.
|
||||||
func IPAllowlist(allowedCIDRs []string, next http.Handler) (http.Handler, error) {
|
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
|
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) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
ip := clientIPFromRemoteAddr(r.RemoteAddr)
|
if allowlist.AllowsRemoteAddr(r.RemoteAddr) {
|
||||||
if ip == nil {
|
|
||||||
rejectByPolicy(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if ip.IsLoopback() {
|
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, ipNet := range nets {
|
|
||||||
if ipNet.Contains(ip) {
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rejectByPolicy(w, r)
|
rejectByPolicy(w, r)
|
||||||
}), nil
|
}), 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) {
|
func rejectByPolicy(w http.ResponseWriter, r *http.Request) {
|
||||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
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")
|
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