oficina / suite Public

Clone
git clone https://amadan.net/oficina/suite

Plain git — no account needed to clone.

Download

Download this file

1package suite
2
3import (
4 "context"
5 "fmt"
6 "net"
7 "net/http"
8 "strings"
9)
10
11// SuiteDomain is the registrable domain every suite host sits under. A sibling
12// host is `<team>.<app>.oficina.works` (doctrine §4.1).
13const SuiteDomain = "oficina.works"
14
15// DialFunc is the hook the sibling client dials through. Tests pass a
16// recorder; production passes nil and gets the standard dialer.
17type DialFunc func(ctx context.Context, network, addr string) (net.Conn, error)
18
19// NewSiblingClient returns an HTTP client that will only ever reach hosts
20// inside its own team, and will never follow a redirect.
21//
22// Doctrine §4.1: every sibling client refuses a host whose `<team>` label
23// differs from its own, and refuses to follow redirects. Neither is a barrier
24// against code execution on an instance — they stop mis-provisioning and
25// accidental cross-team dialling, and that is all they are claimed as.
26func NewSiblingClient(team string, dial DialFunc) *http.Client {
27 base := &http.Transport{}
28 if dial != nil {
29 base.DialContext = dial
30 }
31
32 return &http.Client{
33 Transport: &teamBoundTransport{team: team, next: base},
34 CheckRedirect: func(req *http.Request, via []*http.Request) error {
35 // An allowed host has to be reached to tell us to redirect; that
36 // hop is legitimate. Following it is not: a sibling could walk
37 // this client across the boundary the guard just enforced.
38 return fmt.Errorf("suite: refusing to follow a redirect to %q", req.URL.Host)
39 },
40 }
41}
42
43type teamBoundTransport struct {
44 team string
45 next http.RoundTripper
46}
47
48func (t *teamBoundTransport) RoundTrip(req *http.Request) (*http.Response, error) {
49 if req.URL.Scheme != "https" {
50 return nil, ErrContractBreach
51 }
52 // Before the transport dials, so a refused host costs no connection.
53 urlHost := req.URL.Hostname()
54 if err := CheckSiblingHost(t.team, urlHost); err != nil {
55 return nil, err
56 }
57
58 // req.Host is a second authority field. Go dials req.URL.Host but sends
59 // req.Host as the Host header, so checking only the URL leaves a request
60 // that reaches our own team's instance while announcing itself as another
61 // team's — and a platform router that routes on Host could hand the body
62 // and the consumer token to that other team.
63 if req.Host != "" {
64 override := hostnameOnly(req.Host)
65 if err := CheckSiblingHost(t.team, override); err != nil {
66 return nil, fmt.Errorf("suite: Host header %q: %w", req.Host, err)
67 }
68 if !sameHost(override, urlHost) {
69 return nil, fmt.Errorf("suite: Host header %q does not match the URL host %q", override, urlHost)
70 }
71 }
72
73 return t.next.RoundTrip(req)
74}
75
76// hostnameOnly strips a port if one is present. net.SplitHostPort errors on a
77// bare hostname, which is the common case, so its failure is not interesting.
78func hostnameOnly(hostport string) string {
79 if h, _, err := net.SplitHostPort(hostport); err == nil {
80 return h
81 }
82 return hostport
83}
84
85func sameHost(a, b string) bool {
86 norm := func(s string) string {
87 return strings.TrimSuffix(strings.ToLower(s), ".")
88 }
89 return norm(a) == norm(b)
90}
91
92// CheckSiblingHost answers whether `host` is a sibling of `team`'s instances.
93//
94// It is exported because it guards more than the HTTP client: §7.1's hand-off
95// validates a form's destination with it before the form is rendered, since an
96// HTML form action is not this client and a body posted to another team's host
97// has already crossed the boundary by the time that host could refuse it.
98func CheckSiblingHost(team, host string) error {
99 if team == "" {
100 return fmt.Errorf("suite: no team configured for the sibling client")
101 }
102
103 host = strings.TrimSuffix(strings.ToLower(host), ".")
104 suffix := "." + SuiteDomain
105
106 // The host must sit inside the suite's registrable domain.
107 if !strings.HasSuffix(host, suffix) {
108 return fmt.Errorf("suite: host %q is not in %s", host, SuiteDomain)
109 }
110
111 // Exactly `<team>.<app>` remains once the domain is removed. **This is
112 // the check that does the work**, and the one to be careful editing.
113 // A mutation test weakening the HasSuffix above to a Contains left every
114 // hostile-host fixture still refused, because `acme.docs.oficina.works.evil`
115 // does not end with the suffix, so nothing is trimmed and it arrives here
116 // with five labels rather than two. The suffix check is defence in depth
117 // and a clearer error message; it is not what stops the suffix attack.
118 labels := strings.Split(strings.TrimSuffix(host, suffix), ".")
119 if len(labels) != 2 {
120 return fmt.Errorf("suite: host %q is not <team>.<app>.%s", host, SuiteDomain)
121 }
122
123 // Counting labels is not enough: `acme..oficina.works` counts two and
124 // names no app, and `acme.docs。evil.oficina.works` counts two only
125 // because an ideographic full stop is not a separator *here* — IDNA
126 // normalisation elsewhere in the stack may decide it is one.
127 for _, label := range labels {
128 if err := validLabel(label); err != nil {
129 return fmt.Errorf("suite: host %q: %w", host, err)
130 }
131 }
132
133 if labels[0] != strings.ToLower(team) {
134 return fmt.Errorf("suite: host %q is team %q, not %q", host, labels[0], team)
135 }
136 return nil
137}
138
139// validLabel holds a hostname label to plain ASCII letters, digits and inner
140// hyphens (RFC 1123). Anything else is refused rather than interpreted: a
141// label this code and the platform's router might read differently is exactly
142// the ambiguity the team boundary cannot afford.
143func validLabel(label string) error {
144 if label == "" {
145 return fmt.Errorf("empty label")
146 }
147 if len(label) > 63 {
148 return fmt.Errorf("label %q is longer than 63 octets", label)
149 }
150 if label[0] == '-' || label[len(label)-1] == '-' {
151 return fmt.Errorf("label %q begins or ends with a hyphen", label)
152 }
153 for i := 0; i < len(label); i++ {
154 c := label[i]
155 switch {
156 case c >= 'a' && c <= 'z',
157 c >= '0' && c <= '9',
158 c == '-':
159 default:
160 return fmt.Errorf("label %q contains %q, which is not a hostname character", label, string(c))
161 }
162 }
163 return nil
164}
165