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)
}