Files
vw-api/internal/handlers/handlers.go
T
Jethro Young 76b82d4dba 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
2026-05-20 23:26:29 +02:00

207 lines
5.9 KiB
Go

// Package handlers provides HTTP request handlers for the API.
package handlers
import (
"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.
type Handler struct {
vaultClient *vaultwarden.Client
}
// NewHandler creates a new handler instance.
func NewHandler(vaultClient *vaultwarden.Client) *Handler {
return &Handler{
vaultClient: vaultClient,
}
}
// HealthCheck handles GET /health.
func (h *Handler) HealthCheck(c *fiber.Ctx) error {
return c.JSON(fiber.Map{
"status": "ok",
"service": "vaultwarden-api",
})
}
// 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, 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")
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "secret name is required",
})
}
if !validators.IsValidSecretName(secretName) {
logger.Warn.Printf("Invalid secret name format attempted from IP: %s", c.IP())
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "invalid secret name format",
})
}
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{
"error": "secret not found",
})
}
return c.JSON(fiber.Map{
"name": secretName,
"value": value,
})
}
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()
logger.Info.Println("Cache refresh requested")
return c.JSON(fiber.Map{
"status": "ok",
"message": "cache cleared successfully",
})
}