pluginsdk/egress/egress.go
Alex Dunmow 39427c40e7 feat(egress): http.request capability + allowed_hosts manifest (ADR 0023)
Host-mediated outbound HTTP for wasm plugins: guest http.RoundTripper
over the http.request capability; the host performs the request under
policy. New egress package defines the host-pattern grammar (exact or
multi-label wildcard, public-suffix rejected), matching, resource
bounds, and the ErrDenied sentinel (ABI_ERROR_CODE_EGRESS_DENIED).
plugin.mod gains first-class AllowedHosts + MaxResponseMB; manifest
gains allowed_hosts=34, max_response_mb=35. CoreServices.OutboundHTTP
carries the transport. Golden roundtrip + denial-mapping tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 02:00:29 +08:00

184 lines
5.6 KiB
Go

// 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
}