coverage~bigbes/sr-ht-ecoreb36a9272internalauth/internalauth.go

Coverage
91.0% 61/67 statements
Δ
+0.0
Blob
1c9b6d9
1 // Package internalauth is the service-to-service authentication of a SourceHut
2 // instance: the "Authorization: Internal <fernet token>" header one service
3 // presents to another, and the check the receiving service runs on it.
4 //
5 // Both halves live here, and that is the package rather than a convenience.
6 // core-go implements the check unexported, inside auth.Middleware, so a service
7 // that wants the guard without the rest of that middleware — an endpoint whose
8 // subject comes from the request body, with no session to resolve and no cookie
9 // to fall back to — writes the thirty lines again. dolt.sr.ht did, in
10 // web/handlers_internal.go. The program that mints the header for it is not even
11 // in the same package: it is cmd/dolt-git-hook, git.sr.ht's post-update hook,
12 // which spells the payload out by hand. So the two ends of one protocol sat in
13 // two files that shared no type, no constant and no test. Change the payload
14 // shape, the header scheme or the expiry at one end and nothing fails to
15 // compile, nothing fails a test, and the symptom is that provisioning quietly
16 // stops happening on the next push. Here the two ends are two functions over one
17 // struct, and that change breaks the build on both of them.
18 //
19 // The other half of the argument is what a copy loses rather than what it drifts
20 // from. The check is two checks and both must pass: the source address must be
21 // inside [sr.ht]internal-ipnet, and the header must be a fernet token sealed
22 // with the shared [sr.ht]network-key and less than Expiry old. A copy that keeps
23 // only one of them, or that widens the window while someone is debugging, still
24 // works — it works for everybody — and nothing about it looks wrong until the
25 // endpoint is reached from outside. That is not a class of change a code review
26 // of the fourth copy is going to catch.
27 //
28 // Receiving side:
29 //
30 // guard := internalauth.Guard("git.sr.ht", "dolt-git-hook", nil)
31 // mux.Handle("/internal/repos", guard(http.HandlerFunc(a.handleInternalCreate)))
32 //
33 // Calling side:
34 //
35 // header, err := internalauth.Authorization("git.sr.ht", "dolt-git-hook")
36 // ...
37 // req.Header.Set("Authorization", header)
38 //
39 // Two process-global preconditions, both core-go's and neither checked here at
40 // request time. crypto.InitCrypto must have run, or there is no network key to
41 // seal or open a token with. config.LoadConfig must have run, or the internal
42 // network list is empty and every address on earth is external — which is the
43 // safe direction to fail in, but it fails as "source address is not internal"
44 // for callers that are, which is worth knowing before debugging one.
45 package internalauth
46
47 import (
48 "context"
49 "encoding/json"
50 "errors"
51 "fmt"
52 "net"
53 "net/http"
54 "strings"
55 "time"
56
57 "sourcecraft.dev/bigbes/sr-ht-core/config"
58 "sourcecraft.dev/bigbes/sr-ht-core/crypto"
59 )
60
61 // Scheme is the authorization scheme of this protocol. It is matched
62 // case-insensitively on the way in, as RFC 7235 requires, and spelled this way
63 // on the way out.
64 const Scheme = "Internal"
65
66 // Expiry is how old a token may be. It is core-go's 30 seconds, unchanged, and
67 // this is the one constant in the package worth not touching.
68 //
69 // The window has to cover the clock skew between two hosts of one instance plus
70 // the latency of a single request, and nothing else: the token is minted for one
71 // call and is never stored, so there is no legitimate reason for it to be
72 // presented a minute later. What the window costs is replay — fernet has no
73 // nonce and this package keeps no seen-token set, so anyone who can read a token
74 // off the wire can present it again until it ages out. Thirty seconds is short
75 // enough that this is only reachable by something already inside the internal
76 // network with the traffic in front of it, and long enough that a peer whose
77 // clock is a few seconds off still gets through.
78 //
79 // Fernet widens this at the other end and there is nothing here that can narrow
80 // it: its verifier also refuses a token dated more than 60 seconds in the
81 // future, and accepts everything below that. The real acceptance window is
82 // therefore [now-Expiry, now+60s], and shortening Expiry does not shorten the
83 // forward half.
84 const Expiry = 30 * time.Second
85
86 // The refusals. A caller distinguishes them with errors.Is, and the distinction
87 // that matters most is the first one against the last: ErrSourceIP means the
88 // request did not come from the instance at all, ErrPeer means it did, with a
89 // token this instance's own key sealed, but on behalf of a service this endpoint
90 // does not serve. The first is somebody knocking; the second is a provisioning
91 // bug, a stale deployment, or a service calling an endpoint it was not meant to,
92 // and it wants a different log line and probably a different alert.
93 var (
94 // ErrSourceIP is a request from an address outside [sr.ht]internal-ipnet, or
95 // from a RemoteAddr that does not parse as an address at all.
96 ErrSourceIP = errors.New("internalauth: source address is not internal")
97
98 // ErrMissing is a request with no Authorization header, or one that does not
99 // carry the Internal scheme. It is deliberately not distinguished from a
100 // Bearer or Basic header: to this endpoint they are all "no internal
101 // authorization was presented".
102 ErrMissing = errors.New("internalauth: Internal authorization is required")
103
104 // ErrToken is a token that does not open with the network key: corrupt,
105 // truncated, sealed with a different key, or older than Expiry. Fernet gives
106 // one answer for all of those and this package does not invent more — a
107 // forged token and an expired one are the same event from here, and telling
108 // a caller which it was is telling an attacker whether they have the key.
109 ErrToken = errors.New("internalauth: token does not open, or has expired")
110
111 // ErrPayload is a token that opened but does not hold an Auth: not JSON, or
112 // missing the client or node id. Only a holder of the network key can
113 // produce one, so it means a peer that is minting the wrong shape, not an
114 // attacker.
115 ErrPayload = errors.New("internalauth: token payload is not an internal auth")
116
117 // ErrPeer is a valid, unexpired token from the instance, naming a client or
118 // node other than the one this endpoint accepts.
119 ErrPeer = errors.New("internalauth: token names a different caller")
120
121 // ErrNetworkKey is this process, not the request: crypto.InitCrypto has not
122 // run, so there is no key to seal or open anything with. It is the only
123 // refusal here that is a 500.
124 ErrNetworkKey = errors.New("internalauth: network key is not initialised")
125 )
126
127 // Auth is the token payload — core-go's client.InternalAuth, wire-compatible
128 // field for field, because the peers on the other end of this are core-go
129 // services and the format is theirs.
130 //
131 // Name is the user the call is made on behalf of, empty for a call that has no
132 // user yet (core-go calls that anonymous internal auth and uses it for account
133 // registration and SSH key lookup). Nothing in this package resolves it: a
134 // custom service's user table is its own business, and the guard's job is to
135 // establish that the *caller* is a sibling service, not who they are acting for.
136 //
137 // ClientID names the calling service ("git.sr.ht") and NodeID the instance of it
138 // ("dolt-git-hook", or a hostname). Both are required — an internal call that
139 // cannot say who is making it is refused even when the seal is perfect, which is
140 // upstream's rule and the reason the mint side refuses to produce one.
141 //
142 // core-go's auth.InternalAuth carries a fourth field, oauth_client_id, honoured
143 // only by meta.sr.ht routes that resolve an OAuth client instead of a user. It
144 // is deliberately absent: no custom service can act on it, and a field that is
145 // minted but never read is a field that will one day be trusted by accident.
146 type Auth struct {
147 Name string `json:"name,omitempty"`
148 ClientID string `json:"client_id"`
149 NodeID string `json:"node_id"`
150 }
151
152 // Guard refuses a request that is not a sibling service calling in, and passes
153 // one that is to next with the caller's Auth in its context (see FromContext).
154 //
155 // clientID and nodeID are the caller this endpoint accepts; an empty one accepts
156 // any non-empty value, which is core-go's own behaviour — upstream checks that
157 // the two fields are present and never that they are anybody in particular.
158 // Pinning them is this package's addition and the better default for a custom
159 // service: such a service is typically reachable by exactly one sibling for
160 // exactly one purpose, and "any service on the instance may drive this endpoint"
161 // is a decision worth writing down as Guard("", "", …) rather than inheriting.
162 //
163 // deny handles the refusal and may be nil, which installs Deny. It is given the
164 // request with the reason in its context, so a service can log what failed while
165 // still answering with its own error page; see Reason.
166 30 func Guard(clientID, nodeID string, deny http.HandlerFunc) func(http.Handler) http.Handler {
167 30 if deny == nil {
168 1 deny = Deny
169 1 }
170 30 return func(next http.Handler) http.Handler {
171 30 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
172 30 auth, err := Identify(r, clientID, nodeID)
173 30 if err != nil {
174 22 deny(w, r.WithContext(context.WithValue(r.Context(), reasonKey, err)))
175 22 return
176 22 }
177 8 next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), authKey, auth)))
178 })
179 }
180 }
181
182 // Verify is the check without the middleware, for a service that routes
183 // internal calls itself. It is Identify with the caller's identity dropped.
184 2 func Verify(r *http.Request, clientID, nodeID string) error {
185 2 _, err := Identify(r, clientID, nodeID)
186 2 return err
187 2 }
188
189 // Identify runs the check and returns who the caller says it is.
190 //
191 // The order is fixed and both checks are required — this is core-go's rule and
192 // the reason to state it here is that the two are not redundant and neither is
193 // sufficient. The token is the credential: only a service that has the shared
194 // [sr.ht]network-key can mint one, and it is what actually proves the caller is
195 // part of the instance. The address check is defence in depth for the case that
196 // makes the difference — an internal route accidentally published through the
197 // public ingress — and it is much weaker than it looks, because behind a reverse
198 // proxy the address it sees is the proxy's, which is internal for every request
199 // the proxy forwards. That is exactly why it is not allowed to stand alone.
200 //
201 // The address is RemoteAddr and never X-Forwarded-For. A forwarded header is
202 // written by whoever is in front, is trivially set by a client, and honouring it
203 // would turn the weaker of the two checks into one an outsider can pass by
204 // asking. core-go reads X-Forwarded-For too, but only to record the route it
205 // came by, never to decide with.
206 32 func Identify(r *http.Request, clientID, nodeID string) (Auth, error) {
207 32 host, _, err := net.SplitHostPort(r.RemoteAddr)
208 32 if err != nil {
209 1 // Not host:port; a bare address is what a unix socket or a test writes.
210 1 host = r.RemoteAddr
211 1 }
212 32 ip := net.ParseIP(host)
213 32 if ip == nil {
214 1 // core-go panics here. A request whose RemoteAddr does not parse is not
215 1 // a programmer error on this side, and 401 is the same answer the next
216 1 // line would give it anyway.
217 1 return Auth{}, fmt.Errorf("%w: %q does not parse", ErrSourceIP, host)
218 1 }
219 31 if !config.IsInternalIP(ip) {
220 5 return Auth{}, fmt.Errorf("%w: %s", ErrSourceIP, ip)
221 5 }
222
223 26 scheme, token, ok := strings.Cut(r.Header.Get("Authorization"), " ")
224 26 if !ok || !strings.EqualFold(scheme, Scheme) {
225 4 return Auth{}, ErrMissing
226 4 }
227 22 payload, err := open([]byte(token))
228 22 if err != nil {
229 0 return Auth{}, err
230 0 }
231 22 if payload == nil {
232 5 return Auth{}, ErrToken
233 5 }
234
235 17 var auth Auth
236 17 if err := json.Unmarshal(payload, &auth); err != nil {
237 2 // core-go panics here as well, on the grounds that a payload it could
238 2 // decrypt is one a sibling service wrote. True, and still a 500 handed
239 2 // to whoever holds the key: a peer minting the wrong shape takes the
240 2 // receiver's handler down with it. Refuse it instead.
241 2 return Auth{}, fmt.Errorf("%w: %v", ErrPayload, err)
242 2 }
243 15 if auth.ClientID == "" || auth.NodeID == "" {
244 3 return Auth{}, fmt.Errorf("%w: client_id and node_id are both required", ErrPayload)
245 3 }
246 12 if clientID != "" && auth.ClientID != clientID {
247 2 return Auth{}, fmt.Errorf("%w: client_id is %q, want %q", ErrPeer, auth.ClientID, clientID)
248 2 }
249 10 if nodeID != "" && auth.NodeID != nodeID {
250 1 return Auth{}, fmt.Errorf("%w: node_id is %q, want %q", ErrPeer, auth.NodeID, nodeID)
251 1 }
252 9 return auth, nil
253 }
254
255 // Authorization mints the header for the calling side: the whole value,
256 // "Internal <token>", ready for r.Header.Set("Authorization", …).
257 //
258 // It refuses exactly what Identify refuses — an empty client or node id — so
259 // that a caller finds out at the call site rather than from a 403 out of a
260 // service that will not say which field was missing.
261 19 func Authorization(clientID, nodeID string) (string, error) {
262 19 return AuthorizationAs("", clientID, nodeID)
263 19 }
264
265 // AuthorizationAs mints the header for a call made on behalf of a user, which is
266 // what core-go's client.Do does for every GraphQL call it makes: the username
267 // travels in the token's name field and the receiving core-go service resolves
268 // its whole auth context from it.
269 //
270 // A custom service calling a core-go one needs this; a custom service calling
271 // another custom one usually does not, because the guard here does not resolve
272 // anything from the name. Passing a username the receiver has never heard of is
273 // not this side's error to catch.
274 21 func AuthorizationAs(username, clientID, nodeID string) (string, error) {
275 21 if clientID == "" || nodeID == "" {
276 4 return "", fmt.Errorf("%w: client_id and node_id are both required", ErrPayload)
277 4 }
278 17 blob, err := json.Marshal(Auth{Name: username, ClientID: clientID, NodeID: nodeID})
279 17 if err != nil {
280 0 return "", fmt.Errorf("%w: %v", ErrPayload, err)
281 0 }
282 17 token, err := seal(blob)
283 17 if err != nil {
284 0 return "", err
285 0 }
286 17 return Scheme + " " + string(token), nil
287 }
288
289 // seal and open wrap the two core-go crypto calls, whose failure mode with no
290 // key installed is a nil dereference inside fernet rather than an error.
291 //
292 // Recovering it is worth the ugliness because of where the mint side runs: the
293 // caller in production is a git hook, in a process that has just enough of an
294 // instance to have loaded a config, and an unconfigured network key there should
295 // cost a companion database, not the push. The hook already guards its own
296 // InitCrypto call this way (that one log.Fatals, which recover cannot catch);
297 // this covers the case where InitCrypto was simply never reached.
298 17 func seal(payload []byte) (tok []byte, err error) {
299 17 defer func() {
300 17 if v := recover(); v != nil {
301 0 tok, err = nil, fmt.Errorf("%w: %v", ErrNetworkKey, v)
302 0 }
303 }()
304 17 return crypto.Encrypt(payload), nil
305 }
306
307 22 func open(tok []byte) (payload []byte, err error) {
308 22 defer func() {
309 22 if v := recover(); v != nil {
310 0 payload, err = nil, fmt.Errorf("%w: %v", ErrNetworkKey, v)
311 0 }
312 }()
313 22 return crypto.DecryptWithExpiration(tok, Expiry), nil
314 }
315
316 // Status maps a refusal to the status code core-go and dolt.sr.ht already answer
317 // with, so adopting this package changes no response a caller is switching on.
318 //
319 // 401 for the two failures that mean nothing was presented — a request from
320 // outside, or one with no Internal header — and 403 for a presented credential
321 // that was refused. ErrNetworkKey is the receiver's own misconfiguration and is
322 // the only 500. Anything unrecognised is 403 rather than 200, so a caller that
323 // hands this an error it did not come from still refuses.
324 30 func Status(err error) int {
325 30 switch {
326 11 case errors.Is(err, ErrSourceIP), errors.Is(err, ErrMissing):
327 11 return http.StatusUnauthorized
328 1 case errors.Is(err, ErrNetworkKey):
329 1 return http.StatusInternalServerError
330 18 default:
331 18 return http.StatusForbidden
332 }
333 }
334
335 // Deny is the refusal Guard installs when it is given none: the mapped status
336 // and the reason as plain text.
337 //
338 // The reason is safe to return. Every string in it comes from this package or
339 // from a token that opened with the instance's own key, so the only detail it
340 // discloses to a stranger is which of the two checks they failed — and the one
341 // they can reach without the key is the address check, whose answer they already
342 // know.
343 22 func Deny(w http.ResponseWriter, r *http.Request) {
344 22 err := Reason(r.Context())
345 22 if err == nil {
346 0 err = ErrMissing
347 0 }
348 22 http.Error(w, err.Error(), Status(err))
349 }
350
351 type ctxKey int
352
353 const (
354 authKey ctxKey = iota
355 reasonKey
356 )
357
358 // FromContext returns the verified caller of the request Guard admitted. It is
359 // how a handler behind the guard finds out which sibling service called and on
360 // whose behalf, without parsing the header again.
361 9 func FromContext(ctx context.Context) (Auth, bool) {
362 9 auth, ok := ctx.Value(authKey).(Auth)
363 9 return auth, ok
364 9 }
365
366 // Reason returns the refusal Guard is calling a deny handler about, or nil if
367 // this context is not one. It exists so that a service can supply a deny handler
368 // that renders its own error page and still log which check failed — the thing
369 // this protocol is most often debugged by.
370 44 func Reason(ctx context.Context) error {
371 44 err, _ := ctx.Value(reasonKey).(error)
372 44 return err
373 44 }