package internalauth import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/binary" "net" "net/http" "net/http/httptest" "os" "strings" "testing" "testing/fstest" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sourcecraft.dev/bigbes/sr-ht-core/config" "sourcecraft.dev/bigbes/sr-ht-core/crypto" "sourcecraft.dev/bigbes/sr-ht-ecore/ecoretest" ) // The peer these tests pin the guard to: git.sr.ht's post-update hook, which is // the one real caller of this protocol on the instance today. const ( callerClientID = "git.sr.ht" callerNodeID = "dolt-git-hook" ) // Two source addresses, both documentation ranges so that nothing here could // ever resolve to a real host. internalAddr is RFC 1918, which the config below // makes internal; externalAddr is TEST-NET-3, which nothing does. const ( internalAddr = "10.0.0.5:34512" externalAddr = "203.0.113.9:44321" ) // TestMain installs the two pieces of core-go process state this package reads // and neither creates. // // config.LoadConfig is called for one thing only: it is what fills the internal // network list that config.IsInternalIP answers from, and without it that list // is empty and every address is external. The synthetic config deliberately // carries no [sr.ht]internal-ipnet, so the whole suite runs against the built-in // default — see TestUnsetInternalIPNetKeepsTheLANDefault, which is the test of // that fallback. // // The keys come from ecoretest rather than from this file, so a test here seals // with the same network key every other service's tests do. func TestMain(m *testing.M) { config.FS = fstest.MapFS{ "config.ini": &fstest.MapFile{Data: []byte("[sr.ht]\nsite-name=srht.example\n")}, } config.LoadConfig() ecoretest.InitCrypto() os.Exit(m.Run()) } // --------------------------------------------------------------------------- // Fixtures // --------------------------------------------------------------------------- // result is everything one guarded request produced: what the caller saw, and — // through the recording deny handler — which refusal produced it. type result struct { code int body string auth *Auth reason error } // call runs one request through a guard pinned to clientID/nodeID. Its deny // handler records the reason and then delegates to Deny, so every refusing test // also asserts the status and body a service that supplies no deny handler gets. func call(t *testing.T, clientID, nodeID, remoteAddr, authorization string) result { t.Helper() var res result deny := func(w http.ResponseWriter, r *http.Request) { res.reason = Reason(r.Context()) Deny(w, r) } next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { auth, ok := FromContext(r.Context()) require.True(t, ok, "an admitted request must carry its caller") res.auth = &auth w.WriteHeader(http.StatusNoContent) }) req := httptest.NewRequest(http.MethodPost, "/internal/repos", nil) req.RemoteAddr = remoteAddr if authorization != "" { req.Header.Set("Authorization", authorization) } rec := httptest.NewRecorder() Guard(clientID, nodeID, deny)(next).ServeHTTP(rec, req) res.code = rec.Code res.body = strings.TrimSpace(rec.Body.String()) return res } // mint is Authorization with the error asserted away. func mint(t *testing.T, clientID, nodeID string) string { t.Helper() header, err := Authorization(clientID, nodeID) require.NoError(t, err) return header } // backdate rewrites a minted header's fernet timestamp to age ago and reseals // it, producing the token a slow replay presents: correctly encrypted, signed // with the instance's real key, and too old. // // It has to reach into the wire format because fernet stamps EncryptAndSign with // time.Now() and offers no way to say otherwise. The layout is the fernet spec's: // version byte, 8-byte big-endian unix timestamp, 16-byte IV, ciphertext, and a // trailing HMAC-SHA256 over everything before it keyed with the first half of // the network key. func backdate(t *testing.T, header string, age time.Duration) string { t.Helper() scheme, token, ok := strings.Cut(header, " ") require.True(t, ok) raw, err := base64.URLEncoding.DecodeString(token) require.NoError(t, err) require.Greater(t, len(raw), 9+sha256.Size) binary.BigEndian.PutUint64(raw[1:9], uint64(time.Now().Add(-age).Unix())) key, err := base64.URLEncoding.DecodeString(ecoretest.NetworkKey) require.NoError(t, err) require.Len(t, key, 32) mac := hmac.New(sha256.New, key[:16]) mac.Write(raw[:len(raw)-sha256.Size]) copy(raw[len(raw)-sha256.Size:], mac.Sum(nil)) return scheme + " " + base64.URLEncoding.EncodeToString(raw) } // --------------------------------------------------------------------------- // The round trip // --------------------------------------------------------------------------- // TestGuardAdmitsAMintedHeader is the whole point of the package in one test: // what one half produces, the other half accepts, and the handler behind the // guard learns who called. func TestGuardAdmitsAMintedHeader(t *testing.T) { res := call(t, callerClientID, callerNodeID, internalAddr, mint(t, callerClientID, callerNodeID)) assert.Equal(t, http.StatusNoContent, res.code) require.NotNil(t, res.auth) assert.Equal(t, callerClientID, res.auth.ClientID) assert.Equal(t, callerNodeID, res.auth.NodeID) assert.Empty(t, res.auth.Name, "Authorization mints an anonymous internal call") } // TestGuardCarriesTheUserAnInternalCallIsMadeFor covers the other mint: the // name travels through the seal untouched, which is what a core-go service on // the far end resolves its auth context from. func TestGuardCarriesTheUserAnInternalCallIsMadeFor(t *testing.T) { header, err := AuthorizationAs("bigbes", callerClientID, callerNodeID) require.NoError(t, err) res := call(t, callerClientID, callerNodeID, internalAddr, header) require.Equal(t, http.StatusNoContent, res.code) require.NotNil(t, res.auth) assert.Equal(t, "bigbes", res.auth.Name) } // TestVerifyAgreesWithTheGuard: the routing-free entry point is the same check, // so a service that does its own dispatch cannot end up with a weaker one. func TestVerifyAgreesWithTheGuard(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/internal/repos", nil) req.RemoteAddr = internalAddr req.Header.Set("Authorization", mint(t, callerClientID, callerNodeID)) assert.NoError(t, Verify(req, callerClientID, callerNodeID)) req.RemoteAddr = externalAddr assert.ErrorIs(t, Verify(req, callerClientID, callerNodeID), ErrSourceIP) } // --------------------------------------------------------------------------- // Refusals // --------------------------------------------------------------------------- // TestGuardRefusesAnExpiredToken checks the window from both sides, in seconds // rather than in terms of Expiry: a test that ages a token by Expiry+5s passes // for every value of Expiry, which makes it a test of arithmetic instead of a // test of the window. The 25-second case is the control — backdate reseals the // token, so a refusal of the 31-second one has to be its age and not the // rewriting. func TestGuardRefusesAnExpiredToken(t *testing.T) { assert.Equal(t, 30*time.Second, Expiry, "the window these ages are chosen around") header := mint(t, callerClientID, callerNodeID) fresh := call(t, callerClientID, callerNodeID, internalAddr, backdate(t, header, 25*time.Second)) assert.Equal(t, http.StatusNoContent, fresh.code) stale := call(t, callerClientID, callerNodeID, internalAddr, backdate(t, header, 31*time.Second)) assert.Equal(t, http.StatusForbidden, stale.code) assert.ErrorIs(t, stale.reason, ErrToken) assert.Nil(t, stale.auth) } // TestGuardRefusesAnotherCaller is the check core-go does not do: a token this // instance sealed, unexpired, from an internal address, and still refused // because it was minted for somebody else. Both fields are pinned separately — // a second node of the right service is as wrong as a different service. func TestGuardRefusesAnotherCaller(t *testing.T) { for _, tc := range []struct { name string clientID, nodeID string }{ {"different service", "meta.sr.ht", callerNodeID}, {"different node", callerClientID, "us-east-3.git.sr.ht"}, {"neither", "builds.sr.ht", "runner-7"}, } { t.Run(tc.name, func(t *testing.T) { res := call(t, callerClientID, callerNodeID, internalAddr, mint(t, tc.clientID, tc.nodeID)) assert.Equal(t, http.StatusForbidden, res.code) assert.ErrorIs(t, res.reason, ErrPeer) assert.Nil(t, res.auth) }) } } // TestGuardAcceptsAnyCallerWhenUnpinned: empty means "any", which is upstream's // behaviour and what an endpoint several siblings drive asks for explicitly. func TestGuardAcceptsAnyCallerWhenUnpinned(t *testing.T) { res := call(t, "", "", internalAddr, mint(t, "builds.sr.ht", "runner-7")) require.Equal(t, http.StatusNoContent, res.code) require.NotNil(t, res.auth) assert.Equal(t, "builds.sr.ht", res.auth.ClientID) } // TestGuardRefusesANonInternalSource: a perfectly good token presented from // outside is refused, and refused as 401 — the request never got far enough to // be a credential decision. func TestGuardRefusesANonInternalSource(t *testing.T) { res := call(t, callerClientID, callerNodeID, externalAddr, mint(t, callerClientID, callerNodeID)) assert.Equal(t, http.StatusUnauthorized, res.code) assert.ErrorIs(t, res.reason, ErrSourceIP) assert.Nil(t, res.auth) } // TestGuardRefusesAForwardedSourceAddress: X-Forwarded-For is written by // whoever is in front and is not allowed to make an outside request internal. func TestGuardRefusesAForwardedSourceAddress(t *testing.T) { var res result deny := func(w http.ResponseWriter, r *http.Request) { res.reason = Reason(r.Context()) Deny(w, r) } req := httptest.NewRequest(http.MethodPost, "/internal/repos", nil) req.RemoteAddr = externalAddr req.Header.Set("X-Forwarded-For", "10.0.0.5") req.Header.Set("Authorization", mint(t, callerClientID, callerNodeID)) rec := httptest.NewRecorder() Guard(callerClientID, callerNodeID, deny)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("a forwarded address must not admit anyone") })).ServeHTTP(rec, req) assert.Equal(t, http.StatusUnauthorized, rec.Code) assert.ErrorIs(t, res.reason, ErrSourceIP) } // TestGuardRefusesAMissingHeader: an internal address on its own admits nobody. // The IP check is defence in depth, never the credential. func TestGuardRefusesAMissingHeader(t *testing.T) { res := call(t, callerClientID, callerNodeID, internalAddr, "") assert.Equal(t, http.StatusUnauthorized, res.code) assert.ErrorIs(t, res.reason, ErrMissing) assert.Nil(t, res.auth) } // TestGuardRefusesAMalformedHeader walks the shapes a broken or hostile caller // presents, and pins which side of the taxonomy each lands on: nothing was // presented (401) against something was presented and refused (403). func TestGuardRefusesAMalformedHeader(t *testing.T) { valid := mint(t, callerClientID, callerNodeID) for _, tc := range []struct { name string header string want error code int }{ {"no scheme", "gAAAAAAAAAAA", ErrMissing, http.StatusUnauthorized}, {"scheme only", Scheme, ErrMissing, http.StatusUnauthorized}, {"another scheme", "Bearer " + valid, ErrMissing, http.StatusUnauthorized}, {"not base64", Scheme + " ~~~not-a-token~~~", ErrToken, http.StatusForbidden}, {"empty token", Scheme + " ", ErrToken, http.StatusForbidden}, {"truncated token", Scheme + " " + tokenOf(valid)[:20], ErrToken, http.StatusForbidden}, {"tampered token", Scheme + " " + tamper(tokenOf(valid)), ErrToken, http.StatusForbidden}, } { t.Run(tc.name, func(t *testing.T) { res := call(t, callerClientID, callerNodeID, internalAddr, tc.header) assert.Equal(t, tc.code, res.code) assert.ErrorIs(t, res.reason, tc.want) assert.Nil(t, res.auth) }) } } // TestGuardAcceptsAnyCaseOfTheScheme: RFC 7235 makes the scheme token // case-insensitive, and core-go matches it that way. Pinned so that a parser // tightened later cannot start refusing a caller spelling it as the RFC allows. func TestGuardAcceptsAnyCaseOfTheScheme(t *testing.T) { token := tokenOf(mint(t, callerClientID, callerNodeID)) for _, scheme := range []string{"internal", "INTERNAL", "InTeRnAl"} { res := call(t, callerClientID, callerNodeID, internalAddr, scheme+" "+token) assert.Equal(t, http.StatusNoContent, res.code, "scheme %q", scheme) } } // TestGuardRefusesAPayloadThatIsNotAnInternalAuth covers the tokens only a // holder of the network key can produce: sealed correctly, carrying the wrong // thing. core-go panics on the first of these; here they are refusals. func TestGuardRefusesAPayloadThatIsNotAnInternalAuth(t *testing.T) { for _, tc := range []struct { name string payload string }{ {"not json", "this is not a payload"}, {"json but not an object", `["git.sr.ht"]`}, {"no client id", `{"node_id":"dolt-git-hook"}`}, {"no node id", `{"client_id":"git.sr.ht"}`}, {"empty ids", `{"client_id":"","node_id":""}`}, } { t.Run(tc.name, func(t *testing.T) { header := Scheme + " " + string(crypto.Encrypt([]byte(tc.payload))) res := call(t, "", "", internalAddr, header) assert.Equal(t, http.StatusForbidden, res.code) assert.ErrorIs(t, res.reason, ErrPayload) assert.Nil(t, res.auth) }) } } // --------------------------------------------------------------------------- // The network list // --------------------------------------------------------------------------- // TestUnsetInternalIPNetKeepsTheLANDefault documents what an instance that never // configured [sr.ht]internal-ipnet gets: core-go substitutes loopback, the three // RFC 1918 ranges, unique-local and link-local. So an unset key is not an open // door, and it is not a closed one either — it is "anything on the LAN", which // is right for a single-host instance and too wide for a service sharing a // network with something it does not trust. The suite's whole config is the // unset case (see TestMain), so this asserts the shape of that default rather // than installing another one. func TestUnsetInternalIPNetKeepsTheLANDefault(t *testing.T) { for _, addr := range []string{"127.0.0.1", "10.0.0.5", "172.16.4.1", "192.168.1.9", "::1", "fe80::1"} { assert.True(t, config.IsInternalIP(net.ParseIP(addr)), "%s is on the default LAN list", addr) } for _, addr := range []string{"203.0.113.9", "8.8.8.8", "2001:db8::1"} { assert.False(t, config.IsInternalIP(net.ParseIP(addr)), "%s is not internal", addr) } admitted := call(t, callerClientID, callerNodeID, "127.0.0.1:9001", mint(t, callerClientID, callerNodeID)) assert.Equal(t, http.StatusNoContent, admitted.code) refused := call(t, callerClientID, callerNodeID, "8.8.8.8:9001", mint(t, callerClientID, callerNodeID)) assert.Equal(t, http.StatusUnauthorized, refused.code) assert.ErrorIs(t, refused.reason, ErrSourceIP) } // TestGuardRefusesAnUnparsableRemoteAddr: core-go panics on this one. A request // with no usable source address is not a programmer error on the receiving side, // and it is refused with the same answer an outside address gets. func TestGuardRefusesAnUnparsableRemoteAddr(t *testing.T) { res := call(t, callerClientID, callerNodeID, "@", mint(t, callerClientID, callerNodeID)) assert.Equal(t, http.StatusUnauthorized, res.code) assert.ErrorIs(t, res.reason, ErrSourceIP) } // --------------------------------------------------------------------------- // The mint side, the default deny handler, the taxonomy // --------------------------------------------------------------------------- // TestAuthorizationRefusesAnIncompleteIdentity: the mint refuses exactly what // the guard would, so the caller finds out at the call site instead of from a // 403 that will not say which field was missing. func TestAuthorizationRefusesAnIncompleteIdentity(t *testing.T) { for _, tc := range []struct{ clientID, nodeID string }{ {"", callerNodeID}, {callerClientID, ""}, {"", ""}, } { header, err := Authorization(tc.clientID, tc.nodeID) assert.ErrorIs(t, err, ErrPayload) assert.Empty(t, header) } header, err := AuthorizationAs("bigbes", "", "") assert.ErrorIs(t, err, ErrPayload) assert.Empty(t, header) } // TestAuthorizationMintsTheWholeHeaderValue: the return value goes straight into // Header.Set, scheme included — the one detail a caller re-implementing this got // to choose and would otherwise get wrong in each copy. func TestAuthorizationMintsTheWholeHeaderValue(t *testing.T) { header := mint(t, callerClientID, callerNodeID) assert.True(t, strings.HasPrefix(header, Scheme+" "), "header is %q", header) assert.NotEmpty(t, tokenOf(header)) assert.NotEqual(t, header, mint(t, callerClientID, callerNodeID), "every mint is a fresh seal: fernet's IV and timestamp are per token") } // TestGuardInstallsTheDefaultDenyHandler: a service that passes nil still gets a // refusal with a status and a body, not a nil-handler panic. func TestGuardInstallsTheDefaultDenyHandler(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/internal/repos", nil) req.RemoteAddr = externalAddr rec := httptest.NewRecorder() Guard(callerClientID, callerNodeID, nil)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("refused requests must not reach the handler") })).ServeHTTP(rec, req) assert.Equal(t, http.StatusUnauthorized, rec.Code) assert.Contains(t, rec.Body.String(), ErrSourceIP.Error()) } // TestStatusSplitsNothingPresentedFromCredentialRefused is the taxonomy a // caller switches on, pinned so that adopting this package changes no status // dolt.sr.ht and core-go already answer with. func TestStatusSplitsNothingPresentedFromCredentialRefused(t *testing.T) { assert.Equal(t, http.StatusUnauthorized, Status(ErrSourceIP)) assert.Equal(t, http.StatusUnauthorized, Status(ErrMissing)) assert.Equal(t, http.StatusForbidden, Status(ErrToken)) assert.Equal(t, http.StatusForbidden, Status(ErrPayload)) assert.Equal(t, http.StatusForbidden, Status(ErrPeer)) assert.Equal(t, http.StatusInternalServerError, Status(ErrNetworkKey)) assert.Equal(t, http.StatusForbidden, Status(nil), "no reason at all still refuses") assert.Equal(t, http.StatusForbidden, Status(net.ErrClosed), "an unrecognised error still refuses") } // TestReasonAndFromContextAreEmptyOffThePath: neither accessor invents an answer // for a context that never went through the guard. func TestReasonAndFromContextAreEmptyOffThePath(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/", nil) assert.NoError(t, Reason(req.Context())) auth, ok := FromContext(req.Context()) assert.False(t, ok) assert.Equal(t, Auth{}, auth) } // tokenOf strips the scheme from a minted header. func tokenOf(header string) string { _, token, _ := strings.Cut(header, " ") return token } // tamper flips the last byte of a token's HMAC, producing a token that decodes // and does not verify. func tamper(token string) string { raw, err := base64.URLEncoding.DecodeString(token) if err != nil { return token } raw[len(raw)-1] ^= 0xff return base64.URLEncoding.EncodeToString(raw) }