Files
Hermes 51a71739c9
CI / test (push) Has been cancelled
CI / docker (push) Has been cancelled
feat: add /search?q= endpoint — substring search across all vault fields
2026-08-08 02:03:49 +02:00

539 lines
17 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/auth"
"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",
})
}
// Enforce the authenticated key's scope server-side, regardless of query filters.
if !h.applyKeyScope(c, &filter) {
logger.Warn.Printf("Request denied by key scope from IP: %s", c.IP())
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
"error": "secret not found",
})
}
value, err := h.vaultClient.GetSecret(secretName, filter)
if err != nil {
logger.Error.Printf("Failed to fetch secret (requested by IP: %s)", c.IP())
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
"error": "secret not found",
})
}
return c.JSON(value.ToMap())
}
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
}
// resolveRef resolves a scope reference that may be either a UUID or a friendly name.
// UUIDs pass through (existence not verified); names are looked up via NameMaps.
func resolveRef(nameMap map[string]string, ref string) (string, bool) {
ref = strings.TrimSpace(ref)
if ref == "" {
return "", false
}
if parsed, err := uuid.Parse(ref); err == nil {
return parsed.String(), true
}
return vaultwarden.LookupIDByName(nameMap, ref)
}
// resolveScopeRefs maps scope refs (UUIDs or names) to UUIDs, dropping any that
// don't resolve. The caller treats an all-unresolved dimension as deny (fail closed).
func resolveScopeRefs(refs []string, nameMap map[string]string) []string {
out := make([]string, 0, len(refs))
for _, ref := range refs {
if id, ok := resolveRef(nameMap, ref); ok {
out = append(out, id)
}
}
return out
}
// applyKeyScope sets the server-side scope fields on the filter from the
// authenticated key's scope. It returns false to deny the request (404) when a
// constrained dimension resolves to nothing — including unknown names and the
// pre-first-sync window — so a scoped key can never read outside its scope.
func (h *Handler) applyKeyScope(c *fiber.Ctx, filter *vaultwarden.SecretFilter) bool {
scope, ok := auth.ScopeFromCtx(c)
if !ok {
// No scope in context means the auth middleware did not run for this
// request. Fail closed rather than silently granting full access.
return false
}
if scope.IsEmpty() {
return true // unscoped key: full access
}
nm := h.vaultClient.NameMaps()
if len(scope.Organizations) > 0 {
ids := resolveScopeRefs(scope.Organizations, nm.Organizations)
if len(ids) == 0 {
return false
}
filter.OrganizationIDs = ids
}
if len(scope.Collections) > 0 {
ids := resolveScopeRefs(scope.Collections, nm.Collections)
if len(ids) == 0 {
return false
}
filter.CollectionIDs = ids
}
return true
}
// parseSecretFilters reads placement query params: at most one of id or name per dimension.
// Name-based filters are resolved against h.vaultClient.NameMaps(); unknown names fail.
// Id-based filters are accepted as-is after UUID parsing (existence is not checked here).
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",
})
}
// SearchSecrets handles GET /search?q=<text>.
// Returns every vault item matching the query across ALL fields (name, username,
// uri, notes, password, custom fields). Values are masked in the listing.
// Requires the q parameter; empty query returns an empty list.
func (h *Handler) SearchSecrets(c *fiber.Ctx) error {
q := strings.TrimSpace(c.Query("q"))
if q == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "query parameter 'q' is required",
})
}
results := h.vaultClient.SearchSecrets(q)
return c.JSON(fiber.Map{
"query": q,
"count": len(results),
"results": results,
})
}
// CreateSecret handles POST /secret. Body (JSON):
//
// {
// "type": 1, // 1=Login (default), 2=SecureNote
// "name": "...", // required
// "notes": "...", // secure note body (type 2) or login notes
// "username": "...", // login only
// "password": "...", // login only
// "uri": "...", // login only
// "fields": {"k":"v"} // login custom fields (optional)
// }
func (h *Handler) CreateSecret(c *fiber.Ctx) error {
var body struct {
Type int `json:"type"`
Name string `json:"name"`
Notes string `json:"notes"`
Username string `json:"username"`
Password string `json:"password"`
URI string `json:"uri"`
Fields map[string]string `json:"fields"`
FolderID string `json:"folderId"`
// Card (type 3)
CardholderName string `json:"cardholderName"`
Brand string `json:"brand"`
Number string `json:"number"`
ExpMonth string `json:"expMonth"`
ExpYear string `json:"expYear"`
Code string `json:"code"`
// Identity (type 4)
Title string `json:"title"`
FirstName string `json:"firstName"`
MiddleName string `json:"middleName"`
LastName string `json:"lastName"`
Address1 string `json:"address1"`
Address2 string `json:"address2"`
Address3 string `json:"address3"`
City string `json:"city"`
State string `json:"state"`
PostalCode string `json:"postalCode"`
Country string `json:"country"`
Company string `json:"company"`
Email string `json:"email"`
Phone string `json:"phone"`
SSN string `json:"ssn"`
Passport string `json:"passport"`
License string `json:"license"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid JSON body"})
}
body.Name = strings.TrimSpace(body.Name)
if body.Name == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name is required"})
}
if !validators.IsValidSecretName(body.Name) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid secret name format"})
}
if body.Type == 0 {
body.Type = 1 // default to Login
}
if body.Type < 1 || body.Type > 4 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "supported types: 1=Login, 2=SecureNote, 3=Card, 4=Identity"})
}
id, err := h.vaultClient.CreateSecret(vaultwarden.CreateSecretInput{
Type: body.Type,
Name: body.Name,
Notes: body.Notes,
Username: body.Username,
Password: body.Password,
URI: body.URI,
Fields: body.Fields,
FolderID: body.FolderID,
CardholderName: body.CardholderName,
Brand: body.Brand,
Number: body.Number,
ExpMonth: body.ExpMonth,
ExpYear: body.ExpYear,
Code: body.Code,
Title: body.Title,
FirstName: body.FirstName,
MiddleName: body.MiddleName,
LastName: body.LastName,
Address1: body.Address1,
Address2: body.Address2,
Address3: body.Address3,
City: body.City,
State: body.State,
PostalCode: body.PostalCode,
Country: body.Country,
Company: body.Company,
Email: body.Email,
Phone: body.Phone,
SSN: body.SSN,
Passport: body.Passport,
License: body.License,
})
if err != nil {
logger.Error.Printf("Failed to create secret (requested by IP: %s): %v", c.IP(), err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
"status": "created",
"id": id,
"name": body.Name,
"type": body.Type,
})
}
// UpdateSecret handles PUT /secret/:name. Body (JSON) is the same as CreateSecret.
// It edits the existing item in place (same cipher ID).
func (h *Handler) UpdateSecret(c *fiber.Ctx) error {
name, err := decodeSecretPathParam(c.Params("name"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid secret name format"})
}
if name == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "secret name is required"})
}
var body struct {
Type int `json:"type"`
Name string `json:"name"`
Notes string `json:"notes"`
Username string `json:"username"`
Password string `json:"password"`
URI string `json:"uri"`
Fields map[string]string `json:"fields"`
FolderID string `json:"folderId"`
// Card (type 3)
CardholderName string `json:"cardholderName"`
Brand string `json:"brand"`
Number string `json:"number"`
ExpMonth string `json:"expMonth"`
ExpYear string `json:"expYear"`
Code string `json:"code"`
// Identity (type 4)
Title string `json:"title"`
FirstName string `json:"firstName"`
MiddleName string `json:"middleName"`
LastName string `json:"lastName"`
Address1 string `json:"address1"`
Address2 string `json:"address2"`
Address3 string `json:"address3"`
City string `json:"city"`
State string `json:"state"`
PostalCode string `json:"postalCode"`
Country string `json:"country"`
Company string `json:"company"`
Email string `json:"email"`
Phone string `json:"phone"`
SSN string `json:"ssn"`
Passport string `json:"passport"`
License string `json:"license"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid JSON body"})
}
if body.Type == 0 {
body.Type = 1
}
if body.Type < 1 || body.Type > 4 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "supported types: 1=Login, 2=SecureNote, 3=Card, 4=Identity"})
}
if err := h.vaultClient.UpdateSecret(name, vaultwarden.CreateSecretInput{
Type: body.Type,
Name: body.Name,
Notes: body.Notes,
Username: body.Username,
Password: body.Password,
URI: body.URI,
Fields: body.Fields,
FolderID: body.FolderID,
CardholderName: body.CardholderName,
Brand: body.Brand,
Number: body.Number,
ExpMonth: body.ExpMonth,
ExpYear: body.ExpYear,
Code: body.Code,
Title: body.Title,
FirstName: body.FirstName,
MiddleName: body.MiddleName,
LastName: body.LastName,
Address1: body.Address1,
Address2: body.Address2,
Address3: body.Address3,
City: body.City,
State: body.State,
PostalCode: body.PostalCode,
Country: body.Country,
Company: body.Company,
Email: body.Email,
Phone: body.Phone,
SSN: body.SSN,
Passport: body.Passport,
License: body.License,
}); err != nil {
logger.Error.Printf("Failed to update secret %q: %v", name, err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{
"status": "updated",
"name": name,
"type": body.Type,
})
}
// ListFolders handles GET /folders and returns decrypted folder name -> id map.
func (h *Handler) ListFolders(c *fiber.Ctx) error {
// Client stores folders as id -> name; invert for easy name lookup by clients.
byID := h.vaultClient.ListFolders()
byName := make(map[string]string, len(byID))
for id, name := range byID {
byName[name] = id
}
return c.JSON(byName)
}
// DeleteSecret handles DELETE /secret/:name.
func (h *Handler) DeleteSecret(c *fiber.Ctx) error {
name, err := decodeSecretPathParam(c.Params("name"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid secret name format"})
}
if name == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "secret name is required"})
}
if err := h.vaultClient.DeleteSecret(name); err != nil {
logger.Error.Printf("Failed to delete secret %q: %v", name, err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{
"status": "deleted",
"name": name,
})
}