diff --git a/cmd/sift/main.go b/cmd/sift/main.go index 31045d2..665216e 100644 --- a/cmd/sift/main.go +++ b/cmd/sift/main.go @@ -5,10 +5,13 @@ import ( "fmt" "log/slog" "net" + "net/netip" "os" "os/signal" + "strings" "syscall" + "git.wntrmute.dev/kyle/sift/internal/guard" "git.wntrmute.dev/kyle/sift/internal/server" ) @@ -22,10 +25,31 @@ func defaultConfigPath() string { return "/etc/sift/config.toml" } +type denyFlags []netip.Prefix + +func (d *denyFlags) String() string { + var b []string + for _, p := range *d { + b = append(b, p.String()) + } + return strings.Join(b, ",") +} + +func (d *denyFlags) Set(s string) error { + p, err := netip.ParsePrefix(s) + if err != nil { + return err + } + *d = append(*d, p) + return nil +} + func main() { listen := flag.String("listen", "127.0.0.1:8890", "listen address") configPath := flag.String("config", defaultConfigPath(), "config file (optional in P0)") showVersion := flag.Bool("version", false, "print version and exit") + var deny denyFlags + flag.Var(&deny, "deny-prefix", "extra CIDR to block (repeatable; home WAN /32s)") flag.Parse() if *showVersion { @@ -37,6 +61,10 @@ func main() { if _, err := os.Stat(*configPath); err == nil { log.Info("config file present but unused in P0", "path", *configPath) } + _ = &guard.Guard{DenyPrefixes: deny} // held for fetch in P1; validated at flag parse + if n := len(deny); n > 0 { + log.Info("deny prefixes loaded", "count", n) + } srv := server.New(version) hs := srv.HTTPServer(*listen) diff --git a/dist/sift.toml.example b/dist/sift.toml.example index d38cd07..ddc68bc 100644 --- a/dist/sift.toml.example +++ b/dist/sift.toml.example @@ -1,13 +1,20 @@ -# sift configuration. P0 only uses -listen; remaining keys are reserved for P1+. +# sift configuration. P0 uses -listen and -deny-prefix; remaining keys are P1+. # This file is an example: the service does not assume Nix store paths. listen = "127.0.0.1:8890" +# Extra CIDRs blocked by the SSRF guard (pre-check + Control). Home WAN /32s +# belong here so a fetch of the public IP cannot hairpin into the LAN. +# Set on-box; do not commit residential addresses to git. +# deny_prefixes = ["203.0.113.0/24"] + # [upstreams] # searxng = "http://127.0.0.1:8888" # dixie = "http://dixie.scylla-hammerhead.ts.net:11434/v1" # honcho = "http://rift.scylla-hammerhead.ts.net:8000" +# user_agent = "sift/dev" + # [digest] # model = "ornith-1.5-9b-uncensored" # thinking = false diff --git a/internal/guard/guard.go b/internal/guard/guard.go index 5068d4b..5a7e20d 100644 --- a/internal/guard/guard.go +++ b/internal/guard/guard.go @@ -30,13 +30,6 @@ import ( "time" ) -var ( - cgnat = netip.MustParsePrefix("100.64.0.0/10") // tailnet IPv4 (CGNAT) - tailnetULA = netip.MustParsePrefix("fd7a:115c:a1e0::/48") // tailnet IPv6 - nat64 = netip.MustParsePrefix("64:ff9b::/96") - ipv4zero = netip.MustParsePrefix("0.0.0.0/8") -) - const maxRedirects = 5 // LookupIP resolves a hostname to addresses. Tests inject this. @@ -45,6 +38,10 @@ type LookupIP func(ctx context.Context, host string) ([]netip.Addr, error) // Guard holds the SSRF policy. A zero Guard is ready to use. type Guard struct { LookupIP LookupIP + // DenyPrefixes are extra CIDRs blocked at pre-check and at Control + // (home WAN /32s that hairpin through the router into LAN services). + // Set from config deny_prefixes / -deny-prefix; never from the URL. + DenyPrefixes []netip.Prefix } func (g *Guard) lookup(ctx context.Context, host string) ([]netip.Addr, error) { @@ -232,14 +229,28 @@ func parseIPv4Part(s string) (uint64, bool) { } } -// CheckAddr is the address predicate applied to every resolved record and to -// the literal IP in Dialer.Control. +// CheckAddr is the default (no extra prefixes) address predicate. func CheckAddr(addr netip.Addr) error { + var g Guard + return g.CheckAddr(addr) +} + +// CheckAddr is the address predicate applied to every resolved record and to +// the literal IP in Dialer.Control. Extra DenyPrefixes are checked first. +func (g *Guard) CheckAddr(addr netip.Addr) error { if !addr.IsValid() { return blocked("invalid address") } addr = addr.Unmap() + if g != nil { + for _, p := range g.DenyPrefixes { + if p.Contains(addr) { + return blockedAddr("denied prefix", addr) + } + } + } + switch { case addr.IsUnspecified(): return blockedAddr("unspecified address", addr) @@ -253,33 +264,17 @@ func CheckAddr(addr netip.Addr) error { return blockedAddr("link-local address", addr) } - if nat64.Contains(addr) { - var b [4]byte - raw := addr.As16() - copy(b[:], raw[12:]) - embedded := netip.AddrFrom4(b) - if err := CheckAddr(embedded); err != nil { - return blockedAddr("NAT64-embedded", embedded) - } - return blockedAddr("NAT64 prefix", addr) + if err := checkIANA(addr); err != nil { + return err } if v4, ok := ipv4Compatible(addr); ok { - return CheckAddr(v4) + return g.CheckAddr(v4) } if !addr.IsGlobalUnicast() { return blockedAddr("non-global address", addr) } - if addr.Is4() && ipv4zero.Contains(addr) { - return blockedAddr("unspecified address", addr) - } - if addr.Is4() && cgnat.Contains(addr) { - return blockedAddr("tailnet address", addr) - } - if addr.Is6() && tailnetULA.Contains(addr) { - return blockedAddr("tailnet address", addr) - } return nil } @@ -311,7 +306,7 @@ func (g *Guard) CheckURL(ctx context.Context, raw string) (*url.URL, []netip.Add return nil, nil, err } if literal.IsValid() { - if err := CheckAddr(literal); err != nil { + if err := g.CheckAddr(literal); err != nil { return nil, nil, err } return u, []netip.Addr{literal}, nil @@ -324,7 +319,7 @@ func (g *Guard) CheckURL(ctx context.Context, raw string) (*url.URL, []netip.Add return nil, nil, blocked("no addresses for " + u.Hostname()) } for _, a := range addrs { - if err := CheckAddr(a); err != nil { + if err := g.CheckAddr(a); err != nil { return nil, nil, err } } @@ -334,6 +329,11 @@ func (g *Guard) CheckURL(ctx context.Context, raw string) (*url.URL, []netip.Add // Control is a net.Dialer Control / ControlContext hook. address is the // remote IP:port about to be connect()ed. func (g *Guard) Control(network, address string, _ syscall.RawConn) error { + switch network { + case "tcp", "tcp4", "tcp6": + default: + return blocked("network " + network) + } host, port, err := net.SplitHostPort(address) if err != nil { return blocked("dial address " + address) @@ -345,7 +345,7 @@ func (g *Guard) Control(network, address string, _ syscall.RawConn) error { if err != nil { return blocked("dial address not an IP " + host) } - return CheckAddr(addr) + return g.CheckAddr(addr) } // ControlContext is the context-aware form; policy does not use ctx today. @@ -389,7 +389,10 @@ func (g *Guard) Transport() *http.Transport { IdleConnTimeout: 30 * time.Second, TLSHandshakeTimeout: 5 * time.Second, ExpectContinueTimeout: 1 * time.Second, - DisableCompression: false, + // Go gunzips transparently when this is false. P1 size caps MUST + // apply to the decompressed resp.Body (io.LimitReader on the body + // the client returns); that also covers zip-bombs. + DisableCompression: false, } } diff --git a/internal/guard/guard_test.go b/internal/guard/guard_test.go index ad2a647..0c0aac1 100644 --- a/internal/guard/guard_test.go +++ b/internal/guard/guard_test.go @@ -539,3 +539,93 @@ func TestDialerType(t *testing.T) { t.Fatal("not a Dialer") } } + +func TestIANASpecialPurpose(t *testing.T) { + t.Parallel() + // One address per required prefix from the P0 review. + blocked := []string{ + "2002:a00:1::1", // 6to4 of 10.0.0.1 + "2002:7f00:1::1", // 6to4 of 127.0.0.1 + "2001::1", // Teredo 2001::/32 (inside 2001::/23) + "64:ff9b:1::a00:1", // RFC 8215 local-use NAT64 embedding 10.0.0.1 + "fec0::1", // deprecated site-local + "198.18.0.1", // benchmarking + "198.19.255.255", + "192.0.0.1", + "240.0.0.1", + "255.255.255.255", + "100::1", + "2001:db8::1", + "2001:10::1", // ORCHID + "2001:20::1", // ORCHIDv2 + } + for _, s := range blocked { + t.Run(s, func(t *testing.T) { + t.Parallel() + err := CheckAddr(mustAddr(s)) + if err == nil { + t.Fatalf("CheckAddr(%s) = nil, want blocked", s) + } + if !strings.HasPrefix(err.Error(), "blocked:") { + t.Fatalf("%s: %v", s, err) + } + }) + } + allowed := []string{"8.8.8.8", "1.1.1.1", "2001:4860:4860::8888"} + for _, s := range allowed { + if err := CheckAddr(mustAddr(s)); err != nil { + t.Fatalf("CheckAddr(%s) = %v, want allow", s, err) + } + } +} + +func TestConfiguredDenyPrefix(t *testing.T) { + t.Parallel() + // 8.8.8.8 is allowed by the IANA table; a configured deny must still block + // it (the hairpin/WAN case). + g := &Guard{ + DenyPrefixes: []netip.Prefix{netip.MustParsePrefix("8.8.8.8/32")}, + LookupIP: func(_ context.Context, host string) ([]netip.Addr, error) { + return []netip.Addr{mustAddr("8.8.8.8")}, nil + }, + } + if err := CheckAddr(mustAddr("8.8.8.8")); err != nil { + t.Fatalf("default CheckAddr allowed-set: %v", err) + } + if err := g.CheckAddr(mustAddr("8.8.8.8")); err == nil { + t.Fatal("configured /32 allowed") + } + if !strings.Contains(errString(g.CheckAddr(mustAddr("8.8.8.8"))), "denied prefix") { + t.Fatalf("want denied prefix, got %v", g.CheckAddr(mustAddr("8.8.8.8"))) + } + if _, _, err := g.CheckURL(context.Background(), "http://hairpin.example/"); err == nil { + t.Fatal("CheckURL with injected denied address allowed") + } + if err := g.Control("tcp", "8.8.8.8:80", nopConn{}); err == nil { + t.Fatal("Control allowed configured deny") + } + if err := g.CheckAddr(mustAddr("1.1.1.1")); err != nil { + t.Fatalf("unrelated public: %v", err) + } +} + +func errString(err error) string { + if err == nil { + return "" + } + return err.Error() +} + +func TestControlRejectsNonTCP(t *testing.T) { + t.Parallel() + g := &Guard{} + if err := g.Control("udp", "8.8.8.8:80", nopConn{}); err == nil { + t.Fatal("udp allowed") + } + if err := g.Control("tcp4", "8.8.8.8:80", nopConn{}); err != nil { + t.Fatal(err) + } + if err := g.Control("tcp6", "[2001:4860:4860::8888]:443", nopConn{}); err != nil { + t.Fatal(err) + } +} diff --git a/internal/guard/iana.go b/internal/guard/iana.go new file mode 100644 index 0000000..f5c8dc7 --- /dev/null +++ b/internal/guard/iana.go @@ -0,0 +1,63 @@ +package guard + +import "net/netip" + +// IANA special-purpose registries (IPv4 and IPv6). Families that can embed +// IPv4 (6to4, Teredo, well-known NAT64, local-use NAT64) are blocked outright +// rather than decoded. IsGlobalUnicast remains a final gate in CheckAddr. +// +// Sources: +// https://www.iana.org/assignments/iana-ipv4-special-registry +// https://www.iana.org/assignments/iana-ipv6-special-registry +var ianaDeny = []struct { + prefix netip.Prefix + reason string +}{ + // IPv4 + {p("0.0.0.0/8"), "unspecified address"}, + {p("10.0.0.0/8"), "private address"}, + {p("100.64.0.0/10"), "tailnet address"}, + {p("127.0.0.0/8"), "loopback address"}, + {p("169.254.0.0/16"), "link-local address"}, + {p("172.16.0.0/12"), "private address"}, + {p("192.0.0.0/24"), "special-purpose address"}, + {p("192.0.2.0/24"), "special-purpose address"}, + {p("192.31.196.0/24"), "special-purpose address"}, + {p("192.52.193.0/24"), "special-purpose address"}, + {p("192.88.99.0/24"), "special-purpose address"}, + {p("192.168.0.0/16"), "private address"}, + {p("192.175.48.0/24"), "special-purpose address"}, + {p("198.18.0.0/15"), "special-purpose address"}, + {p("198.51.100.0/24"), "special-purpose address"}, + {p("203.0.113.0/24"), "special-purpose address"}, + {p("224.0.0.0/4"), "multicast address"}, + {p("240.0.0.0/4"), "special-purpose address"}, + {p("255.255.255.255/32"), "special-purpose address"}, + + // IPv6 + {p("::/128"), "unspecified address"}, + {p("::1/128"), "loopback address"}, + {p("64:ff9b::/96"), "NAT64 prefix"}, + {p("64:ff9b:1::/48"), "NAT64 prefix"}, + {p("100::/64"), "special-purpose address"}, + {p("2001::/23"), "special-purpose address"}, // IETF protocol: Teredo, ORCHID, ORCHIDv2, … + {p("2001:db8::/32"), "special-purpose address"}, + {p("2002::/16"), "special-purpose address"}, // 6to4; do not decode embedded v4 + {p("3fff::/20"), "special-purpose address"}, // documentation (RFC 9637) + {p("fc00::/7"), "private address"}, + {p("fe80::/10"), "link-local address"}, + {p("fec0::/10"), "special-purpose address"}, // deprecated site-local + {p("ff00::/8"), "multicast address"}, + {p("fd7a:115c:a1e0::/48"), "tailnet address"}, +} + +func p(s string) netip.Prefix { return netip.MustParsePrefix(s) } + +func checkIANA(addr netip.Addr) error { + for _, e := range ianaDeny { + if e.prefix.Contains(addr) { + return blockedAddr(e.reason, addr) + } + } + return nil +} diff --git a/nix/module.nix b/nix/module.nix index 646c8ee..c6a953b 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -30,6 +30,16 @@ in description = "HTTPS port for tailscale serve (443 is SearXNG on orion)."; }; + denyPrefixes = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = '' + Extra CIDRs the SSRF guard blocks at pre-check and connect time. + Put home WAN /32s here (hairpin into LAN). Set on the host; do not + commit residential IPs to the sift repo. + ''; + }; + settings = lib.mkOption { type = settingsFormat.type; default = { }; @@ -89,7 +99,8 @@ in Type = "simple"; User = "sift"; Group = "sift"; - ExecStart = "${lib.getExe cfg.package} -listen ${cfg.listen} -config /etc/sift/config.toml"; + ExecStart = "${lib.getExe cfg.package} -listen ${cfg.listen} -config /etc/sift/config.toml" + + lib.concatMapStrings (p: " -deny-prefix ${lib.escapeShellArg p}") cfg.denyPrefixes; WorkingDirectory = "/var/lib/sift"; Restart = "on-failure"; RestartSec = "5s";