// Package egress defines the wire-independent policy for host-mediated plugin // HTTP egress (ADR 0023, cms repo): the host-pattern grammar and matching, the // default resource bounds, and the ErrDenied sentinel. Both the guest SDK // (validating plugin.mod at build) and the cms host (enforcing grants at // fetch) import this package so the pattern semantics have exactly one // definition. package egress import ( "errors" "fmt" "net/url" "strconv" "strings" "golang.org/x/net/publicsuffix" ) // ErrDenied marks an egress request refused by policy — host not granted, // grant revoked, platform-denylisted, or an off-allowlist redirect. It is // deliberately distinct from a network failure: a plugin can catch it and // degrade gracefully. It maps to ABI_ERROR_CODE_EGRESS_DENIED across the wire. var ErrDenied = errors.New("egress denied") const ( // DefaultMaxResponseBytes is the per-request response cap when a plugin // declares no larger one. Oversize fails the request, never truncates. DefaultMaxResponseBytes = 10 << 20 // MaxRequestBytes caps the request body a guest may marshal across the // host_call boundary. MaxRequestBytes = 10 << 20 // DefaultPort is the port a host pattern authorizes when it names none. DefaultPort = 443 ) // Pattern is one parsed allowed_hosts entry: an exact host or a left-anchored // multi-label wildcard, optionally pinned to a non-default port. type Pattern struct { // host is the bare hostname (wildcard: the base domain after "*."). host string wildcard bool port int } // ParsePattern parses and validates one allowed_hosts entry. It rejects bare // "*", public-suffix wildcards ("*.com", "*.co.uk"), embedded schemes/paths, // and malformed ports. A leading "*." marks a multi-label wildcard whose base // must be a registrable domain (≥2 labels and not itself a public suffix). func ParsePattern(raw string) (Pattern, error) { s := strings.TrimSpace(strings.ToLower(raw)) if s == "" { return Pattern{}, fmt.Errorf("empty host pattern") } if strings.ContainsAny(s, "/:@") && !strings.Contains(s, ":") { return Pattern{}, fmt.Errorf("host pattern %q must be a bare host, not a URL", raw) } if strings.Contains(s, "://") || strings.ContainsAny(s, "/@") { return Pattern{}, fmt.Errorf("host pattern %q must be a bare host, not a URL", raw) } port := DefaultPort host := s if h, p, ok := splitHostPort(s); ok { host = h parsed, err := strconv.Atoi(p) if err != nil || parsed < 1 || parsed > 65535 { return Pattern{}, fmt.Errorf("host pattern %q has an invalid port", raw) } port = parsed } wildcard := false if strings.HasPrefix(host, "*.") { wildcard = true host = strings.TrimPrefix(host, "*.") if host == "" || strings.Contains(host, "*") { return Pattern{}, fmt.Errorf("host pattern %q: only a single leading %q wildcard is allowed", raw, "*.") } if strings.Count(host, ".") < 1 { return Pattern{}, fmt.Errorf("host pattern %q: wildcard base must be a registrable domain (e.g. *.cal.com)", raw) } if suffix, _ := publicsuffix.PublicSuffix(host); suffix == host { return Pattern{}, fmt.Errorf("host pattern %q: cannot wildcard a public suffix", raw) } } if host == "" || strings.Contains(host, "*") { return Pattern{}, fmt.Errorf("host pattern %q is malformed", raw) } if !validHostname(host) { return Pattern{}, fmt.Errorf("host pattern %q is not a valid hostname", raw) } return Pattern{host: host, wildcard: wildcard, port: port}, nil } // matches reports whether this pattern authorizes host:port. func (p Pattern) matches(host string, port int) bool { if port != p.port { return false } host = strings.ToLower(strings.TrimSuffix(host, ".")) if p.wildcard { return strings.HasSuffix(host, "."+p.host) } return host == p.host } // Allowlist is a plugin's parsed egress grant. type Allowlist []Pattern // ParseAllowlist parses every entry, failing on the first invalid one. func ParseAllowlist(raw []string) (Allowlist, error) { out := make(Allowlist, 0, len(raw)) for _, r := range raw { p, err := ParsePattern(r) if err != nil { return nil, err } out = append(out, p) } return out, nil } // Allows returns nil if u is authorized by this allowlist, else an ErrDenied. // It enforces the https-only scheme rule and the port pinning; the caller // still applies the SSRF IP guard and platform denylist. func (a Allowlist) Allows(u *url.URL) error { if u == nil { return fmt.Errorf("%w: nil URL", ErrDenied) } if strings.ToLower(u.Scheme) != "https" { return fmt.Errorf("%w: %q is not https", ErrDenied, u.Scheme) } host := u.Hostname() if host == "" { return fmt.Errorf("%w: missing host", ErrDenied) } port := DefaultPort if p := u.Port(); p != "" { parsed, err := strconv.Atoi(p) if err != nil { return fmt.Errorf("%w: invalid port", ErrDenied) } port = parsed } for _, pat := range a { if pat.matches(host, port) { return nil } } return fmt.Errorf("%w: host %q not in allowlist", ErrDenied, u.Host) } func splitHostPort(s string) (host, port string, ok bool) { i := strings.LastIndex(s, ":") if i < 0 { return "", "", false } // Reject bracketed IPv6 / multiple colons — patterns are hostnames. if strings.Contains(s[:i], ":") { return "", "", false } return s[:i], s[i+1:], true } func validHostname(h string) bool { if len(h) > 253 { return false } for _, label := range strings.Split(h, ".") { if label == "" || len(label) > 63 { return false } for _, r := range label { if !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') && r != '-' { return false } } if label[0] == '-' || label[len(label)-1] == '-' { return false } } return true }