This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
// Package netguard blocks server-initiated connections to non-public
|
||||
// destinations. It backs URL previews, CardDAV sync and IMAP connections so a
|
||||
// user-supplied host can never point the server at another user's internal
|
||||
// service, the Docker database, or cloud metadata endpoints.
|
||||
package netguard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AllowPrivate reports whether range checks are disabled via
|
||||
// INTEGRATIONS_ALLOW_PRIVATE=1. Tests set it for loopback fake servers;
|
||||
// operators can set it for deliberate LAN deployments (home Nextcloud or
|
||||
// mail on private addresses). It is off in the production Compose stack.
|
||||
func AllowPrivate() bool { return os.Getenv("INTEGRATIONS_ALLOW_PRIVATE") == "1" }
|
||||
|
||||
// extraBlocked covers ranges net.IP's helpers miss. IPv4: "this network"
|
||||
// (0.0.0.0/8), RFC 6598 CGNAT (100.64.0.0/10 — used by Tailscale, GKE
|
||||
// internal LBs, some Docker setups), IETF protocol assignments
|
||||
// (192.0.0.0/24), TEST-NET-1/2/3 documentation space (192.0.2.0/24,
|
||||
// 198.51.100.0/24, 203.0.113.0/24), deprecated 6to4 relay anycast
|
||||
// (192.88.99.0/24), benchmark space (198.18.0.0/15), and reserved/limited
|
||||
// broadcast space (240.0.0.0/4, which includes 255.255.255.255).
|
||||
// IPv6: discard-only (100::/64), benchmarking (2001:2::/48), ORCHID/ORCHIDv2
|
||||
// (2001:10::/28, 2001:20::/28), documentation (2001:db8::/32, 3fff::/20),
|
||||
// SRv6 SIDs (5f00::/16), deprecated 6bone (3ffe::/16), and site-local
|
||||
// fec0::/10. The transition prefixes with embedded IPv4 (6to4, Teredo,
|
||||
// IPv4-compatible/translated, NAT64) are handled explicitly below.
|
||||
var extraBlocked = func() []net.IPNet {
|
||||
cidrs := []string{
|
||||
"0.0.0.0/8", "100.64.0.0/10", "192.0.0.0/24", "192.0.2.0/24",
|
||||
"192.88.99.0/24", "198.18.0.0/15", "198.51.100.0/24", "203.0.113.0/24",
|
||||
"240.0.0.0/4",
|
||||
"100::/64", "2001:2::/48", "2001:10::/28", "2001:20::/28",
|
||||
"2001:db8::/32", "3ffe::/16", "3fff::/20", "5f00::/16", "fec0::/10",
|
||||
}
|
||||
nets := make([]net.IPNet, 0, len(cidrs))
|
||||
for _, cidr := range cidrs {
|
||||
_, n, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
panic("netguard: bad CIDR " + cidr)
|
||||
}
|
||||
nets = append(nets, *n)
|
||||
}
|
||||
return nets
|
||||
}()
|
||||
|
||||
// parsePrefix is the panic-on-programmer-error helper for the fixed policy
|
||||
// prefixes below.
|
||||
func parsePrefix(cidr string) net.IPNet {
|
||||
_, n, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
panic("netguard: bad CIDR " + cidr)
|
||||
}
|
||||
return *n
|
||||
}
|
||||
|
||||
// Transition prefixes that embed an IPv4 destination in a different place
|
||||
// than the well-known NAT64 layout. They are the "rare IPv6 tricks" from the
|
||||
// review: without these checks an IPv6 literal can smuggle a loopback or
|
||||
// RFC 1918 destination past the range guard.
|
||||
var (
|
||||
// RFC 6052 well-known NAT64: low 32 bits are the IPv4 destination.
|
||||
nat64Prefix = parsePrefix("64:ff9b::/96")
|
||||
// RFC 8215 local-use NAT64: never a public destination.
|
||||
nat64LocalPrefix = parsePrefix("64:ff9b:1::/48")
|
||||
// RFC 3056 6to4: bits 16..47 are the embedded IPv4 destination.
|
||||
sixToFourPrefix = parsePrefix("2002::/16")
|
||||
// RFC 4380 Teredo: block the whole prefix. Its client address is
|
||||
// obfuscated and the route depends on a relay, so judging an embedded
|
||||
// value is both unreliable and unnecessary for public web/mail targets.
|
||||
teredoPrefix = parsePrefix("2001::/32")
|
||||
// Deprecated IPv4-compatible (::/96) and IPv4-translated
|
||||
// (::ffff:0:0:0/96) addresses. These are not IPv4-mapped addresses (those
|
||||
// have To4() != nil and are judged directly above).
|
||||
ipv4CompatiblePrefix = parsePrefix("::/96")
|
||||
ipv4TranslatedPrefix = parsePrefix("::ffff:0:0:0/96")
|
||||
)
|
||||
|
||||
func publicIPv4(ip net.IP) bool {
|
||||
v4 := ip.To4()
|
||||
if v4 == nil || !v4.IsGlobalUnicast() || v4.IsPrivate() || v4.IsLoopback() || v4.IsLinkLocalUnicast() {
|
||||
return false
|
||||
}
|
||||
for _, n := range extraBlocked {
|
||||
if n.Contains(v4) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Destination policy (review follow-up, stated explicitly): allowed targets
|
||||
// are globally routable unicast addresses only. Blocked: private (RFC 1918,
|
||||
// ULA fc00::/7), loopback, link-local, CGNAT, benchmark, IETF protocol and
|
||||
// documentation ranges, unspecified/broadcast/multicast, IPv4-mapped
|
||||
// addresses whose embedded IPv4 is non-public, and transition addresses
|
||||
// (6to4/NAT64) whose embedded IPv4 is non-public. Teredo,
|
||||
// IPv4-compatible/translated and local-use NAT64 prefixes are blocked
|
||||
// wholesale. INTEGRATIONS_ALLOW_PRIVATE=1 (fixtures and deliberate LAN
|
||||
// deployments) is the only relaxation.
|
||||
func IsPublic(ip net.IP) bool {
|
||||
if ip == nil || !ip.IsGlobalUnicast() || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsMulticast() || ip.IsUnspecified() {
|
||||
return false
|
||||
}
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
return publicIPv4(v4)
|
||||
}
|
||||
v6 := ip.To16()
|
||||
if v6 == nil {
|
||||
return false
|
||||
}
|
||||
if ipv4CompatiblePrefix.Contains(v6) || ipv4TranslatedPrefix.Contains(v6) {
|
||||
return false
|
||||
}
|
||||
if sixToFourPrefix.Contains(v6) {
|
||||
return publicIPv4(net.IPv4(v6[2], v6[3], v6[4], v6[5]))
|
||||
}
|
||||
if teredoPrefix.Contains(v6) || nat64LocalPrefix.Contains(v6) {
|
||||
return false
|
||||
}
|
||||
if nat64Prefix.Contains(v6) {
|
||||
return publicIPv4(net.IPv4(v6[12], v6[13], v6[14], v6[15]))
|
||||
}
|
||||
for _, n := range extraBlocked {
|
||||
if n.Contains(v6) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
var errBlocked = errors.New("netguard: private destination blocked")
|
||||
var errBlockedPort = errors.New("netguard: destination port blocked")
|
||||
|
||||
// DialContextPorts is DialContext with an allowlist of destination ports.
|
||||
// Callers that fetch user-supplied URLs use it to keep outbound traffic on
|
||||
// the protocol's normal ports: a preview has no business dialing SSH or a
|
||||
// database port on a public host. Fixtures and deliberate LAN deployments
|
||||
// can bypass it by calling DialContext directly when AllowPrivate is set.
|
||||
func DialContextPorts(ctx context.Context, network, address string, allowed ...int) (net.Conn, error) {
|
||||
_, rawPort, e := net.SplitHostPort(address)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
port, e := strconv.Atoi(rawPort)
|
||||
if e != nil {
|
||||
return nil, errBlockedPort
|
||||
}
|
||||
ok := false
|
||||
for _, p := range allowed {
|
||||
if port == p {
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
return nil, errBlockedPort
|
||||
}
|
||||
return DialContext(ctx, network, address)
|
||||
}
|
||||
|
||||
// dialMaxAttempts bounds the fallback across already-validated addresses
|
||||
// (review follow-up): the first A/AAAA record being unreachable does not make
|
||||
// the host unreachable, but we never chase an unbounded list — and there is
|
||||
// deliberately no second DNS lookup anywhere in this path.
|
||||
const dialMaxAttempts = 3
|
||||
|
||||
// DialContext resolves the host, rejects non-public destinations, then dials
|
||||
// the checked IPs. Check and dial use the same lookup result, so there is no
|
||||
// DNS-rebinding window between them. Every resolved address must pass the
|
||||
// range check (a host that mixes public and private records is refused
|
||||
// outright), and the dial then falls back among the validated addresses
|
||||
// instead of failing on the first unreachable one.
|
||||
func DialContext(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
host, port, e := net.SplitHostPort(address)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
ips, e := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
return nil, errors.New("netguard: unresolved address")
|
||||
}
|
||||
if !AllowPrivate() {
|
||||
for _, a := range ips {
|
||||
if !IsPublic(a.IP) {
|
||||
return nil, errBlocked
|
||||
}
|
||||
}
|
||||
}
|
||||
dialer := &net.Dialer{Timeout: 10 * time.Second}
|
||||
var lastErr error
|
||||
for i, a := range ips {
|
||||
if i >= dialMaxAttempts {
|
||||
break
|
||||
}
|
||||
var conn net.Conn
|
||||
conn, lastErr = dialer.DialContext(ctx, network, net.JoinHostPort(a.IP.String(), port))
|
||||
if lastErr == nil {
|
||||
return conn, nil
|
||||
}
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package netguard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestIsPublic(t *testing.T) {
|
||||
public := []string{
|
||||
"8.8.8.8", "1.1.1.1", "2001:4860:4860::8888",
|
||||
// NAT64 and 6to4 wrapping a PUBLIC embedded IPv4 address a real
|
||||
// public destination on IPv6-only networks — allowed by policy.
|
||||
"64:ff9b::8.8.8.8", "2002:808:808::",
|
||||
}
|
||||
blocked := []string{
|
||||
"127.0.0.1", "::1", // loopback
|
||||
"10.1.2.3", "172.16.0.1", "192.168.1.1", "fc00::1", "fd12::1", "fec0::1", // RFC1918 + ULA + site-local
|
||||
"169.254.169.254", "fe80::1", // link-local / cloud metadata
|
||||
"100.64.0.1", "100.127.255.254", // RFC 6598 CGNAT
|
||||
"198.18.0.1", "198.19.255.255", // benchmark
|
||||
"192.0.0.1", // IETF protocol assignments
|
||||
"192.0.2.1", "198.51.100.1", "203.0.113.1", "2001:db8::1", "3fff::1", // documentation space
|
||||
"0.1.2.3", "240.0.0.1", "255.255.255.255", // this-network / reserved / broadcast
|
||||
"0.0.0.0", "::", "224.0.0.1", "ff02::1", // unspecified / multicast
|
||||
"::ffff:10.0.0.1", "::ffff:127.0.0.1", // IPv4-mapped private/loopback
|
||||
"64:ff9b::7f00:1", "64:ff9b::a00:1", "64:ff9b::a9fe:a9fe", // NAT64-embedded loopback / private / metadata
|
||||
"64:ff9b:1::8.8.8.8", // local-use NAT64 is never public
|
||||
"2002:7f00:1::", "2002:a00:1::", "2002:a9fe:a9fe::", // 6to4-embedded loopback / private / metadata
|
||||
"2001:0:0:0:0:0:7f00:1", "2001:0:4136:e378:8000:63bf:3fff:fdd2", // Teredo
|
||||
"::127.0.0.1", "::10.0.0.1", "::ffff:0:7f00:1", "::ffff:0:10.0.0.1", // IPv4-compatible / translated
|
||||
"100::1", "2001:2::1", "2001:10::1", "2001:20::1", "5f00::1", "3ffe::1", // discard, benchmark, ORCHID, SRv6, 6bone
|
||||
}
|
||||
for _, s := range public {
|
||||
if !IsPublic(net.ParseIP(s)) {
|
||||
t.Errorf("IsPublic(%s) = false, want true", s)
|
||||
}
|
||||
}
|
||||
for _, s := range blocked {
|
||||
if IsPublic(net.ParseIP(s)) {
|
||||
t.Errorf("IsPublic(%s) = true, want false", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowPrivateEnv(t *testing.T) {
|
||||
if AllowPrivate() {
|
||||
t.Fatal("AllowPrivate must default to false")
|
||||
}
|
||||
t.Setenv("INTEGRATIONS_ALLOW_PRIVATE", "1")
|
||||
if !AllowPrivate() {
|
||||
t.Fatal("AllowPrivate must honor INTEGRATIONS_ALLOW_PRIVATE=1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialContextBlocksLoopbackByDefault(t *testing.T) {
|
||||
if _, e := DialContext(context.Background(), "tcp", "127.0.0.1:5432"); e == nil {
|
||||
t.Fatal("loopback destination accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialContextPortsRejectsUnusualPorts(t *testing.T) {
|
||||
// Port policy is checked before DNS, so this never dials.
|
||||
if _, e := DialContextPorts(context.Background(), "tcp", "8.8.8.8:22", 80, 443); e == nil {
|
||||
t.Fatal("SSH port accepted for a URL preview")
|
||||
}
|
||||
if _, e := DialContextPorts(context.Background(), "tcp", "8.8.8.8:not-a-port", 80, 443); e == nil {
|
||||
t.Fatal("non-numeric port accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// The NAT64 well-known prefix must not smuggle a non-public IPv4 destination
|
||||
// past the guard (review follow-up: comprehensive special-range coverage).
|
||||
func TestDialContextBlocksNAT64WrappedLoopback(t *testing.T) {
|
||||
if _, e := DialContext(context.Background(), "tcp", "[64:ff9b::7f00:1]:80"); e == nil {
|
||||
t.Fatal("NAT64-wrapped loopback destination accepted")
|
||||
}
|
||||
if _, e := DialContext(context.Background(), "tcp", "[64:ff9b::a9fe:a9fe]:80"); e == nil {
|
||||
t.Fatal("NAT64-wrapped metadata destination accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// Bounded fallback among already-validated addresses (review follow-up): a
|
||||
// host that resolves to several addresses connects even when the first
|
||||
// record is unreachable. "localhost" resolves to ::1 and/or 127.0.0.1
|
||||
// depending on the resolver; the listener binds only IPv4 loopback, so on
|
||||
// dual-stack machines the ::1 attempt must fail over to 127.0.0.1.
|
||||
func TestDialContextFallsBackAmongValidatedAddresses(t *testing.T) {
|
||||
t.Setenv("INTEGRATIONS_ALLOW_PRIVATE", "1")
|
||||
ln, e := net.Listen("tcp4", "127.0.0.1:0")
|
||||
if e != nil {
|
||||
t.Skipf("no IPv4 loopback listener possible: %v", e)
|
||||
}
|
||||
defer ln.Close()
|
||||
go func() {
|
||||
for {
|
||||
c, e := ln.Accept()
|
||||
if e != nil {
|
||||
return
|
||||
}
|
||||
c.Close()
|
||||
}
|
||||
}()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
conn, e := DialContext(ctx, "tcp", fmt.Sprintf("localhost:%d", ln.Addr().(*net.TCPAddr).Port))
|
||||
if e != nil {
|
||||
t.Fatalf("fallback dial to localhost failed: %v", e)
|
||||
}
|
||||
conn.Close()
|
||||
}
|
||||
Reference in New Issue
Block a user