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>
184 lines
5.6 KiB
Go
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
|
|
}
|