Harden API-key model: per-key scoping + revocable keys, and configurable/whitelist-aware rate limiting (#16)
* API scope and rate limiting * improvements from review
This commit is contained in:
1 parent
a400e07ae2
commit
495edffde3
11 files changed
+843
-42
No files matched your search
@@ -7,6 +7,7 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/Turbootzz/vaultwarden-api/internal/auth"
|
||||
"github.com/Turbootzz/vaultwarden-api/internal/validators"
|
||||
"github.com/Turbootzz/vaultwarden-api/internal/vaultwarden"
|
||||
"github.com/Turbootzz/vaultwarden-api/pkg/logger"
|
||||
@@ -91,6 +92,14 @@ func (h *Handler) GetSecret(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
// Enforce the authenticated key's scope server-side, regardless of query filters.
|
||||
if !h.applyKeyScope(c, &filter) {
|
||||
logger.Warn.Printf("Request denied by key scope from IP: %s", c.IP())
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
|
||||
"error": "secret not found",
|
||||
})
|
||||
}
|
||||
|
||||
value, err := h.vaultClient.GetSecret(secretName, filter)
|
||||
if err != nil {
|
||||
logger.Error.Printf("Failed to fetch secret (requested by IP: %s)", c.IP())
|
||||
@@ -135,6 +144,66 @@ func resolveDim(dim, name, id string, nameMap map[string]string, out *string) er
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveRef resolves a scope reference that may be either a UUID or a friendly name.
|
||||
// UUIDs pass through (existence not verified); names are looked up via NameMaps.
|
||||
func resolveRef(nameMap map[string]string, ref string) (string, bool) {
|
||||
ref = strings.TrimSpace(ref)
|
||||
if ref == "" {
|
||||
return "", false
|
||||
}
|
||||
if parsed, err := uuid.Parse(ref); err == nil {
|
||||
return parsed.String(), true
|
||||
}
|
||||
return vaultwarden.LookupIDByName(nameMap, ref)
|
||||
}
|
||||
|
||||
// resolveScopeRefs maps scope refs (UUIDs or names) to UUIDs, dropping any that
|
||||
// don't resolve. The caller treats an all-unresolved dimension as deny (fail closed).
|
||||
func resolveScopeRefs(refs []string, nameMap map[string]string) []string {
|
||||
out := make([]string, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
if id, ok := resolveRef(nameMap, ref); ok {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// applyKeyScope sets the server-side scope fields on the filter from the
|
||||
// authenticated key's scope. It returns false to deny the request (404) when a
|
||||
// constrained dimension resolves to nothing — including unknown names and the
|
||||
// pre-first-sync window — so a scoped key can never read outside its scope.
|
||||
func (h *Handler) applyKeyScope(c *fiber.Ctx, filter *vaultwarden.SecretFilter) bool {
|
||||
scope, ok := auth.ScopeFromCtx(c)
|
||||
if !ok {
|
||||
// No scope in context means the auth middleware did not run for this
|
||||
// request. Fail closed rather than silently granting full access.
|
||||
return false
|
||||
}
|
||||
if scope.IsEmpty() {
|
||||
return true // unscoped key: full access
|
||||
}
|
||||
|
||||
nm := h.vaultClient.NameMaps()
|
||||
|
||||
if len(scope.Organizations) > 0 {
|
||||
ids := resolveScopeRefs(scope.Organizations, nm.Organizations)
|
||||
if len(ids) == 0 {
|
||||
return false
|
||||
}
|
||||
filter.OrganizationIDs = ids
|
||||
}
|
||||
if len(scope.Collections) > 0 {
|
||||
ids := resolveScopeRefs(scope.Collections, nm.Collections)
|
||||
if len(ids) == 0 {
|
||||
return false
|
||||
}
|
||||
filter.CollectionIDs = ids
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// parseSecretFilters reads placement query params: at most one of id or name per dimension.
|
||||
// Name-based filters are resolved against h.vaultClient.NameMaps(); unknown names fail.
|
||||
// Id-based filters are accepted as-is after UUID parsing (existence is not checked here).
|
||||
|
||||
@@ -5,9 +5,11 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Turbootzz/vaultwarden-api/internal/auth"
|
||||
"github.com/Turbootzz/vaultwarden-api/internal/vaultwarden"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/valyala/fasthttp"
|
||||
@@ -221,7 +223,7 @@ func TestParseSecretFilters(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("filter = %+v, want %+v", got, tt.want)
|
||||
}
|
||||
})
|
||||
@@ -229,8 +231,11 @@ func TestParseSecretFilters(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetSecret(t *testing.T) {
|
||||
const fullKey = "full-access-key-for-getsecret-test-000000"
|
||||
h := NewHandler(vaultwarden.NewClient(nil, 0, 0, vaultwarden.WithState(testVaultItems(), testNameMaps())))
|
||||
app := fiber.New()
|
||||
// Mirror production: auth runs first and attaches a (here unscoped) scope.
|
||||
app.Use(auth.Middleware(auth.NewStore([]auth.APIKey{{Name: "full", Key: fullKey}})))
|
||||
app.Get("/secret/:name", h.GetSecret)
|
||||
|
||||
// Test the GetSecret handler with various input scenarios
|
||||
@@ -315,6 +320,106 @@ func TestGetSecret(t *testing.T) {
|
||||
url += "?" + tt.query
|
||||
}
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+fullKey)
|
||||
resp, err := app.Test(req, -1)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != tt.wantStatus {
|
||||
t.Errorf("status = %d, want %d", resp.StatusCode, tt.wantStatus)
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if tt.wantStatus == http.StatusOK {
|
||||
var payload map[string]string
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
if payload["value"] != tt.wantBody {
|
||||
t.Errorf("value = %q, want %q", payload["value"], tt.wantBody)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !strings.Contains(string(body), tt.wantBody) {
|
||||
t.Errorf("body = %s, want substring %q", body, tt.wantBody)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetSecretFailsClosedWithoutAuth verifies that if the handler is reached
|
||||
// without the auth middleware (no scope in context), it denies rather than
|
||||
// granting full access.
|
||||
func TestGetSecretFailsClosedWithoutAuth(t *testing.T) {
|
||||
h := NewHandler(vaultwarden.NewClient(nil, 0, 0, vaultwarden.WithState(testVaultItems(), testNameMaps())))
|
||||
app := fiber.New()
|
||||
app.Get("/secret/:name", h.GetSecret) // intentionally no auth.Middleware
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/secret/db-password", nil)
|
||||
resp, err := app.Test(req, -1)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want %d (fail closed)", resp.StatusCode, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSecretScoped(t *testing.T) {
|
||||
h := NewHandler(vaultwarden.NewClient(nil, 0, 0, vaultwarden.WithState(testVaultItems(), testNameMaps())))
|
||||
|
||||
// Keys wired through the real auth middleware so scope flows via c.Locals.
|
||||
const (
|
||||
fullKey = "full-access-0000000000000000000000000000"
|
||||
colKey = "collection-scoped-11111111111111111111111"
|
||||
orgKey = "org-scoped-2222222222222222222222222222222"
|
||||
badScopeKey = "bad-scope-33333333333333333333333333333333"
|
||||
)
|
||||
store := auth.NewStore([]auth.APIKey{
|
||||
{Name: "full", Key: fullKey},
|
||||
{Name: "dev", Key: colKey, Scope: auth.Scope{Collections: []string{"Shared"}}},
|
||||
{Name: "acme", Key: orgKey, Scope: auth.Scope{Organizations: []string{"Acme"}}},
|
||||
{Name: "broken", Key: badScopeKey, Scope: auth.Scope{Collections: []string{"Nonexistent"}}},
|
||||
})
|
||||
|
||||
app := fiber.New()
|
||||
app.Use(auth.Middleware(store))
|
||||
app.Get("/secret/:name", h.GetSecret)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
path string
|
||||
query string
|
||||
wantStatus int
|
||||
wantBody string
|
||||
}{
|
||||
// db-password (cipher-1) lives in org "Acme" / collection "Shared".
|
||||
{"collection scope can read in-scope secret", colKey, "/secret/db-password", "", http.StatusOK, "s3cret"},
|
||||
// other-password (cipher-2) has no collection -> out of a collection scope.
|
||||
{"collection scope blocks out-of-scope secret", colKey, "/secret/other-password", "", http.StatusNotFound, "secret not found"},
|
||||
{"org scope can read in-scope secret", orgKey, "/secret/db-password", "", http.StatusOK, "s3cret"},
|
||||
// other-password is in a different org -> blocked server-side regardless of query.
|
||||
{"org scope blocks other org secret", orgKey, "/secret/other-password", "", http.StatusNotFound, "secret not found"},
|
||||
{"client filter cannot widen beyond org scope", orgKey, "/secret/other-password", "organization_id=" + testOtherOrgID, http.StatusNotFound, "secret not found"},
|
||||
// Unscoped (full-access) key sees everything.
|
||||
{"full access reads other org secret", fullKey, "/secret/other-password", "", http.StatusOK, "other-org"},
|
||||
// Scope referencing an unknown collection name fails closed.
|
||||
{"unresolvable scope fails closed", badScopeKey, "/secret/db-password", "", http.StatusNotFound, "secret not found"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
url := tt.path
|
||||
if tt.query != "" {
|
||||
url += "?" + tt.query
|
||||
}
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+tt.key)
|
||||
resp, err := app.Test(req, -1)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
|
||||
Reference in new issue
Block a user