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
@@ -305,6 +305,20 @@ When you request `/secret/DATABASE_URL`, the API:
|
||||
|
||||
This means you can name your Vaultwarden items naturally (e.g., "Database URL") and fetch them with any casing.
|
||||
|
||||
**Colliding names**: By default, the first match will be selected and returned. To help distinguish between matches with the same name, you can split them up into different organizations, collections, or folders to your liking.
|
||||
You can then use either the ID or the name of these groupings as a filter for the request.
|
||||
Examples:
|
||||
- `GET /secret/DATABASE_URL?organization_name=Organization1`
|
||||
- `GET /secret/DATABASE_URL?organization_id=<organization_uuid>`
|
||||
- `GET /secret/DATABASE_URL?collection_name=Project1`
|
||||
- `GET /secret/DATABASE_URL?collection_id=<collection_uuid>`
|
||||
- `GET /secret/DATABASE_URL?folder_name=Deployments`
|
||||
- `GET /secret/DATABASE_URL?folder_id=<folder_uuid>`
|
||||
|
||||
You can combine the filters for even more fine-grained filtering, as such:
|
||||
- `GET /secret/DATABASE_URL?organization_name=Organization1&collection_name=Project1`
|
||||
However, for each dimension (organization | collection | folder) you can only filter on one value. Additionally, specifying the name and the ID are mutually exclusive; you cannot specify both at the same time.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Error | Cause | Fix |
|
||||
|
||||
+16
-14
@@ -9,18 +9,18 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/compress"
|
||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||
"github.com/gofiber/fiber/v2/middleware/helmet"
|
||||
"github.com/gofiber/fiber/v2/middleware/limiter"
|
||||
"github.com/gofiber/fiber/v2/middleware/recover"
|
||||
"github.com/Turbootzz/vaultwarden-api/internal/auth"
|
||||
"github.com/Turbootzz/vaultwarden-api/internal/config"
|
||||
"github.com/Turbootzz/vaultwarden-api/internal/handlers"
|
||||
"github.com/Turbootzz/vaultwarden-api/internal/ipwhitelist"
|
||||
"github.com/Turbootzz/vaultwarden-api/internal/vaultwarden"
|
||||
"github.com/Turbootzz/vaultwarden-api/pkg/logger"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/compress"
|
||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||
"github.com/gofiber/fiber/v2/middleware/helmet"
|
||||
"github.com/gofiber/fiber/v2/middleware/limiter"
|
||||
"github.com/gofiber/fiber/v2/middleware/recover"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -74,15 +74,17 @@ func main() {
|
||||
|
||||
// Create Fiber app with security configurations.
|
||||
app := fiber.New(fiber.Config{
|
||||
AppName: "Vaultwarden API v2.0",
|
||||
DisableStartupMessage: false,
|
||||
ReadTimeout: cfg.ReadTimeout,
|
||||
WriteTimeout: cfg.WriteTimeout,
|
||||
ServerHeader: "",
|
||||
ErrorHandler: customErrorHandler(cfg.IsProd()),
|
||||
AppName: "Vaultwarden API v2.0",
|
||||
DisableStartupMessage: false,
|
||||
ReadTimeout: cfg.ReadTimeout,
|
||||
WriteTimeout: cfg.WriteTimeout,
|
||||
ServerHeader: "",
|
||||
ErrorHandler: customErrorHandler(cfg.IsProd()),
|
||||
EnableTrustedProxyCheck: true,
|
||||
TrustedProxies: getTrustedProxies(),
|
||||
ProxyHeader: fiber.HeaderXForwardedFor,
|
||||
TrustedProxies: getTrustedProxies(),
|
||||
ProxyHeader: fiber.HeaderXForwardedFor,
|
||||
// Avoid empty c.IP() when header is missing (e.g. behind a trusted proxy)
|
||||
EnableIPValidation: true,
|
||||
})
|
||||
|
||||
app.Use(helmet.New())
|
||||
|
||||
@@ -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