160 lines
6.3 KiB
Go
160 lines
6.3 KiB
Go
package api
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func callerTokensForTest() CallerTokens {
|
|
return CallerTokens{
|
|
CallerVelocity: "tok-velocity",
|
|
CallerLimbo: "tok-limbo",
|
|
CallerBuild: "tok-build",
|
|
CallerOps: "tok-ops",
|
|
}
|
|
}
|
|
|
|
func bearer(tok string) map[string]string {
|
|
return map[string]string{"Authorization": "Bearer " + tok, "Content-Type": "application/json"}
|
|
}
|
|
|
|
// Each token answers with its own caller, and nothing else gets in.
|
|
func TestCallerTokensNameTheCaller(t *testing.T) {
|
|
c := callerTokensForTest()
|
|
for tok, want := range map[string]Caller{
|
|
"tok-velocity": CallerVelocity,
|
|
"tok-limbo": CallerLimbo,
|
|
"tok-build": CallerBuild,
|
|
"tok-ops": CallerOps,
|
|
} {
|
|
r := httptest.NewRequest("GET", "/", nil)
|
|
r.Header.Set("Authorization", "Bearer "+tok)
|
|
got, err := c.Authenticate(r)
|
|
if err != nil || got != want {
|
|
t.Errorf("%s: got (%q, %v), want %q", tok, got, err, want)
|
|
}
|
|
}
|
|
for _, header := range []string{"", "Bearer tok-velocityX", "Bearer tok-veloci", "Basic tok-ops"} {
|
|
r := httptest.NewRequest("GET", "/", nil)
|
|
if header != "" {
|
|
r.Header.Set("Authorization", header)
|
|
}
|
|
if got, err := c.Authenticate(r); err == nil {
|
|
t.Errorf("%q authenticated as %q", header, got)
|
|
}
|
|
}
|
|
|
|
// A caller whose Secret is missing has an empty token; that must not turn into
|
|
// "any empty-ish credential passes".
|
|
partial := CallerTokens{CallerVelocity: "tok-velocity", CallerBuild: ""}
|
|
r := httptest.NewRequest("GET", "/", nil)
|
|
r.Header.Set("Authorization", "Bearer ")
|
|
if got, err := partial.Authenticate(r); err == nil {
|
|
t.Fatalf("blank bearer authenticated as %q", got)
|
|
}
|
|
}
|
|
|
|
func TestNewCallerTokensRefusesAmbiguousSets(t *testing.T) {
|
|
if _, err := NewCallerTokens(map[Caller]string{CallerVelocity: "a", CallerLimbo: "b", CallerBuild: "", CallerOps: "c"}); err != nil {
|
|
t.Fatalf("distinct tokens refused: %v", err)
|
|
}
|
|
_, err := NewCallerTokens(map[Caller]string{CallerVelocity: "same", CallerBuild: "same"})
|
|
if err == nil || !strings.Contains(err.Error(), "same value") {
|
|
t.Fatalf("shared value: err = %v, want a same-value refusal", err)
|
|
}
|
|
}
|
|
|
|
// The caller scopes the gap asked for, spelled out rather than read back from the
|
|
// route table: the build token reads a submission's context and nothing else, only
|
|
// the proxy and the login gate mint link codes, only the proxy approves an
|
|
// op-login, and only the on-node console asks for a break-glass backup.
|
|
func TestInternalRoutesServeOnlyTheirCallers(t *testing.T) {
|
|
a := newTestAPI(newFakeRepo(), newFakeCluster())
|
|
a.Internal = callerTokensForTest()
|
|
h := a.InternalHandler()
|
|
|
|
cases := []struct {
|
|
method, path string
|
|
allowed []Caller
|
|
}{
|
|
{"GET", "/api/v1/servers", []Caller{CallerVelocity}},
|
|
{"GET", "/api/v1/internal/submissions/sub-1/context", []Caller{CallerBuild}},
|
|
{"POST", "/api/v1/internal/account/link/code", []Caller{CallerVelocity, CallerLimbo}},
|
|
{"GET", "/api/v1/internal/account/link/status/00000000-0000-0000-0000-000000000001", []Caller{CallerVelocity, CallerLimbo}},
|
|
{"GET", "/api/v1/internal/player/blacklist/00000000-0000-0000-0000-000000000001", []Caller{CallerVelocity, CallerLimbo}},
|
|
{"POST", "/api/v1/internal/op-login/req-1/approve", []Caller{CallerVelocity}},
|
|
{"GET", "/api/v1/internal/op-login/pending", []Caller{CallerVelocity}},
|
|
{"GET", "/api/v1/internal/op-login/req-1", []Caller{CallerVelocity}},
|
|
{"POST", "/api/v1/internal/account/migrate/start", []Caller{CallerVelocity}},
|
|
{"POST", "/api/v1/internal/player/reclaim", []Caller{CallerVelocity}},
|
|
{"POST", "/api/v1/internal/servers/survival/wake", []Caller{CallerVelocity}},
|
|
{"POST", "/api/v1/internal/servers/survival/claim", []Caller{CallerVelocity}},
|
|
{"POST", "/api/v1/internal/servers/survival/backup", []Caller{CallerOps}},
|
|
}
|
|
tokens := map[Caller]string{CallerVelocity: "tok-velocity", CallerLimbo: "tok-limbo", CallerBuild: "tok-build", CallerOps: "tok-ops"}
|
|
for _, tc := range cases {
|
|
for caller, tok := range tokens {
|
|
allowed := false
|
|
for _, c := range tc.allowed {
|
|
allowed = allowed || c == caller
|
|
}
|
|
w := do(h, tc.method, tc.path, "{}", bearer(tok))
|
|
refused := w.Code == http.StatusForbidden && decodeErr(t, w) == "wrong_caller"
|
|
if allowed && (refused || w.Code == http.StatusUnauthorized) {
|
|
t.Errorf("%s %s as %s: refused (%d %s), want it served", tc.method, tc.path, caller, w.Code, w.Body.String())
|
|
}
|
|
if !allowed && !refused {
|
|
t.Errorf("%s %s as %s: got %d %s, want 403 wrong_caller", tc.method, tc.path, caller, w.Code, w.Body.String())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// The audit names the caller whose token asked for the action.
|
|
func TestInternalAuditNamesTheCaller(t *testing.T) {
|
|
repo := newFakeRepo()
|
|
a := newTestAPI(repo, newFakeCluster())
|
|
a.Internal = callerTokensForTest()
|
|
r := httptest.NewRequest("POST", "/", nil)
|
|
if got := internalSource(r); got != "internal" {
|
|
t.Fatalf("no caller: source = %q, want internal", got)
|
|
}
|
|
var seen string
|
|
probe := a.requireInternal(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
seen = internalSource(r)
|
|
}))
|
|
r.Header.Set("Authorization", "Bearer tok-limbo")
|
|
probe.ServeHTTP(httptest.NewRecorder(), r)
|
|
if seen != "internal:limbo" {
|
|
t.Fatalf("source = %q, want internal:limbo", seen)
|
|
}
|
|
}
|
|
|
|
// A route added to the internal table without saying who calls it would be open
|
|
// to every token; the face refuses to build instead. Callers on an external route
|
|
// would mean nothing, so that is refused too.
|
|
func TestBuildFaceRequiresCallersOnInternalRoutes(t *testing.T) {
|
|
a := newTestAPI(newFakeRepo(), newFakeCluster())
|
|
noop := func(w http.ResponseWriter, r *http.Request) {}
|
|
pass := func(h http.Handler) http.Handler { return h }
|
|
mustPanic := func(name string, fn func()) {
|
|
t.Helper()
|
|
defer func() {
|
|
if recover() == nil {
|
|
t.Errorf("%s: built without complaint", name)
|
|
}
|
|
}()
|
|
fn()
|
|
}
|
|
mustPanic("internal route without callers", func() {
|
|
a.buildFace("internal", []apiRoute{{Method: "GET", Pattern: "/api/v1/internal/x", h: noop}}, pass)
|
|
})
|
|
mustPanic("external route with callers", func() {
|
|
a.buildFace("external", []apiRoute{{Method: "GET", Pattern: "/api/v1/x", Callers: []Caller{CallerOps}, h: noop}}, pass)
|
|
})
|
|
// Public internal routes (probes, hasJoined) carry no token and list no callers.
|
|
a.buildFace("internal", []apiRoute{{Method: "GET", Pattern: "/readyz", Public: true, h: noop}}, pass)
|
|
}
|