package suite import ( "context" "fmt" "net" "net/http" "strings" ) // SuiteDomain is the registrable domain every suite host sits under. A sibling // host is `..oficina.works` (doctrine §4.1). const SuiteDomain = "oficina.works" // DialFunc is the hook the sibling client dials through. Tests pass a // recorder; production passes nil and gets the standard dialer. type DialFunc func(ctx context.Context, network, addr string) (net.Conn, error) // NewSiblingClient returns an HTTP client that will only ever reach hosts // inside its own team, and will never follow a redirect. // // Doctrine §4.1: every sibling client refuses a host whose `` label // differs from its own, and refuses to follow redirects. Neither is a barrier // against code execution on an instance — they stop mis-provisioning and // accidental cross-team dialling, and that is all they are claimed as. func NewSiblingClient(team string, dial DialFunc) *http.Client { base := &http.Transport{} if dial != nil { base.DialContext = dial } return &http.Client{ Transport: &teamBoundTransport{team: team, next: base}, CheckRedirect: func(req *http.Request, via []*http.Request) error { // An allowed host has to be reached to tell us to redirect; that // hop is legitimate. Following it is not: a sibling could walk // this client across the boundary the guard just enforced. return fmt.Errorf("suite: refusing to follow a redirect to %q", req.URL.Host) }, } } type teamBoundTransport struct { team string next http.RoundTripper } func (t *teamBoundTransport) RoundTrip(req *http.Request) (*http.Response, error) { if req.URL.Scheme != "https" { return nil, ErrContractBreach } // Before the transport dials, so a refused host costs no connection. urlHost := req.URL.Hostname() if err := CheckSiblingHost(t.team, urlHost); err != nil { return nil, err } // req.Host is a second authority field. Go dials req.URL.Host but sends // req.Host as the Host header, so checking only the URL leaves a request // that reaches our own team's instance while announcing itself as another // team's — and a platform router that routes on Host could hand the body // and the consumer token to that other team. if req.Host != "" { override := hostnameOnly(req.Host) if err := CheckSiblingHost(t.team, override); err != nil { return nil, fmt.Errorf("suite: Host header %q: %w", req.Host, err) } if !sameHost(override, urlHost) { return nil, fmt.Errorf("suite: Host header %q does not match the URL host %q", override, urlHost) } } return t.next.RoundTrip(req) } // hostnameOnly strips a port if one is present. net.SplitHostPort errors on a // bare hostname, which is the common case, so its failure is not interesting. func hostnameOnly(hostport string) string { if h, _, err := net.SplitHostPort(hostport); err == nil { return h } return hostport } func sameHost(a, b string) bool { norm := func(s string) string { return strings.TrimSuffix(strings.ToLower(s), ".") } return norm(a) == norm(b) } // CheckSiblingHost answers whether `host` is a sibling of `team`'s instances. // // It is exported because it guards more than the HTTP client: §7.1's hand-off // validates a form's destination with it before the form is rendered, since an // HTML form action is not this client and a body posted to another team's host // has already crossed the boundary by the time that host could refuse it. func CheckSiblingHost(team, host string) error { if team == "" { return fmt.Errorf("suite: no team configured for the sibling client") } host = strings.TrimSuffix(strings.ToLower(host), ".") suffix := "." + SuiteDomain // The host must sit inside the suite's registrable domain. if !strings.HasSuffix(host, suffix) { return fmt.Errorf("suite: host %q is not in %s", host, SuiteDomain) } // Exactly `.` remains once the domain is removed. **This is // the check that does the work**, and the one to be careful editing. // A mutation test weakening the HasSuffix above to a Contains left every // hostile-host fixture still refused, because `acme.docs.oficina.works.evil` // does not end with the suffix, so nothing is trimmed and it arrives here // with five labels rather than two. The suffix check is defence in depth // and a clearer error message; it is not what stops the suffix attack. labels := strings.Split(strings.TrimSuffix(host, suffix), ".") if len(labels) != 2 { return fmt.Errorf("suite: host %q is not ..%s", host, SuiteDomain) } // Counting labels is not enough: `acme..oficina.works` counts two and // names no app, and `acme.docs。evil.oficina.works` counts two only // because an ideographic full stop is not a separator *here* — IDNA // normalisation elsewhere in the stack may decide it is one. for _, label := range labels { if err := validLabel(label); err != nil { return fmt.Errorf("suite: host %q: %w", host, err) } } if labels[0] != strings.ToLower(team) { return fmt.Errorf("suite: host %q is team %q, not %q", host, labels[0], team) } return nil } // validLabel holds a hostname label to plain ASCII letters, digits and inner // hyphens (RFC 1123). Anything else is refused rather than interpreted: a // label this code and the platform's router might read differently is exactly // the ambiguity the team boundary cannot afford. func validLabel(label string) error { if label == "" { return fmt.Errorf("empty label") } if len(label) > 63 { return fmt.Errorf("label %q is longer than 63 octets", label) } if label[0] == '-' || label[len(label)-1] == '-' { return fmt.Errorf("label %q begins or ends with a hyphen", label) } for i := 0; i < len(label); i++ { c := label[i] switch { case c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '-': default: return fmt.Errorf("label %q contains %q, which is not a hostname character", label, string(c)) } } return nil }