539 lines
17 KiB
Go
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,
|
|
})
|
|
}
|