335 lines
10 KiB
Go
335 lines
10 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",
|
|
})
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
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 != 2 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "only Login (1) and SecureNote (2) are supported for writing"})
|
|
}
|
|
|
|
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,
|
|
})
|
|
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,
|
|
})
|
|
}
|