362 lines
14 KiB
Go
362 lines
14 KiB
Go
package adminapi
|
|
|
|
import (
|
|
"bytes"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/iceBear67/simplepages/api"
|
|
"github.com/iceBear67/simplepages/internal/store"
|
|
)
|
|
|
|
// createKey posts to path and returns the minted credential.
|
|
func (e *env) createKey(t *testing.T, path, token string, req api.CreateKeyRequest) api.CreateKeyResponse {
|
|
t.Helper()
|
|
status, body := e.do(t, http.MethodPost, path, token, req)
|
|
var out api.CreateKeyResponse
|
|
mustJSON(t, status, http.StatusCreated, body, &out)
|
|
return out
|
|
}
|
|
|
|
func TestCreateAdminKeyReturnsWorkingToken(t *testing.T) {
|
|
e := newEnv(t)
|
|
out := e.createKey(t, api.PathKeys(), e.adminToken, api.CreateKeyRequest{Name: "ci"})
|
|
if out.Key.Scope != api.ScopeAdmin {
|
|
t.Errorf("scope = %q, want %q", out.Key.Scope, api.ScopeAdmin)
|
|
}
|
|
if out.Key.Project != "" {
|
|
t.Errorf("admin key reports project %q", out.Key.Project)
|
|
}
|
|
if !strings.HasPrefix(out.Token, "pgs_"+out.Key.ID+"_") {
|
|
t.Errorf("token does not carry its own key id %q", out.Key.ID)
|
|
}
|
|
|
|
// The new key authenticates and reports itself.
|
|
status, body := e.do(t, http.MethodGet, api.PathWhoAmI(), out.Token, nil)
|
|
var who api.WhoAmI
|
|
mustJSON(t, status, http.StatusOK, body, &who)
|
|
if who.KeyID != out.Key.ID || who.Scope != api.ScopeAdmin || who.Name != "ci" {
|
|
t.Errorf("whoami = %+v, want key %q scope admin name ci", who, out.Key.ID)
|
|
}
|
|
}
|
|
|
|
// The token exists on the wire exactly once. If it were cacheable, a shared
|
|
// proxy in front of the management API would keep a live credential on disk.
|
|
func TestCreateKeyResponseIsNoStore(t *testing.T) {
|
|
e := newEnv(t)
|
|
resp := e.doResp(t, http.MethodPost, api.PathKeys(), e.adminToken, api.CreateKeyRequest{})
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("status = %d, want 201", resp.StatusCode)
|
|
}
|
|
if cc := resp.Header.Get("Cache-Control"); !strings.Contains(cc, "no-store") {
|
|
t.Errorf("Cache-Control = %q, want it to contain no-store", cc)
|
|
}
|
|
}
|
|
|
|
// The one invariant that matters most in this package: no endpoint other than
|
|
// creation may ever put a secret on the wire. Asserted against the raw bytes,
|
|
// not a decoded struct, so a field added to api.Key later cannot leak one past
|
|
// this test.
|
|
func TestNoEndpointEverReturnsASecret(t *testing.T) {
|
|
e := newEnv(t)
|
|
e.createProject(t, "demo")
|
|
admin := e.createKey(t, api.PathKeys(), e.adminToken, api.CreateKeyRequest{Name: "admin-2"})
|
|
proj := e.createKey(t, api.PathProjectKeys("demo"), e.adminToken, api.CreateKeyRequest{Name: "ci"})
|
|
|
|
secrets := []string{
|
|
admin.Token, secretOf(t, admin.Token),
|
|
proj.Token, secretOf(t, proj.Token),
|
|
e.adminToken, secretOf(t, e.adminToken),
|
|
}
|
|
|
|
for _, tc := range []struct{ method, path, token string }{
|
|
{http.MethodGet, api.PathKeys(), e.adminToken},
|
|
{http.MethodGet, api.PathProjectKeys("demo"), e.adminToken},
|
|
{http.MethodGet, api.PathProjectKeys("demo"), proj.Token},
|
|
{http.MethodGet, api.PathWhoAmI(), proj.Token},
|
|
{http.MethodGet, api.PathProject("demo"), e.adminToken},
|
|
{http.MethodGet, api.PathProjects(), e.adminToken},
|
|
} {
|
|
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
|
status, body := e.do(t, tc.method, tc.path, tc.token, nil)
|
|
if status != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body: %s", status, body)
|
|
}
|
|
for _, s := range secrets {
|
|
if bytes.Contains(body, []byte(s)) {
|
|
t.Fatalf("response contains a credential")
|
|
}
|
|
}
|
|
// The public half is fine to return, and the listings would be
|
|
// useless without it — check the test is actually looking at keys.
|
|
if strings.HasSuffix(tc.path, "/keys") && !bytes.Contains(body, []byte(proj.Key.ID)) &&
|
|
!bytes.Contains(body, []byte(admin.Key.ID)) {
|
|
t.Errorf("key listing mentions no key id at all: %s", body)
|
|
}
|
|
})
|
|
}
|
|
|
|
// And the log, which sees every request, never saw one either.
|
|
if logged := e.logBuf.String(); logged != "" {
|
|
for _, s := range secrets {
|
|
if strings.Contains(logged, s) {
|
|
t.Fatal("a credential reached the log")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// secretOf returns the half of a token that must never be seen again. The split
|
|
// is bounded at three because the base64url secret may itself contain "_".
|
|
func secretOf(t *testing.T, token string) string {
|
|
t.Helper()
|
|
parts := strings.SplitN(token, "_", 3)
|
|
if len(parts) != 3 {
|
|
t.Fatalf("token has %d parts, want 3", len(parts))
|
|
}
|
|
return parts[2]
|
|
}
|
|
|
|
func TestCreateProjectKey(t *testing.T) {
|
|
e := newEnv(t)
|
|
e.createProject(t, "demo")
|
|
out := e.createKey(t, api.PathProjectKeys("demo"), e.adminToken, api.CreateKeyRequest{Name: "ci"})
|
|
if out.Key.Scope != api.ScopeProject {
|
|
t.Errorf("scope = %q, want %q", out.Key.Scope, api.ScopeProject)
|
|
}
|
|
if out.Key.Project != "demo" {
|
|
t.Errorf("project = %q, want demo", out.Key.Project)
|
|
}
|
|
|
|
status, body := e.do(t, http.MethodGet, api.PathWhoAmI(), out.Token, nil)
|
|
var who api.WhoAmI
|
|
mustJSON(t, status, http.StatusOK, body, &who)
|
|
if who.Scope != api.ScopeProject || who.Project != "demo" {
|
|
t.Errorf("whoami = %+v, want scope project on demo", who)
|
|
}
|
|
}
|
|
|
|
func TestCreateProjectKeyForUnknownProject(t *testing.T) {
|
|
e := newEnv(t)
|
|
status, body := e.do(t, http.MethodPost, api.PathProjectKeys("ghost"), e.adminToken,
|
|
api.CreateKeyRequest{})
|
|
if status != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404; body: %s", status, body)
|
|
}
|
|
}
|
|
|
|
func TestCreateKeyRejectsBadInput(t *testing.T) {
|
|
e := newEnv(t)
|
|
past := time.Now().Add(-time.Minute)
|
|
|
|
t.Run("expired on arrival", func(t *testing.T) {
|
|
status, body := e.do(t, http.MethodPost, api.PathKeys(), e.adminToken,
|
|
api.CreateKeyRequest{ExpiresAt: &past})
|
|
if status != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400; body: %s", status, body)
|
|
}
|
|
})
|
|
t.Run("control characters in name", func(t *testing.T) {
|
|
status, body := e.do(t, http.MethodPost, api.PathKeys(), e.adminToken,
|
|
api.CreateKeyRequest{Name: "ci\x1b[2Jrunner"})
|
|
if status != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400; body: %s", status, body)
|
|
}
|
|
})
|
|
t.Run("oversized name", func(t *testing.T) {
|
|
status, body := e.do(t, http.MethodPost, api.PathKeys(), e.adminToken,
|
|
api.CreateKeyRequest{Name: strings.Repeat("a", maxKeyNameLen+1)})
|
|
if status != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400; body: %s", status, body)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestExpiredKeyDoesNotAuthenticate(t *testing.T) {
|
|
e := newEnv(t)
|
|
// The API refuses to mint one already expired, so this goes in through the
|
|
// store to exercise the verifier's expiry check rather than the validator's.
|
|
future := time.Now().Add(time.Hour)
|
|
live := e.createKey(t, api.PathKeys(), e.adminToken, api.CreateKeyRequest{ExpiresAt: &future})
|
|
if status, body := e.do(t, http.MethodGet, api.PathWhoAmI(), live.Token, nil); status != http.StatusOK {
|
|
t.Fatalf("key expiring in an hour: status = %d, body: %s", status, body)
|
|
}
|
|
|
|
past := time.Now().Add(-time.Hour)
|
|
token := e.mintWith(t, store.ScopeAdmin, nil, "stale", &past)
|
|
if status, _ := e.do(t, http.MethodGet, api.PathWhoAmI(), token, nil); status != http.StatusUnauthorized {
|
|
t.Errorf("expired key authenticated: status = %d", status)
|
|
}
|
|
}
|
|
|
|
// Revocation must be visible on the next request, not when a cache entry ages
|
|
// out — an operator revoking a leaked token is racing an attacker who has it.
|
|
func TestRevokeTakesEffectImmediately(t *testing.T) {
|
|
e := newEnv(t)
|
|
e.createProject(t, "demo")
|
|
victim := e.createKey(t, api.PathProjectKeys("demo"), e.adminToken, api.CreateKeyRequest{Name: "ci"})
|
|
|
|
// Warm the auth cache: without Invalidate() the revocation would not be
|
|
// observed until the entry expired.
|
|
if status, _ := e.do(t, http.MethodGet, api.PathWhoAmI(), victim.Token, nil); status != http.StatusOK {
|
|
t.Fatalf("key did not work before revocation")
|
|
}
|
|
|
|
status, body := e.do(t, http.MethodDelete, api.PathKey(victim.Key.ID), e.adminToken, nil)
|
|
if status != http.StatusNoContent {
|
|
t.Fatalf("revoke: status = %d, want 204; body: %s", status, body)
|
|
}
|
|
if status, _ := e.do(t, http.MethodGet, api.PathWhoAmI(), victim.Token, nil); status != http.StatusUnauthorized {
|
|
t.Errorf("revoked key still authenticates: status = %d", status)
|
|
}
|
|
|
|
// Revoking again is a no-op rather than an error: a retrying CI step must
|
|
// not fail on the second attempt.
|
|
if status, body := e.do(t, http.MethodDelete, api.PathKey(victim.Key.ID), e.adminToken, nil); status != http.StatusNoContent {
|
|
t.Errorf("second revoke: status = %d, want 204; body: %s", status, body)
|
|
}
|
|
|
|
// The listing keeps it, with a revocation timestamp, so an operator can see
|
|
// what happened.
|
|
status, body = e.do(t, http.MethodGet, api.PathKeys(), e.adminToken, nil)
|
|
var list api.KeyList
|
|
mustJSON(t, status, http.StatusOK, body, &list)
|
|
var found bool
|
|
for _, k := range list.Keys {
|
|
if k.ID == victim.Key.ID {
|
|
found = true
|
|
if !k.Revoked() {
|
|
t.Errorf("key %s is listed without revoked_at", k.ID)
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("revoked key vanished from the listing")
|
|
}
|
|
}
|
|
|
|
// A CI runner that believes its token leaked should be able to burn it without
|
|
// waiting for an operator.
|
|
func TestProjectKeyMayRevokeItself(t *testing.T) {
|
|
e := newEnv(t)
|
|
e.createProject(t, "demo")
|
|
k := e.createKey(t, api.PathProjectKeys("demo"), e.adminToken, api.CreateKeyRequest{Name: "ci"})
|
|
|
|
status, body := e.do(t, http.MethodDelete, api.PathKey(k.Key.ID), k.Token, nil)
|
|
if status != http.StatusNoContent {
|
|
t.Fatalf("status = %d, want 204; body: %s", status, body)
|
|
}
|
|
if status, _ := e.do(t, http.MethodGet, api.PathWhoAmI(), k.Token, nil); status != http.StatusUnauthorized {
|
|
t.Errorf("key survived revoking itself: status = %d", status)
|
|
}
|
|
}
|
|
|
|
func TestRevokeAuthorisation(t *testing.T) {
|
|
e := newEnv(t)
|
|
e.createProject(t, "demo")
|
|
e.createProject(t, "other")
|
|
|
|
mine := e.createKey(t, api.PathProjectKeys("demo"), e.adminToken, api.CreateKeyRequest{Name: "ci"})
|
|
sibling := e.createKey(t, api.PathProjectKeys("demo"), e.adminToken, api.CreateKeyRequest{Name: "ci-2"})
|
|
foreign := e.createKey(t, api.PathProjectKeys("other"), e.adminToken, api.CreateKeyRequest{Name: "ci"})
|
|
adminKey := e.createKey(t, api.PathKeys(), e.adminToken, api.CreateKeyRequest{Name: "admin-2"})
|
|
|
|
t.Run("sibling in the same project", func(t *testing.T) {
|
|
status, body := e.do(t, http.MethodDelete, api.PathKey(sibling.Key.ID), mine.Token, nil)
|
|
if status != http.StatusNoContent {
|
|
t.Fatalf("status = %d, want 204; body: %s", status, body)
|
|
}
|
|
})
|
|
t.Run("another project's key", func(t *testing.T) {
|
|
status, body := e.do(t, http.MethodDelete, api.PathKey(foreign.Key.ID), mine.Token, nil)
|
|
if status != http.StatusForbidden {
|
|
t.Fatalf("status = %d, want 403; body: %s", status, body)
|
|
}
|
|
})
|
|
t.Run("an admin key", func(t *testing.T) {
|
|
// An admin key has no project, so a project-scoped caller can never own
|
|
// it. Escalating by revoking the operator's credentials is the attack
|
|
// this closes.
|
|
status, body := e.do(t, http.MethodDelete, api.PathKey(adminKey.Key.ID), mine.Token, nil)
|
|
if status != http.StatusForbidden {
|
|
t.Fatalf("status = %d, want 403; body: %s", status, body)
|
|
}
|
|
if status, _ := e.do(t, http.MethodGet, api.PathWhoAmI(), adminKey.Token, nil); status != http.StatusOK {
|
|
t.Errorf("the admin key stopped working: status = %d", status)
|
|
}
|
|
})
|
|
}
|
|
|
|
// 404 versus 403 is an oracle: a project key must not be able to walk the key
|
|
// id space and learn which ones exist.
|
|
func TestRevokeUnknownKey(t *testing.T) {
|
|
e := newEnv(t)
|
|
e.createProject(t, "demo")
|
|
proj := e.createKey(t, api.PathProjectKeys("demo"), e.adminToken, api.CreateKeyRequest{Name: "ci"})
|
|
const unknown = "abcdefghijklmnop" // well-formed, never minted
|
|
|
|
t.Run("admin", func(t *testing.T) {
|
|
status, body := e.do(t, http.MethodDelete, api.PathKey(unknown), e.adminToken, nil)
|
|
if status != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404; body: %s", status, body)
|
|
}
|
|
})
|
|
t.Run("project", func(t *testing.T) {
|
|
status, body := e.do(t, http.MethodDelete, api.PathKey(unknown), proj.Token, nil)
|
|
if status != http.StatusForbidden {
|
|
t.Fatalf("status = %d, want 403; body: %s", status, body)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRevokeMalformedKeyID(t *testing.T) {
|
|
e := newEnv(t)
|
|
for _, id := range []string{"nope", "ABCDEFGHIJKLMNOP", "abcdefghijklmno1", strings.Repeat("a", 17)} {
|
|
t.Run(id, func(t *testing.T) {
|
|
status, body := e.do(t, http.MethodDelete, api.PathKey(id), e.adminToken, nil)
|
|
if status != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400; body: %s", status, body)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestListProjectKeysIsScoped(t *testing.T) {
|
|
e := newEnv(t)
|
|
e.createProject(t, "demo")
|
|
e.createProject(t, "other")
|
|
mine := e.createKey(t, api.PathProjectKeys("demo"), e.adminToken, api.CreateKeyRequest{Name: "ci"})
|
|
foreign := e.createKey(t, api.PathProjectKeys("other"), e.adminToken, api.CreateKeyRequest{Name: "ci"})
|
|
|
|
status, body := e.do(t, http.MethodGet, api.PathProjectKeys("demo"), mine.Token, nil)
|
|
var list api.KeyList
|
|
mustJSON(t, status, http.StatusOK, body, &list)
|
|
if len(list.Keys) != 1 || list.Keys[0].ID != mine.Key.ID {
|
|
t.Fatalf("listing = %+v, want just %s", list.Keys, mine.Key.ID)
|
|
}
|
|
if list.Keys[0].Project != "demo" {
|
|
t.Errorf("project = %q, want demo", list.Keys[0].Project)
|
|
}
|
|
|
|
if status, _ := e.do(t, http.MethodGet, api.PathProjectKeys("other"), mine.Token, nil); status != http.StatusForbidden {
|
|
t.Errorf("read another project's keys: status = %d, want 403", status)
|
|
}
|
|
|
|
// The admin listing spans projects and names each key's project.
|
|
status, body = e.do(t, http.MethodGet, api.PathKeys(), e.adminToken, nil)
|
|
var all api.KeyList
|
|
mustJSON(t, status, http.StatusOK, body, &all)
|
|
byID := make(map[string]api.Key, len(all.Keys))
|
|
for _, k := range all.Keys {
|
|
byID[k.ID] = k
|
|
}
|
|
if got := byID[foreign.Key.ID].Project; got != "other" {
|
|
t.Errorf("foreign key's project = %q, want other", got)
|
|
}
|
|
if _, ok := byID[mine.Key.ID]; !ok {
|
|
t.Errorf("admin listing is missing %s", mine.Key.ID)
|
|
}
|
|
}
|