Filtering of synchronized secrets on collection, organization, and/or folder (#12)
* Filtering feature, minor logic changes +: Add IP validation (to properly log IP even when trusted proxy is missing) +: Added synchronization of decrypted organization, collection, and folder names (for filtering purposes) +: Changed sync behavior to prevent collapsing multiple logins with the same name +: Added possibility to filter logins based on organization, collection, and folder name/id in GetSecret +: Added support for space character in secret name validation (it is also properly URL-decoded) * Update README * Fixes related to issues reported by CodeRabbit: +: Changed british use of organisations in README +: Reduced line count in parseSecretFilters by including helper function for filter dimensions resolution +: Added comment clarifying absence of existence checking on dimension IDs as opposed to names +: Removed efensive comma split logic on IPs (redundant due to IPValidation setting on Fiber) +: Changed Query validator to align with name validator (mainly removing hard-coded values) +: Added comments to decryptVaultLabel for clarity +: Change empty SyncNameMaps instance with emptySyncNameMaps helper for consistency +: Add lexical sort for lookupIDByName for deterministic result in case of multiple matches * Bumped logging to warn and added entity metadata in log * Unit tests: +: Added unit tests for every newly introduced or changed function +: Rewrote NewClient constructor to accept optional preloaded data (so it can be used in test scenarios) * Changes requested by CodeRabbit
This commit is contained in:
1 parent
cac042a5c4
commit
76b82d4dba
11 files changed
+1174
-88
No files matched your search
@@ -2,10 +2,16 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/Turbootzz/vaultwarden-api/internal/validators"
|
||||
"github.com/Turbootzz/vaultwarden-api/internal/vaultwarden"
|
||||
"github.com/Turbootzz/vaultwarden-api/pkg/logger"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Handler contains all HTTP handlers.
|
||||
@@ -28,9 +34,38 @@ func (h *Handler) HealthCheck(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
// decodeSecretPathParam unescapes the name of the secret from the URL path.
|
||||
// Mainly used to handle space decodings. Repeats until stable to handle
|
||||
// typical double-encoded values (e.g. %2520). Fails if recursive encoding
|
||||
// is detected.
|
||||
func decodeSecretPathParam(raw string) (string, error) {
|
||||
s := strings.TrimSpace(raw)
|
||||
if s == "" {
|
||||
return "", nil
|
||||
}
|
||||
const maxPasses = 4
|
||||
for range maxPasses {
|
||||
dec, err := url.PathUnescape(s)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if dec == s {
|
||||
return dec, nil
|
||||
}
|
||||
s = dec
|
||||
}
|
||||
return "", errors.New("path encoding depth exceeded")
|
||||
}
|
||||
|
||||
// GetSecret handles GET /secret/:name.
|
||||
func (h *Handler) GetSecret(c *fiber.Ctx) error {
|
||||
secretName := c.Params("name")
|
||||
secretName, err := decodeSecretPathParam(c.Params("name"))
|
||||
if err != nil {
|
||||
logger.Warn.Printf("Invalid secret path encoding from IP: %s", c.IP())
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": "invalid secret name format",
|
||||
})
|
||||
}
|
||||
|
||||
if secretName == "" {
|
||||
logger.Warn.Println("Secret name not provided")
|
||||
@@ -46,7 +81,17 @@ func (h *Handler) GetSecret(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
value, err := h.vaultClient.GetSecret(secretName)
|
||||
filter, err := h.parseSecretFilters(c)
|
||||
if err != nil {
|
||||
// Don't leak information about existence of correct filters
|
||||
// Security through obscurity ;)
|
||||
logger.Warn.Printf("Invalid secret filters attempted from IP: %s - %v", c.IP(), err)
|
||||
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())
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
|
||||
@@ -60,6 +105,95 @@ func (h *Handler) GetSecret(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
func parseUUIDQuery(field, raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", nil
|
||||
}
|
||||
parsed, err := uuid.Parse(raw)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid %s: must be a UUID", field)
|
||||
}
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
// resolveDim sets *out from either a friendly name (resolved via NameMaps) or a raw id.
|
||||
// Name lookups use vaultwarden.LookupIDByName and return an error when the name is unknown.
|
||||
// Id branch copies the value through without verifying it exists in the vault—only format
|
||||
// validation (parseUUIDQuery) applies earlier. That asymmetry is intentional.
|
||||
func resolveDim(dim, name, id string, nameMap map[string]string, out *string) error {
|
||||
switch {
|
||||
case name != "":
|
||||
resolved, ok := vaultwarden.LookupIDByName(nameMap, name)
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown %s_name", dim)
|
||||
}
|
||||
*out = resolved
|
||||
case id != "":
|
||||
*out = id
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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).
|
||||
func (h *Handler) parseSecretFilters(c *fiber.Ctx) (vaultwarden.SecretFilter, error) {
|
||||
var out vaultwarden.SecretFilter
|
||||
|
||||
orgID, err := parseUUIDQuery("organization_id", c.Query("organization_id"))
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
orgName := strings.TrimSpace(c.Query("organization_name"))
|
||||
|
||||
colID, err := parseUUIDQuery("collection_id", c.Query("collection_id"))
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
colName := strings.TrimSpace(c.Query("collection_name"))
|
||||
|
||||
folderID, err := parseUUIDQuery("folder_id", c.Query("folder_id"))
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
folderName := strings.TrimSpace(c.Query("folder_name"))
|
||||
|
||||
if orgID != "" && orgName != "" {
|
||||
return out, fmt.Errorf("use only one of organization_id and organization_name")
|
||||
}
|
||||
if colID != "" && colName != "" {
|
||||
return out, fmt.Errorf("use only one of collection_id and collection_name")
|
||||
}
|
||||
if folderID != "" && folderName != "" {
|
||||
return out, fmt.Errorf("use only one of folder_id and folder_name")
|
||||
}
|
||||
|
||||
if orgName != "" && !validators.IsValidFilterQueryValue(orgName) {
|
||||
return out, fmt.Errorf("invalid organization_name")
|
||||
}
|
||||
if colName != "" && !validators.IsValidFilterQueryValue(colName) {
|
||||
return out, fmt.Errorf("invalid collection_name")
|
||||
}
|
||||
if folderName != "" && !validators.IsValidFilterQueryValue(folderName) {
|
||||
return out, fmt.Errorf("invalid folder_name")
|
||||
}
|
||||
|
||||
nm := h.vaultClient.NameMaps()
|
||||
|
||||
if err := resolveDim("organization", orgName, orgID, nm.Organizations, &out.OrganizationID); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := resolveDim("collection", colName, colID, nm.Collections, &out.CollectionID); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := resolveDim("folder", folderName, folderID, nm.Folders, &out.FolderID); err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RefreshCache handles POST /refresh.
|
||||
func (h *Handler) RefreshCache(c *fiber.Ctx) error {
|
||||
h.vaultClient.ClearCache()
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Turbootzz/vaultwarden-api/internal/vaultwarden"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
// Global test constants. Also used in other test files
|
||||
const (
|
||||
testOrgID = "11111111-1111-4111-8111-111111111111"
|
||||
testColID = "44444444-4444-4444-8444-444444444444"
|
||||
testFolderID = "33333333-3333-4333-8333-333333333333"
|
||||
testOtherOrgID = "22222222-2222-4222-8222-222222222222"
|
||||
)
|
||||
|
||||
func testNameMaps() vaultwarden.SyncNameMaps {
|
||||
return vaultwarden.SyncNameMaps{
|
||||
Organizations: map[string]string{testOrgID: "Acme"},
|
||||
Collections: map[string]string{testColID: "Shared"},
|
||||
Folders: map[string]string{testFolderID: "Work"},
|
||||
}
|
||||
}
|
||||
|
||||
func testVaultItems() map[string]vaultwarden.DecryptedItem {
|
||||
return map[string]vaultwarden.DecryptedItem{
|
||||
"cipher-1": {
|
||||
ID: "cipher-1",
|
||||
Name: "db-password",
|
||||
Password: "s3cret",
|
||||
OrganizationID: testOrgID,
|
||||
CollectionIDs: []string{testColID},
|
||||
FolderID: testFolderID,
|
||||
},
|
||||
"cipher-2": {
|
||||
ID: "cipher-2",
|
||||
Name: "other-password",
|
||||
Password: "other-org",
|
||||
OrganizationID: testOtherOrgID,
|
||||
},
|
||||
"cipher-3": {
|
||||
ID: "cipher-3",
|
||||
Name: "my secret",
|
||||
Password: "partial",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func acquireTestCtx(t *testing.T, query string) (*fiber.App, *fiber.Ctx) {
|
||||
t.Helper()
|
||||
app := fiber.New()
|
||||
ctx := app.AcquireCtx(&fasthttp.RequestCtx{})
|
||||
ctx.Request().Header.SetMethod("GET")
|
||||
ctx.Request().URI().SetPath("/")
|
||||
if query != "" {
|
||||
ctx.Request().URI().SetQueryString(query)
|
||||
}
|
||||
t.Cleanup(func() { app.ReleaseCtx(ctx) })
|
||||
return app, ctx
|
||||
}
|
||||
|
||||
func TestDecodeSecretPathParam(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Test proper parsing of URL-encoded secret names
|
||||
// (mainly proper decoding of spaces)
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty", "", "", false},
|
||||
{"plain", "my-secret", "my-secret", false},
|
||||
{"trim", " spaced ", "spaced", false},
|
||||
{"single encoded space", "hello%20world", "hello world", false},
|
||||
{"double encoded space", "hello%2520world", "hello world", false},
|
||||
{"invalid percent", "%ZZ", "", true},
|
||||
{"depth exceeded", "%252525252520", "", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := decodeSecretPathParam(tt.raw)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUUIDQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
valid := "11111111-1111-4111-8111-111111111111"
|
||||
|
||||
// Test proper parsing of UUID query values
|
||||
tests := []struct {
|
||||
name string
|
||||
field string
|
||||
raw string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty", "organization_id", "", "", false},
|
||||
{"trimmed valid", "organization_id", " " + valid + " ", valid, false},
|
||||
{"invalid", "collection_id", "not-a-uuid", "", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := parseUUIDQuery(tt.field, tt.raw)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.field) {
|
||||
t.Errorf("error %v should mention field %q", err, tt.field)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSecretFilters(t *testing.T) {
|
||||
h := NewHandler(vaultwarden.NewClient(nil, 0, 0, vaultwarden.WithState(nil, testNameMaps())))
|
||||
|
||||
// Test proper parsing of query arguments for secret filters
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
want vaultwarden.SecretFilter
|
||||
wantErr string
|
||||
}{
|
||||
{"no filters", "", vaultwarden.SecretFilter{}, ""},
|
||||
{
|
||||
"organization id passthrough",
|
||||
"organization_id=" + testOrgID,
|
||||
vaultwarden.SecretFilter{OrganizationID: testOrgID},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"organization name resolved",
|
||||
"organization_name=Acme",
|
||||
vaultwarden.SecretFilter{OrganizationID: testOrgID},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"collection and folder by name",
|
||||
"collection_name=Shared&folder_name=Work",
|
||||
vaultwarden.SecretFilter{CollectionID: testColID, FolderID: testFolderID},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"both org id and name",
|
||||
"organization_id=" + testOrgID + "&organization_name=Acme",
|
||||
vaultwarden.SecretFilter{},
|
||||
"use only one of organization_id and organization_name",
|
||||
},
|
||||
{
|
||||
"invalid organization uuid",
|
||||
"organization_id=bad",
|
||||
vaultwarden.SecretFilter{},
|
||||
"invalid organization_id",
|
||||
},
|
||||
{
|
||||
"unknown organization name",
|
||||
"organization_name=Missing",
|
||||
vaultwarden.SecretFilter{},
|
||||
"unknown organization_name",
|
||||
},
|
||||
{
|
||||
"invalid organization name chars",
|
||||
"organization_name=bad%0aname",
|
||||
vaultwarden.SecretFilter{},
|
||||
"invalid organization_name",
|
||||
},
|
||||
{
|
||||
"unknown id accepted",
|
||||
"folder_id=88888888-8888-4888-8888-888888888888",
|
||||
vaultwarden.SecretFilter{FolderID: "88888888-8888-4888-8888-888888888888"},
|
||||
"",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, ctx := acquireTestCtx(t, tt.query)
|
||||
got, err := h.parseSecretFilters(ctx)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error = %v, want substring %q", err, tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("filter = %+v, want %+v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSecret(t *testing.T) {
|
||||
h := NewHandler(vaultwarden.NewClient(nil, 0, 0, vaultwarden.WithState(testVaultItems(), testNameMaps())))
|
||||
app := fiber.New()
|
||||
app.Get("/secret/:name", h.GetSecret)
|
||||
|
||||
// Test the GetSecret handler with various input scenarios
|
||||
// Mainly tests that edge cases in GetSecret are handled properly
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
query string
|
||||
wantStatus int
|
||||
wantBody string
|
||||
}{
|
||||
{
|
||||
name: "invalid path encoding",
|
||||
path: "/secret/%25ZZ",
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: "invalid secret name format",
|
||||
},
|
||||
{
|
||||
name: "invalid secret name",
|
||||
path: "/secret/..",
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: "invalid secret name format",
|
||||
},
|
||||
{
|
||||
name: "whitespace only secret name",
|
||||
path: "/secret/%20",
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: "invalid secret name format",
|
||||
},
|
||||
{
|
||||
name: "invalid filter uuid",
|
||||
path: "/secret/db-password",
|
||||
query: "organization_id=not-a-uuid",
|
||||
wantStatus: http.StatusNotFound,
|
||||
wantBody: "secret not found",
|
||||
},
|
||||
{
|
||||
name: "unknown filter name",
|
||||
path: "/secret/db-password",
|
||||
query: "organization_name=Unknown",
|
||||
wantStatus: http.StatusNotFound,
|
||||
wantBody: "secret not found",
|
||||
},
|
||||
{
|
||||
name: "filtered out by folder",
|
||||
path: "/secret/db-password",
|
||||
query: "folder_id=88888888-8888-4888-8888-888888888888",
|
||||
wantStatus: http.StatusNotFound,
|
||||
wantBody: "secret not found",
|
||||
},
|
||||
{
|
||||
name: "secret not in vault",
|
||||
path: "/secret/missing-item",
|
||||
wantStatus: http.StatusNotFound,
|
||||
wantBody: "secret not found",
|
||||
},
|
||||
{
|
||||
name: "success",
|
||||
path: "/secret/db-password",
|
||||
wantStatus: http.StatusOK,
|
||||
wantBody: "s3cret",
|
||||
},
|
||||
{
|
||||
name: "success with encoded space in path",
|
||||
path: "/secret/my%2520secret",
|
||||
wantStatus: http.StatusOK,
|
||||
wantBody: "partial",
|
||||
},
|
||||
{
|
||||
name: "success with organization filter",
|
||||
path: "/secret/db-password",
|
||||
query: "organization_name=Acme",
|
||||
wantStatus: http.StatusOK,
|
||||
wantBody: "s3cret",
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -10,18 +10,18 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/Turbootzz/vaultwarden-api/pkg/logger"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// IPWhitelist manages IP-based access control
|
||||
type IPWhitelist struct {
|
||||
mu sync.RWMutex
|
||||
allowedIPs map[string]bool
|
||||
allowedCIDRs []*net.IPNet
|
||||
githubIPRanges []*net.IPNet
|
||||
enableGitHub bool
|
||||
lastGitHubUpdate time.Time
|
||||
mu sync.RWMutex
|
||||
allowedIPs map[string]bool
|
||||
allowedCIDRs []*net.IPNet
|
||||
githubIPRanges []*net.IPNet
|
||||
enableGitHub bool
|
||||
lastGitHubUpdate time.Time
|
||||
}
|
||||
|
||||
// GitHubMeta represents GitHub's API response for IP ranges
|
||||
@@ -86,27 +86,14 @@ func (wl *IPWhitelist) Middleware() fiber.Handler {
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
// Get client IP from Fiber
|
||||
clientIP := c.IP()
|
||||
|
||||
// If c.IP() returns comma-separated IPs, parse them manually
|
||||
// This happens when Fiber doesn't parse X-Forwarded-For despite TrustedProxies config
|
||||
// We take the FIRST IP (leftmost = original client, rightmost = trusted proxy)
|
||||
var realClientIP string
|
||||
if strings.Contains(clientIP, ",") {
|
||||
ips := strings.Split(clientIP, ",")
|
||||
// Take first IP (original client before any proxies)
|
||||
realClientIP = strings.TrimSpace(ips[0])
|
||||
} else {
|
||||
realClientIP = clientIP
|
||||
}
|
||||
|
||||
if wl.IsAllowed(realClientIP) {
|
||||
logger.Debug.Printf("IP allowed: %s", realClientIP)
|
||||
if wl.IsAllowed(clientIP) {
|
||||
logger.Debug.Printf("IP allowed: %s", clientIP)
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
logger.Warn.Printf("IP blocked (not whitelisted): %s on %s %s", realClientIP, c.Method(), c.Path())
|
||||
logger.Warn.Printf("IP blocked (not whitelisted): %s on %s %s", clientIP, c.Method(), c.Path())
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
|
||||
"error": "access denied: IP not whitelisted",
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
const SecretNameMaxLength = 255
|
||||
|
||||
var SecretNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_\-\./]*[a-zA-Z0-9]$`)
|
||||
var SecretNamePattern = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9 _\-\./]*[a-zA-Z0-9])?$`)
|
||||
|
||||
func IsValidSecretName(name string) bool {
|
||||
if len(name) == 0 || len(name) > SecretNameMaxLength {
|
||||
@@ -43,4 +43,17 @@ func SanitizeSecretName(name string) (string, bool) {
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func IsValidFilterQueryValue(s string) bool {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" || len(s) > SecretNameMaxLength {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if r < 32 || r == 127 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package validators
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsValidFilterQueryValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Test allowed length of filter query values
|
||||
longOK := strings.Repeat("a", SecretNameMaxLength)
|
||||
tooLong := strings.Repeat("a", SecretNameMaxLength+1)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
// Add test cases here
|
||||
{"simple name", "Acme Corp", true},
|
||||
{"trimmed valid", " Dev Team ", true},
|
||||
{"unicode printable", "Team-α", true},
|
||||
{"max length", longOK, true},
|
||||
{"empty", "", false},
|
||||
{"whitespace only", " ", false},
|
||||
{"too long", tooLong, false},
|
||||
{"newline", "bad\nname", false},
|
||||
{"tab", "bad\tname", false},
|
||||
{"del control", "bad\x7fname", false},
|
||||
{"null", "bad\x00name", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := IsValidFilterQueryValue(tt.input); got != tt.want {
|
||||
t.Errorf("IsValidFilterQueryValue(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,13 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/Turbootzz/vaultwarden-api/pkg/logger"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// PreloginResponse contains the KDF parameters from the server.
|
||||
@@ -34,16 +35,18 @@ type TokenResponse struct {
|
||||
|
||||
// SyncResponse contains the full vault sync data.
|
||||
type SyncResponse struct {
|
||||
Profile SyncProfile `json:"profile"`
|
||||
Ciphers []SyncCipher `json:"ciphers"`
|
||||
Profile SyncProfile `json:"profile"`
|
||||
Ciphers []SyncCipher `json:"ciphers"`
|
||||
Collections []SyncCollection `json:"collections"`
|
||||
Folders []SyncFolder `json:"folders"`
|
||||
}
|
||||
|
||||
// SyncProfile contains user profile info.
|
||||
type SyncProfile struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Key string `json:"key"`
|
||||
PrivateKey string `json:"privateKey"`
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Key string `json:"key"`
|
||||
PrivateKey string `json:"privateKey"`
|
||||
Organizations []SyncOrganization `json:"organizations"`
|
||||
}
|
||||
|
||||
@@ -54,11 +57,26 @@ type SyncOrganization struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
// SyncCollection represents a collection that logins can be assigned to.
|
||||
type SyncCollection struct {
|
||||
ID string `json:"id"`
|
||||
OrganizationID string `json:"organizationId"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// SyncFolder represents a folder that logins can be assigned to.
|
||||
type SyncFolder struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// SyncCipher represents an encrypted vault item from the sync response.
|
||||
type SyncCipher struct {
|
||||
ID string `json:"id"`
|
||||
Type int `json:"type"`
|
||||
OrganizationID *string `json:"organizationId"`
|
||||
CollectionIDs []string `json:"collectionIds"`
|
||||
FolderID *string `json:"folderId"`
|
||||
Name string `json:"name"`
|
||||
Notes *string `json:"notes"`
|
||||
Login *SyncLogin `json:"login"`
|
||||
@@ -264,10 +282,128 @@ func (ac *APIClient) EnsureValidToken() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sync fetches and decrypts all vault items.
|
||||
func (ac *APIClient) Sync() ([]DecryptedItem, error) {
|
||||
// SyncNameMaps holds decrypted display names keyed by Vaultwarden UUID (from sync).
|
||||
type SyncNameMaps struct {
|
||||
Organizations map[string]string // organization id -> name (decrypted with user symmetric key)
|
||||
Folders map[string]string // folder id -> name (decrypted with user symmetric key)
|
||||
Collections map[string]string // collection id -> name (decrypted with org symmetric key)
|
||||
}
|
||||
|
||||
func emptySyncNameMaps() SyncNameMaps {
|
||||
return SyncNameMaps{
|
||||
Organizations: make(map[string]string),
|
||||
Folders: make(map[string]string),
|
||||
Collections: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// decryptVaultLabel decrypts a cipher string using the given keys in order.
|
||||
// decryptVaultLabel decrypts a cipher string using the given keys in order.
|
||||
// The entityKind and entityID are used for tracing during logging.
|
||||
func decryptVaultLabel(raw, entityKind, entityID string, keys ...SymmetricKey) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
for _, k := range keys {
|
||||
if len(k.EncKey) == 0 {
|
||||
continue
|
||||
}
|
||||
out, err := DecryptStr(raw, k)
|
||||
if err == nil && out != "" {
|
||||
return out
|
||||
}
|
||||
}
|
||||
// If not a ciphertext, return as-is
|
||||
// NOTE: This does not mean that the decryption will silently fail and return the raw value if
|
||||
// the decryption fails during MAC verification (that part still throws an error as it should)
|
||||
if _, err := ParseCipherString(raw); err != nil {
|
||||
return raw
|
||||
}
|
||||
logger.Warn.Printf("decryptVaultLabel: could not decrypt ciphertext-form %s name (id=%s)", entityKind, entityID)
|
||||
return ""
|
||||
}
|
||||
|
||||
// buildSyncNameMaps decrypts organization, folder, and collection names from a sync response.
|
||||
// Organization names are tried with the user symmetric key first, then that org's symmetric key
|
||||
// Folder names use the user key; collection names use the org key.
|
||||
func buildSyncNameMaps(syncResp SyncResponse, userKey SymmetricKey, orgKeys map[string]SymmetricKey) SyncNameMaps {
|
||||
out := emptySyncNameMaps()
|
||||
|
||||
for _, org := range syncResp.Profile.Organizations {
|
||||
if org.ID == "" {
|
||||
continue
|
||||
}
|
||||
var keys []SymmetricKey
|
||||
keys = append(keys, userKey)
|
||||
if k, ok := orgKeys[org.ID]; ok {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
name := decryptVaultLabel(org.Name, "organization", org.ID, keys...)
|
||||
if name == "" {
|
||||
logger.Debug.Printf("Empty organization display name for org %s (raw len=%d)", org.ID, len(org.Name))
|
||||
continue
|
||||
}
|
||||
out.Organizations[org.ID] = name
|
||||
}
|
||||
|
||||
for _, f := range syncResp.Folders {
|
||||
if f.ID == "" {
|
||||
continue
|
||||
}
|
||||
name := decryptVaultLabel(f.Name, "folder", f.ID, userKey)
|
||||
if name == "" {
|
||||
logger.Debug.Printf("Empty folder name for folder %s", f.ID)
|
||||
continue
|
||||
}
|
||||
out.Folders[f.ID] = name
|
||||
}
|
||||
|
||||
for _, col := range syncResp.Collections {
|
||||
if col.ID == "" || col.OrganizationID == "" {
|
||||
continue
|
||||
}
|
||||
orgKey, ok := orgKeys[col.OrganizationID]
|
||||
if !ok {
|
||||
logger.Debug.Printf("No org key for collection %s (org %s), skipping name decrypt", col.ID, col.OrganizationID)
|
||||
continue
|
||||
}
|
||||
name := decryptVaultLabel(col.Name, "collection", col.ID, orgKey)
|
||||
if name == "" {
|
||||
logger.Debug.Printf("Empty collection name for collection %s", col.ID)
|
||||
continue
|
||||
}
|
||||
out.Collections[col.ID] = name
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// LookupIDByName returns an id whose display name equals target (case-insensitive, trimmed).
|
||||
// If several ids share that name, it returns the lexicographically smallest id.
|
||||
func LookupIDByName(idToName map[string]string, target string) (id string, ok bool) {
|
||||
target = strings.TrimSpace(target)
|
||||
if target == "" || len(idToName) == 0 {
|
||||
return "", false
|
||||
}
|
||||
var matches []string
|
||||
for id, n := range idToName {
|
||||
if strings.EqualFold(strings.TrimSpace(n), target) {
|
||||
matches = append(matches, id)
|
||||
}
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
return "", false
|
||||
}
|
||||
sort.Strings(matches)
|
||||
return matches[0], true
|
||||
}
|
||||
|
||||
// Sync fetches and decrypts all vault items and returns them along with maps of decrypted
|
||||
// organization, folder, and collection names.
|
||||
func (ac *APIClient) Sync() ([]DecryptedItem, SyncNameMaps, error) {
|
||||
if err := ac.EnsureValidToken(); err != nil {
|
||||
return nil, fmt.Errorf("ensure valid token: %w", err)
|
||||
return nil, emptySyncNameMaps(), fmt.Errorf("ensure valid token: %w", err)
|
||||
}
|
||||
|
||||
ac.mu.RLock()
|
||||
@@ -277,20 +413,24 @@ func (ac *APIClient) Sync() ([]DecryptedItem, error) {
|
||||
|
||||
req, err := http.NewRequest("GET", ac.baseURL+"/api/sync", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create sync request: %w", err)
|
||||
return nil, emptySyncNameMaps(), fmt.Errorf("create sync request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp, err := ac.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sync request: %w", err)
|
||||
return nil, emptySyncNameMaps(), fmt.Errorf("sync request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
if closeErr := resp.Body.Close(); closeErr != nil {
|
||||
logger.Warn.Printf("close sync 401 response body: %v", closeErr)
|
||||
}
|
||||
|
||||
// Token might be invalid, try to refresh and retry once.
|
||||
if err := ac.RefreshAccessToken(); err != nil {
|
||||
return nil, fmt.Errorf("sync auth failed, refresh failed: %w", err)
|
||||
return nil, emptySyncNameMaps(), fmt.Errorf("sync auth failed, refresh failed: %w", err)
|
||||
}
|
||||
ac.mu.RLock()
|
||||
token = ac.accessToken
|
||||
@@ -299,19 +439,23 @@ func (ac *APIClient) Sync() ([]DecryptedItem, error) {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err = ac.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sync retry: %w", err)
|
||||
return nil, emptySyncNameMaps(), fmt.Errorf("sync retry: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
defer func() {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
logger.Warn.Printf("close sync response body: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("sync failed (HTTP %d): %s", resp.StatusCode, string(body))
|
||||
return nil, emptySyncNameMaps(), fmt.Errorf("sync failed (HTTP %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var syncResp SyncResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&syncResp); err != nil {
|
||||
return nil, fmt.Errorf("decode sync response: %w", err)
|
||||
return nil, emptySyncNameMaps(), fmt.Errorf("decode sync response: %w", err)
|
||||
}
|
||||
|
||||
// Decrypt org keys if organizations are present.
|
||||
@@ -357,19 +501,32 @@ func (ac *APIClient) Sync() ([]DecryptedItem, error) {
|
||||
}
|
||||
|
||||
logger.Info.Printf("Synced and decrypted %d vault items", len(items))
|
||||
return items, nil
|
||||
|
||||
nameMaps := buildSyncNameMaps(syncResp, key, orgKeys)
|
||||
|
||||
logger.Info.Printf(
|
||||
"Synced %d organizations, %d folders, and %d collections",
|
||||
len(syncResp.Profile.Organizations),
|
||||
len(syncResp.Folders),
|
||||
len(syncResp.Collections),
|
||||
)
|
||||
|
||||
return items, nameMaps, nil
|
||||
}
|
||||
|
||||
// DecryptedItem is a decrypted vault item ready for cache lookup.
|
||||
type DecryptedItem struct {
|
||||
ID string
|
||||
Type int
|
||||
Name string
|
||||
Username string
|
||||
Password string
|
||||
Notes string
|
||||
URI string
|
||||
Fields map[string]string
|
||||
ID string
|
||||
Type int
|
||||
Name string
|
||||
Username string
|
||||
Password string
|
||||
Notes string
|
||||
URI string
|
||||
Fields map[string]string
|
||||
OrganizationID string
|
||||
CollectionIDs []string
|
||||
FolderID string
|
||||
}
|
||||
|
||||
// decryptCipher decrypts a single vault cipher into a DecryptedItem.
|
||||
@@ -418,6 +575,16 @@ func decryptCipher(c SyncCipher, key SymmetricKey) (DecryptedItem, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if c.OrganizationID != nil {
|
||||
item.OrganizationID = strings.TrimSpace(*c.OrganizationID)
|
||||
}
|
||||
if len(c.CollectionIDs) > 0 {
|
||||
item.CollectionIDs = append([]string(nil), c.CollectionIDs...)
|
||||
}
|
||||
if c.FolderID != nil {
|
||||
item.FolderID = strings.TrimSpace(*c.FolderID)
|
||||
}
|
||||
|
||||
return item, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
package vaultwarden
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const (
|
||||
testOrgID = "11111111-1111-4111-8111-111111111111"
|
||||
testOrgID2 = "22222222-2222-4222-8222-222222222222"
|
||||
testFolderID = "33333333-3333-4333-8333-333333333333"
|
||||
testCollectionID = "44444444-4444-4444-8444-444444444444"
|
||||
)
|
||||
|
||||
// testUserKey generates a test symmetric key for the user.
|
||||
func testUserKey() SymmetricKey {
|
||||
encKey := make([]byte, 32)
|
||||
macKey := make([]byte, 32)
|
||||
for i := range encKey {
|
||||
encKey[i] = byte(i)
|
||||
}
|
||||
for i := range macKey {
|
||||
macKey[i] = byte(i + 32)
|
||||
}
|
||||
return SymmetricKey{EncKey: encKey, MacKey: macKey}
|
||||
}
|
||||
|
||||
// testOrgKey generates a test symmetric key for the organization.
|
||||
func testOrgKey() SymmetricKey {
|
||||
encKey := make([]byte, 32)
|
||||
macKey := make([]byte, 32)
|
||||
for i := range encKey {
|
||||
encKey[i] = byte(i + 64)
|
||||
}
|
||||
for i := range macKey {
|
||||
macKey[i] = byte(i + 96)
|
||||
}
|
||||
return SymmetricKey{EncKey: encKey, MacKey: macKey}
|
||||
}
|
||||
|
||||
// encryptType2Cipher builds a Bitwarden type-2 cipher string for unit tests.
|
||||
func encryptType2Cipher(plaintext string, key SymmetricKey) (string, error) {
|
||||
data := []byte(plaintext)
|
||||
padLen := aes.BlockSize - (len(data) % aes.BlockSize)
|
||||
padded := make([]byte, len(data)+padLen)
|
||||
copy(padded, data)
|
||||
for i := len(data); i < len(padded); i++ {
|
||||
padded[i] = byte(padLen)
|
||||
}
|
||||
|
||||
iv := make([]byte, 16)
|
||||
for i := range iv {
|
||||
iv[i] = byte(i + 7)
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key.EncKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ct := make([]byte, len(padded))
|
||||
cipher.NewCBCEncrypter(block, iv).CryptBlocks(ct, padded)
|
||||
|
||||
mac := hmac.New(sha256.New, key.MacKey)
|
||||
mac.Write(iv)
|
||||
mac.Write(ct)
|
||||
macBytes := mac.Sum(nil)
|
||||
|
||||
return fmt.Sprintf("2.%s|%s|%s",
|
||||
base64.StdEncoding.EncodeToString(iv),
|
||||
base64.StdEncoding.EncodeToString(ct),
|
||||
base64.StdEncoding.EncodeToString(macBytes),
|
||||
), nil
|
||||
}
|
||||
|
||||
// mustEncryptType2Cipher encrypts a plaintext string using the given symmetric key and returns the encrypted string.
|
||||
func mustEncryptType2Cipher(t *testing.T, plaintext string, key SymmetricKey) string {
|
||||
t.Helper()
|
||||
s, err := encryptType2Cipher(plaintext, key)
|
||||
if err != nil {
|
||||
t.Fatalf("encryptType2Cipher: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestEmptySyncNameMaps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Test that the function generates empty maps
|
||||
m := emptySyncNameMaps()
|
||||
if m.Organizations == nil || m.Folders == nil || m.Collections == nil {
|
||||
t.Fatal("expected non-nil maps")
|
||||
}
|
||||
if len(m.Organizations)+len(m.Folders)+len(m.Collections) != 0 {
|
||||
t.Fatal("expected empty maps")
|
||||
}
|
||||
|
||||
// Test that the function generates a new object each time it is called
|
||||
m.Organizations["x"] = "y"
|
||||
m2 := emptySyncNameMaps()
|
||||
if _, ok := m2.Organizations["x"]; ok {
|
||||
t.Fatal("maps should not be shared between calls")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptVaultLabel(t *testing.T) {
|
||||
userKey := testUserKey()
|
||||
wrongKey := testOrgKey()
|
||||
encrypted := mustEncryptType2Cipher(t, "Secret Org", userKey)
|
||||
encryptedNope := mustEncryptType2Cipher(t, "Nope", userKey)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
keys []SymmetricKey
|
||||
want string
|
||||
}{
|
||||
// Check that empty input returns empty string
|
||||
{"empty", "", nil, ""},
|
||||
// Check that plaintext is passed through as-is
|
||||
{"plaintext passthrough", "Plain Org", []SymmetricKey{userKey}, "Plain Org"},
|
||||
// Check that encrypted text is decrypted with the given keys
|
||||
{"encrypted with user key", encrypted, []SymmetricKey{userKey}, "Secret Org"},
|
||||
{"ecnrypted try multiple", encrypted, []SymmetricKey{wrongKey, userKey}, "Secret Org"},
|
||||
// Check that wrong key on ciphertext returns empty string
|
||||
{"wrong key on ciphertext", encryptedNope, []SymmetricKey{wrongKey}, ""},
|
||||
// Check that empty keys only returns empty string
|
||||
{"empty keys only", encrypted, []SymmetricKey{{}, {}}, ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := decryptVaultLabel(tt.raw, "organization", testOrgID, tt.keys...)
|
||||
if got != tt.want {
|
||||
t.Errorf("decryptVaultLabel() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSyncNameMaps(t *testing.T) {
|
||||
userKey := testUserKey()
|
||||
orgKey := testOrgKey()
|
||||
orgKeys := map[string]SymmetricKey{testOrgID: orgKey}
|
||||
|
||||
// Create fake sync response containing test orgs, folders, and collections
|
||||
syncResp := SyncResponse{
|
||||
Profile: SyncProfile{
|
||||
Organizations: []SyncOrganization{
|
||||
{ID: testOrgID, Name: "Plain Org"},
|
||||
{ID: testOrgID2, Name: mustEncryptType2Cipher(t, "Encrypted Org", userKey)},
|
||||
},
|
||||
},
|
||||
Folders: []SyncFolder{
|
||||
{ID: testFolderID, Name: mustEncryptType2Cipher(t, "Work", userKey)},
|
||||
{ID: "00000000-0000-4000-8000-000000000099", Name: mustEncryptType2Cipher(t, "Bad", testOrgKey())},
|
||||
},
|
||||
Collections: []SyncCollection{
|
||||
{ID: testCollectionID, OrganizationID: testOrgID, Name: mustEncryptType2Cipher(t, "Shared", orgKey)},
|
||||
{ID: "55555555-5555-4555-8555-555555555555", OrganizationID: "99999999-9999-4999-8999-999999999999", Name: "orphan"},
|
||||
{ID: "", OrganizationID: testOrgID, Name: "skip-empty-id"},
|
||||
},
|
||||
}
|
||||
|
||||
maps := buildSyncNameMaps(syncResp, userKey, orgKeys)
|
||||
|
||||
// Test that the function decrypts the organization names correctly
|
||||
if got := maps.Organizations[testOrgID]; got != "Plain Org" {
|
||||
t.Errorf("plain org name = %q, want Plain Org", got)
|
||||
}
|
||||
if got := maps.Organizations[testOrgID2]; got != "Encrypted Org" {
|
||||
t.Errorf("encrypted org name = %q, want Encrypted Org", got)
|
||||
}
|
||||
// Test that the function decrypts the folder names correctly
|
||||
if got := maps.Folders[testFolderID]; got != "Work" {
|
||||
t.Errorf("folder name = %q, want Work", got)
|
||||
}
|
||||
// Test that the function does not include folders with wrong decrypt key
|
||||
if _, ok := maps.Folders["00000000-0000-4000-8000-000000000099"]; ok {
|
||||
t.Error("folder with wrong decrypt key should be omitted")
|
||||
}
|
||||
// Test that the function decrypts the collection names correctly
|
||||
if got := maps.Collections[testCollectionID]; got != "Shared" {
|
||||
t.Errorf("collection name = %q, want Shared", got)
|
||||
}
|
||||
// Test that the function does not include collections with empty organization ID
|
||||
if len(maps.Collections) != 1 {
|
||||
t.Errorf("collections map len = %d, want 1", len(maps.Collections))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupIDByName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The map to run tests on
|
||||
idToName := map[string]string{
|
||||
"bbb-id": "Acme",
|
||||
"aaa-id": "Acme",
|
||||
"ccc-id": "Other",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
target string
|
||||
wantID string
|
||||
wantOK bool
|
||||
}{
|
||||
{"exact match", "Acme", "aaa-id", true},
|
||||
{"case insensitive and trimmed", " acme ", "aaa-id", true},
|
||||
{"unknown", "Missing", "", false},
|
||||
{"empty target", "", "", false},
|
||||
{"other name", "other", "ccc-id", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
id, ok := LookupIDByName(idToName, tt.target)
|
||||
if ok != tt.wantOK || id != tt.wantID {
|
||||
t.Errorf("LookupIDByName(%q) = (%q, %v), want (%q, %v)", tt.target, id, ok, tt.wantID, tt.wantOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Test that empty map returns false
|
||||
t.Run("empty map", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ok := LookupIDByName(nil, "Acme")
|
||||
if ok {
|
||||
t.Error("expected false for empty map")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -4,6 +4,7 @@ package vaultwarden
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -18,22 +19,41 @@ type Client struct {
|
||||
syncEvery time.Duration
|
||||
|
||||
mu sync.RWMutex
|
||||
items map[string]DecryptedItem // keyed by lowercase name
|
||||
byID map[string]DecryptedItem // keyed by ID
|
||||
items map[string]DecryptedItem // keyed by cipher id
|
||||
|
||||
// nameMaps from the last successful sync (for resolving filter names to UUIDs).
|
||||
nameMaps SyncNameMaps
|
||||
|
||||
stopSync chan struct{}
|
||||
}
|
||||
|
||||
// NewClient creates a new vault client backed by the native API client.
|
||||
func NewClient(api *APIClient, cacheTTL, syncInterval time.Duration) *Client {
|
||||
return &Client{
|
||||
// ClientOption configures NewClient.
|
||||
type ClientOption func(*Client)
|
||||
|
||||
// WithState preloads decrypted items and name maps (e.g. unit tests with api set to nil).
|
||||
func WithState(items map[string]DecryptedItem, nameMaps SyncNameMaps) ClientOption {
|
||||
return func(c *Client) {
|
||||
if items != nil {
|
||||
c.items = items
|
||||
}
|
||||
c.nameMaps = nameMaps
|
||||
}
|
||||
}
|
||||
|
||||
// NewClient creates a vault client. Pass WithState to preload cache data without calling Initialize.
|
||||
func NewClient(api *APIClient, cacheTTL, syncInterval time.Duration, opts ...ClientOption) *Client {
|
||||
c := &Client{
|
||||
api: api,
|
||||
cacheTTL: cacheTTL,
|
||||
syncEvery: syncInterval,
|
||||
items: make(map[string]DecryptedItem),
|
||||
byID: make(map[string]DecryptedItem),
|
||||
nameMaps: emptySyncNameMaps(),
|
||||
stopSync: make(chan struct{}),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Initialize authenticates and performs the initial vault sync.
|
||||
@@ -52,9 +72,39 @@ func (c *Client) Initialize() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SecretFilter limits lookup by vault placement. Empty fields are ignored (no constraint).
|
||||
// Use at most one of organization id vs name, etc., enforced at the HTTP layer.
|
||||
type SecretFilter struct {
|
||||
OrganizationID string
|
||||
CollectionID string
|
||||
FolderID string
|
||||
}
|
||||
|
||||
func matchesSecretFilter(item DecryptedItem, f SecretFilter) bool {
|
||||
if f.OrganizationID != "" && !strings.EqualFold(item.OrganizationID, f.OrganizationID) {
|
||||
return false
|
||||
}
|
||||
if f.CollectionID != "" {
|
||||
found := false
|
||||
for _, id := range item.CollectionIDs {
|
||||
if strings.EqualFold(id, f.CollectionID) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if f.FolderID != "" && !strings.EqualFold(item.FolderID, f.FolderID) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// GetSecret retrieves a decrypted secret by name.
|
||||
// It searches by exact name (case-insensitive), then falls back to partial match.
|
||||
func (c *Client) GetSecret(name string) (string, error) {
|
||||
func (c *Client) GetSecret(name string, filter SecretFilter) (string, error) {
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("secret name cannot be empty")
|
||||
}
|
||||
@@ -64,13 +114,21 @@ func (c *Client) GetSecret(name string) (string, error) {
|
||||
|
||||
key := strings.ToLower(name)
|
||||
|
||||
// Exact match.
|
||||
if item, ok := c.items[key]; ok {
|
||||
return extractSecret(item), nil
|
||||
candidates := make([]DecryptedItem, 0, len(c.items))
|
||||
for _, item := range c.items {
|
||||
if matchesSecretFilter(item, filter) {
|
||||
candidates = append(candidates, item)
|
||||
}
|
||||
}
|
||||
|
||||
// Partial match (search).
|
||||
for _, item := range c.items {
|
||||
// Case 1: Exact match.
|
||||
for _, item := range candidates {
|
||||
if strings.EqualFold(item.Name, name) {
|
||||
return extractSecret(item), nil
|
||||
}
|
||||
}
|
||||
// Case 2: Partial match
|
||||
for _, item := range candidates {
|
||||
if strings.Contains(strings.ToLower(item.Name), key) {
|
||||
logger.Debug.Printf("Partial match found for secret lookup")
|
||||
return extractSecret(item), nil
|
||||
@@ -92,25 +150,36 @@ func (c *Client) Stop() {
|
||||
close(c.stopSync)
|
||||
}
|
||||
|
||||
// NameMaps returns a copy of decrypted organization, folder, and collection names
|
||||
// from the last successful vault sync.
|
||||
func (c *Client) NameMaps() SyncNameMaps {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return SyncNameMaps{
|
||||
Organizations: maps.Clone(c.nameMaps.Organizations),
|
||||
Folders: maps.Clone(c.nameMaps.Folders),
|
||||
Collections: maps.Clone(c.nameMaps.Collections),
|
||||
}
|
||||
}
|
||||
|
||||
// syncVault fetches and decrypts all items from the vault.
|
||||
func (c *Client) syncVault() error {
|
||||
items, err := c.api.Sync()
|
||||
items, nameMaps, err := c.api.Sync()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newItems := make(map[string]DecryptedItem, len(items))
|
||||
newByID := make(map[string]DecryptedItem, len(items))
|
||||
|
||||
for _, item := range items {
|
||||
key := strings.ToLower(item.Name)
|
||||
newItems[key] = item
|
||||
newByID[item.ID] = item
|
||||
if item.ID == "" {
|
||||
continue
|
||||
}
|
||||
newItems[item.ID] = item
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.items = newItems
|
||||
c.byID = newByID
|
||||
c.nameMaps = nameMaps
|
||||
c.mu.Unlock()
|
||||
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package vaultwarden
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewClient_withState(t *testing.T) {
|
||||
items := map[string]DecryptedItem{
|
||||
"c1": {ID: "c1", Name: "db-password", Password: "pw"},
|
||||
}
|
||||
nameMaps := SyncNameMaps{
|
||||
Organizations: map[string]string{testOrgID: "Acme"},
|
||||
Folders: map[string]string{},
|
||||
Collections: map[string]string{},
|
||||
}
|
||||
|
||||
c := NewClient(nil, 0, 0, WithState(items, nameMaps))
|
||||
|
||||
val, err := c.GetSecret("db-password", SecretFilter{})
|
||||
if err != nil || val != "pw" {
|
||||
t.Fatalf("GetSecret() = (%q, %v), want (pw, nil)", val, err)
|
||||
}
|
||||
if got := c.NameMaps().Organizations[testOrgID]; got != "Acme" {
|
||||
t.Errorf("NameMaps org = %q, want Acme", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchesSecretFilter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
base := DecryptedItem{
|
||||
ID: "item-1",
|
||||
OrganizationID: testOrgID,
|
||||
CollectionIDs: []string{testCollectionID, "66666666-6666-4666-8666-666666666666"},
|
||||
FolderID: testFolderID,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
filter SecretFilter
|
||||
want bool
|
||||
}{
|
||||
{"empty filter", SecretFilter{}, true},
|
||||
{"org match", SecretFilter{OrganizationID: testOrgID}, true},
|
||||
{"org case insensitive", SecretFilter{OrganizationID: strings.ToUpper(testOrgID)}, true},
|
||||
{"org mismatch", SecretFilter{OrganizationID: testOrgID2}, false},
|
||||
{"collection match", SecretFilter{CollectionID: testCollectionID}, true},
|
||||
{"collection case insensitive", SecretFilter{CollectionID: strings.ToUpper(testCollectionID)}, true},
|
||||
{"collection missing", SecretFilter{CollectionID: "77777777-7777-4777-8777-777777777777"}, false},
|
||||
{"folder match", SecretFilter{FolderID: testFolderID}, true},
|
||||
{"folder mismatch", SecretFilter{FolderID: "88888888-8888-4888-8888-888888888888"}, false},
|
||||
{
|
||||
"all dimensions match",
|
||||
SecretFilter{
|
||||
OrganizationID: testOrgID,
|
||||
CollectionID: testCollectionID,
|
||||
FolderID: testFolderID,
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"org ok collection fail",
|
||||
SecretFilter{OrganizationID: testOrgID, CollectionID: "77777777-7777-4777-8777-777777777777"},
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := matchesSecretFilter(base, tt.filter); got != tt.want {
|
||||
t.Errorf("matchesSecretFilter() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user