Files
pocket-id/backend/internal/outbound/policy.go
T

255 lines
6.6 KiB
Go

package outbound
import (
"errors"
"fmt"
"net/netip"
"net/url"
"slices"
"strings"
)
const (
KeywordPrivate = "private"
KeywordLoopback = "loopback"
KeywordNone = "none"
)
// privatePrefixes are the ranges the "private" keyword expands to, in addition to LOCAL_IPV6_RANGES
var privatePrefixes = mustParsePrefixes(
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"100.64.0.0/10",
"fc00::/7",
)
// loopbackPrefixes are the ranges the "loopback" keyword expands to
var loopbackPrefixes = mustParsePrefixes(
"127.0.0.0/8",
"::1/128",
)
// specialUsePrefixes are reserved ranges that netip's classification methods do not catch
var specialUsePrefixes = mustParsePrefixes(
"0.0.0.0/8",
"100.64.0.0/10",
"192.0.0.0/24",
"192.0.2.0/24",
"192.31.196.0/24",
"192.52.193.0/24",
"192.88.99.0/24",
"192.175.48.0/24",
"198.18.0.0/15",
"198.51.100.0/24",
"203.0.113.0/24",
"224.0.0.0/4",
"240.0.0.0/4",
"64:ff9b::/96",
"64:ff9b:1::/48",
"100::/64",
"100:0:0:1::/64",
"2001::/23",
"2001:db8::/32",
"2002::/16",
"2620:4f:8000::/48",
"3fff::/20",
"5f00::/16",
"fec0::/10",
"ff00::/8",
)
// Policy decides which destinations an outbound request may reach
type Policy struct {
// localIPv6 holds LOCAL_IPV6_RANGES, which are public IPv6 ranges the operator uses on the local network
localIPv6 []netip.Prefix
// allowedPrefixes are the non-public ranges the allowlist opens up
allowedPrefixes []netip.Prefix
// allowedHosts are exact hostnames whose resolved addresses are trusted
allowedHosts []string
// allowedHostSuffixes come from "*.example.com" entries and include the leading dot
allowedHostSuffixes []string
}
// ParsePolicy parses a comma-separated allowlist of keywords, IP addresses, CIDR ranges and hostnames
func ParsePolicy(value string, localIPv6 []netip.Prefix) (Policy, error) {
policy := Policy{localIPv6: localIPv6}
for entry := range strings.SplitSeq(value, ",") {
entry = strings.ToLower(strings.TrimSpace(entry))
if entry == "" {
continue
}
var err error
policy, err = policy.withEntry(entry)
if err != nil {
return Policy{}, err
}
}
return policy, nil
}
// withEntry returns a copy of the policy that also allows the given allowlist entry
func (p Policy) withEntry(entry string) (Policy, error) {
// Keywords expand to fixed sets of ranges
switch entry {
case KeywordNone:
return p, nil
case KeywordPrivate:
p.allowedPrefixes = append(p.allowedPrefixes, privatePrefixes...)
p.allowedPrefixes = append(p.allowedPrefixes, p.localIPv6...)
return p, nil
case KeywordLoopback:
p.allowedPrefixes = append(p.allowedPrefixes, loopbackPrefixes...)
return p, nil
}
// CIDR ranges and single addresses are compared against the IP that is actually dialed
if strings.Contains(entry, "/") {
prefix, err := netip.ParsePrefix(entry)
if err != nil {
return Policy{}, fmt.Errorf("'%s' is not a valid CIDR range", entry)
}
p.allowedPrefixes = append(p.allowedPrefixes, prefix.Masked())
return p, nil
}
if addr, err := netip.ParseAddr(entry); err == nil {
addr = addr.Unmap()
p.allowedPrefixes = append(p.allowedPrefixes, netip.PrefixFrom(addr, addr.BitLen()))
return p, nil
}
// Anything else must be a hostname, optionally with a leading wildcard label
if suffix, ok := strings.CutPrefix(entry, "*."); ok {
if !isValidHostname(suffix) {
return Policy{}, fmt.Errorf("'%s' is not a valid hostname pattern", entry)
}
p.allowedHostSuffixes = append(p.allowedHostSuffixes, "."+suffix)
return p, nil
}
if !isValidHostname(entry) {
return Policy{}, fmt.Errorf("'%s' is not a valid keyword, IP address, CIDR range or hostname", entry)
}
p.allowedHosts = append(p.allowedHosts, entry)
return p, nil
}
// AllowAddr reports whether a connection to the address is permitted
func (p Policy) AllowAddr(addr netip.Addr) bool {
// Zoned addresses are never contained in a prefix, and IPv4-mapped IPv6 addresses must match IPv4 rules
addr = addr.WithZone("").Unmap()
if !addr.IsValid() {
return false
}
if !p.isBlocked(addr) {
return true
}
for _, prefix := range p.allowedPrefixes {
if prefix.Contains(addr) {
return true
}
}
return false
}
// AllowURL reports whether the URL's host is trusted without checking the addresses it resolves to
func (p Policy) AllowURL(u *url.URL) bool {
host := normalizeHostname(u.Hostname())
if host == "" {
return false
}
if slices.Contains(p.allowedHosts, host) {
return true
}
for _, suffix := range p.allowedHostSuffixes {
if strings.HasSuffix(host, suffix) {
return true
}
}
return false
}
// hasTrustedHosts reports whether some requests may skip the address check
func (p Policy) hasTrustedHosts() bool {
return len(p.allowedHosts) > 0 || len(p.allowedHostSuffixes) > 0
}
// isBlocked reports whether the address is anything other than a public unicast address
func (p Policy) isBlocked(addr netip.Addr) bool {
if addr.IsLoopback() || addr.IsPrivate() || addr.IsUnspecified() ||
addr.IsLinkLocalUnicast() || addr.IsLinkLocalMulticast() ||
addr.IsInterfaceLocalMulticast() || addr.IsMulticast() {
return true
}
for _, prefix := range specialUsePrefixes {
if prefix.Contains(addr) {
return true
}
}
for _, prefix := range p.localIPv6 {
if prefix.Contains(addr) {
return true
}
}
return false
}
// normalizeHostname lowercases a hostname and drops the trailing dot of a fully qualified name
func normalizeHostname(host string) string {
return strings.TrimSuffix(strings.ToLower(host), ".")
}
// isValidHostname accepts DNS names made of letters, digits, hyphens, underscores and dots
func isValidHostname(host string) bool {
if host == "" || len(host) > 253 || strings.HasPrefix(host, ".") || strings.Contains(host, "..") {
return false
}
for _, r := range host {
if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' && r != '.' && r != '_' {
return false
}
}
return true
}
// ParseLocalIPv6Ranges parses the comma-separated LOCAL_IPV6_RANGES value
func ParseLocalIPv6Ranges(value string) ([]netip.Prefix, error) {
var prefixes []netip.Prefix
for entry := range strings.SplitSeq(value, ",") {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
prefix, err := netip.ParsePrefix(entry)
if err != nil {
return nil, err
}
if !prefix.Addr().Is6() || prefix.Addr().Is4In6() {
return nil, errors.New("range '" + entry + "' is not a valid IPv6 range")
}
prefixes = append(prefixes, prefix.Masked())
}
return prefixes, nil
}
func mustParsePrefixes(values ...string) []netip.Prefix {
prefixes := make([]netip.Prefix, len(values))
for i, value := range values {
prefixes[i] = netip.MustParsePrefix(value)
}
return prefixes
}