From 4131345add7b14ca1432b3826fd7f3c3878b1ef0 Mon Sep 17 00:00:00 2001 From: Turbootzz Date: Fri, 24 Oct 2025 13:25:05 +0200 Subject: [PATCH] security update --- .dockerignore | 5 ++ .env.example | 23 ++--- .gitignore | 4 +- Dockerfile | 7 +- cmd/api/main.go | 30 ++++--- internal/config/config.go | 35 +++++--- internal/handlers/handlers.go | 10 ++- internal/validators/validators.go | 45 ++++++++++ internal/vaultwarden/auth.go | 138 +++++++++++++++++++++++++++++ internal/vaultwarden/client.go | 63 +++++++++++-- internal/vaultwarden/client_cli.go | 108 ++++++++++++++++++++++ 11 files changed, 424 insertions(+), 44 deletions(-) create mode 100644 internal/validators/validators.go create mode 100644 internal/vaultwarden/auth.go create mode 100644 internal/vaultwarden/client_cli.go diff --git a/.dockerignore b/.dockerignore index d546a38..df057ce 100644 --- a/.dockerignore +++ b/.dockerignore @@ -47,3 +47,8 @@ Dockerfile # Make Makefile + +# Documentation +docs/ +CLAUDE.md +settings.local.json \ No newline at end of file diff --git a/.env.example b/.env.example index c027446..1bbcc4e 100644 --- a/.env.example +++ b/.env.example @@ -1,26 +1,29 @@ # Server Configuration API_PORT=8080 -ENVIRONMENT=development +ENVIRONMENT=production # IMPORTANT: Set to 'production' in production! # Security - REQUIRED -# Generate a strong random API key (e.g., using: openssl rand -base64 32) -API_KEY=your-super-secret-api-key-here +API_KEY=CHANGE-THIS-USE-32-CHARS-MINIMUM!! + +# CORS Configuration +CORS_ALLOWED_ORIGINS=http://localhost:3000 # Vaultwarden Configuration - REQUIRED # URL of your Vaultwarden instance (no trailing slash) VAULTWARDEN_URL=https://vault.yourdomain.com -# Vaultwarden API access token -# Get this from: Vaultwarden > Settings > Security > API Key -# Or create via CLI: vaultwarden hash --preset -VAULTWARDEN_ACCESS_TOKEN=your-vaultwarden-access-token +# Authentication Method 1: API Keys (RECOMMENDED - doesn't expire) +VAULTWARDEN_CLIENT_ID=user.xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +VAULTWARDEN_CLIENT_SECRET=your-long-client-secret-here + +# Authentication Method 2: Session Token (LEGACY - expires after some time) +# VAULTWARDEN_ACCESS_TOKEN=your-session-token-here # Performance & Caching # How long to cache secrets in memory (reduces API calls) -# Set to 0 to disable caching +# Set to 0 to disable caching (more secure but slower) CACHE_TTL=5m # Timeouts READ_TIMEOUT=10s -WRITE_TIMEOUT=10s - +WRITE_TIMEOUT=10s \ No newline at end of file diff --git a/.gitignore b/.gitignore index aee97bb..d069697 100644 --- a/.gitignore +++ b/.gitignore @@ -58,4 +58,6 @@ temp/ docker-compose.override.yml # Documentation artifacts -CLAUDE.md \ No newline at end of file +CLAUDE.md +docs/ +settings.local.json \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 5240e2a..8eec826 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,8 +27,11 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ # Final stage - minimal runtime image FROM alpine:3.19 -# Install CA certificates for HTTPS and wget for healthcheck -RUN apk --no-cache add ca-certificates wget +# Install CA certificates for HTTPS, wget for healthcheck, and Bitwarden CLI +RUN apk --no-cache add ca-certificates wget nodejs npm && \ + npm install -g @bitwarden/cli && \ + apk del npm && \ + rm -rf /root/.npm # Create non-root user for security RUN addgroup -g 1000 appuser && \ diff --git a/cmd/api/main.go b/cmd/api/main.go index fad12d5..fdd9d46 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -29,7 +29,14 @@ func main() { logger.Info.Printf("Starting Vaultwarden API on port %s (environment: %s)", cfg.Port, cfg.Environment) // Initialize Vaultwarden client - vaultClient := vaultwarden.NewClient(cfg.VaultwardenURL, cfg.VaultwardenToken, cfg.CacheTTL) + var vaultClient *vaultwarden.Client + if cfg.VaultwardenClientID != "" && cfg.VaultwardenSecret != "" { + logger.Info.Println("Using API key authentication (recommended)") + vaultClient = vaultwarden.NewClientWithAuth(cfg.VaultwardenURL, cfg.VaultwardenClientID, cfg.VaultwardenSecret, cfg.CacheTTL) + } else { + logger.Warn.Println("Using session token authentication (legacy - token will expire!)") + vaultClient = vaultwarden.NewClient(cfg.VaultwardenURL, cfg.VaultwardenToken, cfg.CacheTTL) + } // Initialize handlers h := handlers.NewHandler(vaultClient) @@ -46,23 +53,26 @@ func main() { ErrorHandler: customErrorHandler(cfg.IsProd()), }) - // Security middleware - app.Use(helmet.New()) // Security headers - app.Use(recover.New()) // Recover from panics + app.Use(helmet.New()) + app.Use(recover.New()) app.Use(compress.New(compress.Config{ Level: compress.LevelBestSpeed, })) - // CORS configuration - adjust as needed app.Use(cors.New(cors.Config{ - AllowOrigins: "*", // Restrict this in production to specific domains - AllowMethods: "GET,POST", - AllowHeaders: "Authorization,Content-Type", + AllowOrigins: cfg.CORSAllowedOrigins, + AllowMethods: "GET,POST", + AllowHeaders: "Authorization,Content-Type", + AllowCredentials: false, })) - // Rate limiting to prevent abuse app.Use(limiter.New(limiter.Config{ - Max: 100, // 100 requests per minute per IP + Max: 30, + LimitReached: func(c *fiber.Ctx) error { + return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{ + "error": "too many requests, please slow down", + }) + }, })) // Public routes (no authentication required) diff --git a/internal/config/config.go b/internal/config/config.go index a01f0dd..fa5e4da 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,12 +19,13 @@ type Config struct { // Security APIKey string - // Vaultwarden settings - VaultwardenURL string - VaultwardenToken string + VaultwardenURL string + VaultwardenToken string + VaultwardenClientID string + VaultwardenSecret string - // Cache settings - CacheTTL time.Duration + CacheTTL time.Duration + CORSAllowedOrigins string } // Load reads configuration from environment variables @@ -34,26 +35,32 @@ func Load() (*Config, error) { Environment: getEnv("ENVIRONMENT", "development"), APIKey: os.Getenv("API_KEY"), - VaultwardenURL: os.Getenv("VAULTWARDEN_URL"), - VaultwardenToken: os.Getenv("VAULTWARDEN_ACCESS_TOKEN"), + VaultwardenURL: os.Getenv("VAULTWARDEN_URL"), + VaultwardenToken: os.Getenv("VAULTWARDEN_ACCESS_TOKEN"), + VaultwardenClientID: os.Getenv("VAULTWARDEN_CLIENT_ID"), + VaultwardenSecret: os.Getenv("VAULTWARDEN_CLIENT_SECRET"), - ReadTimeout: parseDuration(getEnv("READ_TIMEOUT", "10s")), - WriteTimeout: parseDuration(getEnv("WRITE_TIMEOUT", "10s")), - CacheTTL: parseDuration(getEnv("CACHE_TTL", "5m")), + ReadTimeout: parseDuration(getEnv("READ_TIMEOUT", "10s")), + WriteTimeout: parseDuration(getEnv("WRITE_TIMEOUT", "10s")), + CacheTTL: parseDuration(getEnv("CACHE_TTL", "5m")), + CORSAllowedOrigins: getEnv("CORS_ALLOWED_ORIGINS", "http://localhost:3000"), } // Validate required fields if cfg.APIKey == "" { return nil, fmt.Errorf("API_KEY is required") } - if len(cfg.APIKey) < 16 { - return nil, fmt.Errorf("API_KEY must be at least 16 characters for security") + if len(cfg.APIKey) < 32 { + return nil, fmt.Errorf("API_KEY must be at least 32 characters for security (run: openssl rand -base64 32)") } if cfg.VaultwardenURL == "" { return nil, fmt.Errorf("VAULTWARDEN_URL is required") } - if cfg.VaultwardenToken == "" { - return nil, fmt.Errorf("VAULTWARDEN_ACCESS_TOKEN is required") + + hasAPIKeys := cfg.VaultwardenClientID != "" && cfg.VaultwardenSecret != "" + hasSessionToken := cfg.VaultwardenToken != "" + if !hasAPIKeys && !hasSessionToken { + return nil, fmt.Errorf("either VAULTWARDEN_CLIENT_ID+VAULTWARDEN_CLIENT_SECRET or VAULTWARDEN_ACCESS_TOKEN is required") } // Validate and normalize URL diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 8f4f73d..f0d239b 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -2,6 +2,7 @@ package handlers import ( "github.com/gofiber/fiber/v2" + "github.com/thijsherman/vaultwarden-api/internal/validators" "github.com/thijsherman/vaultwarden-api/internal/vaultwarden" "github.com/thijsherman/vaultwarden-api/pkg/logger" ) @@ -37,10 +38,15 @@ func (h *Handler) GetSecret(c *fiber.Ctx) error { }) } - // Fetch secret from Vaultwarden + if !validators.IsValidSecretName(secretName) { + logger.Warn.Printf("Invalid secret name attempted: %s", secretName) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "invalid secret name format", + }) + } + value, err := h.vaultClient.GetSecret(secretName) if err != nil { - // Don't expose internal error details to client logger.Error.Printf("Failed to fetch secret '%s': %v", secretName, err) return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ "error": "secret not found", diff --git a/internal/validators/validators.go b/internal/validators/validators.go new file mode 100644 index 0000000..c99ae7e --- /dev/null +++ b/internal/validators/validators.go @@ -0,0 +1,45 @@ +package validators + +import ( + "regexp" + "strings" +) + +const SecretNameMaxLength = 255 + +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 { + return false + } + + if strings.Contains(name, "..") || strings.Contains(name, "\x00") { + return false + } + + for _, ch := range name { + if ch < 32 || ch > 126 { + return false + } + } + + return SecretNamePattern.MatchString(name) +} + +func SanitizeSecretName(name string) (string, bool) { + name = strings.TrimSpace(name) + + cleaned := strings.Map(func(r rune) rune { + if r >= 32 && r <= 126 { + return r + } + return -1 + }, name) + + if IsValidSecretName(cleaned) { + return cleaned, true + } + + return "", false +} \ No newline at end of file diff --git a/internal/vaultwarden/auth.go b/internal/vaultwarden/auth.go new file mode 100644 index 0000000..44fb541 --- /dev/null +++ b/internal/vaultwarden/auth.go @@ -0,0 +1,138 @@ +package vaultwarden + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/thijsherman/vaultwarden-api/pkg/logger" +) + +// AuthManager handles authentication with Vaultwarden +type AuthManager struct { + baseURL string + clientID string + clientSecret string + httpClient *http.Client + + // Token management + mu sync.RWMutex + accessToken string + tokenExpiry time.Time +} + +// TokenResponse represents the OAuth token response from Vaultwarden +type TokenResponse struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + TokenType string `json:"token_type"` + RefreshToken string `json:"refresh_token,omitempty"` +} + +// NewAuthManager creates a new authentication manager +func NewAuthManager(baseURL, clientID, clientSecret string) *AuthManager { + return &AuthManager{ + baseURL: baseURL, + clientID: clientID, + clientSecret: clientSecret, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// GetAccessToken returns a valid access token, refreshing if necessary +func (am *AuthManager) GetAccessToken() (string, error) { + am.mu.RLock() + // Check if we have a valid token + if am.accessToken != "" && time.Now().Before(am.tokenExpiry) { + token := am.accessToken + am.mu.RUnlock() + return token, nil + } + am.mu.RUnlock() + + // Token expired or doesn't exist, get a new one + return am.refreshAccessToken() +} + +// refreshAccessToken obtains a new access token from Vaultwarden +func (am *AuthManager) refreshAccessToken() (string, error) { + am.mu.Lock() + defer am.mu.Unlock() + + // Double-check after acquiring write lock + if am.accessToken != "" && time.Now().Before(am.tokenExpiry) { + return am.accessToken, nil + } + + logger.Info.Println("Obtaining new access token from Vaultwarden...") + + // Prepare the token request + data := url.Values{} + data.Set("grant_type", "client_credentials") + data.Set("scope", "api") + data.Set("client_id", am.clientID) + data.Set("client_secret", am.clientSecret) + + // Generate device identifier (required by Bitwarden protocol) + deviceID := am.generateDeviceID() + data.Set("deviceIdentifier", deviceID) + data.Set("deviceType", "14") // SDK type + data.Set("deviceName", "Vaultwarden-API") + + // Create request + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + tokenURL := fmt.Sprintf("%s/identity/connect/token", am.baseURL) + req, err := http.NewRequestWithContext(ctx, "POST", tokenURL, strings.NewReader(data.Encode())) + if err != nil { + return "", fmt.Errorf("failed to create token request: %w", err) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + // Execute request + resp, err := am.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("failed to execute token request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + // SECURITY: Do not log response body - may contain sensitive information + logger.Error.Printf("Token request failed with status %d", resp.StatusCode) + return "", fmt.Errorf("token request failed with status %d", resp.StatusCode) + } + + // Parse response + var tokenResp TokenResponse + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + return "", fmt.Errorf("failed to decode token response: %w", err) + } + + // Store the token + am.accessToken = tokenResp.AccessToken + // Refresh 5 minutes before expiry to avoid edge cases + am.tokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn-300) * time.Second) + + logger.Info.Printf("Successfully obtained access token (expires in %d seconds)", tokenResp.ExpiresIn) + + return am.accessToken, nil +} + +// generateDeviceID creates a consistent device identifier +func (am *AuthManager) generateDeviceID() string { + // Create a hash of client_id for consistent device ID + hash := sha256.Sum256([]byte(am.clientID)) + return base64.StdEncoding.EncodeToString(hash[:16]) +} diff --git a/internal/vaultwarden/client.go b/internal/vaultwarden/client.go index 0c3b601..05432cf 100644 --- a/internal/vaultwarden/client.go +++ b/internal/vaultwarden/client.go @@ -13,10 +13,11 @@ import ( // Client handles communication with Vaultwarden API type Client struct { - baseURL string - token string - httpClient *http.Client - cache *secretCache + baseURL string + token string // Legacy: session token for CLI fallback + authManager *AuthManager + httpClient *http.Client + cache *secretCache } // secretCache provides a simple in-memory cache with TTL @@ -55,6 +56,9 @@ type CipherResponse struct { } // NewClient creates a new Vaultwarden client +// token can be either: +// - A session token (from bw unlock) for CLI-based access +// - A client_id for API-based access (requires clientSecret via NewClientWithAuth) func NewClient(baseURL, token string, cacheTTL time.Duration) *Client { cache := &secretCache{ items: make(map[string]*cacheItem), @@ -77,6 +81,30 @@ func NewClient(baseURL, token string, cacheTTL time.Duration) *Client { } } +// NewClientWithAuth creates a client with API key authentication (recommended) +// Use this method when you have client_id and client_secret from Vaultwarden +func NewClientWithAuth(baseURL, clientID, clientSecret string, cacheTTL time.Duration) *Client { + cache := &secretCache{ + items: make(map[string]*cacheItem), + ttl: cacheTTL, + enabled: cacheTTL > 0, + } + + // Start cache cleanup goroutine if caching is enabled + if cacheTTL > 0 { + go cache.startCleanup(cacheTTL) + } + + return &Client{ + baseURL: baseURL, + authManager: NewAuthManager(baseURL, clientID, clientSecret), + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + cache: cache, + } +} + // GetSecret retrieves a secret by name from Vaultwarden // It first checks the cache, then queries the API if needed func (c *Client) GetSecret(name string) (string, error) { @@ -110,10 +138,20 @@ func (c *Client) GetSecret(name string) (string, error) { // fetchSecret queries the Vaultwarden API for a secret func (c *Client) fetchSecret(name string) (string, error) { + // First, try using the Bitwarden CLI if available + // This is more reliable for Vaultwarden instances + if value, err := c.FetchSecretViaCLI(name); err == nil { + return value, nil + } else { + logger.Warn.Printf("CLI method failed, trying API: %v", err) + } + + // Fallback to API method // Build the API request with context for timeout ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() + // Using the Bitwarden public API endpoint for list items url := fmt.Sprintf("%s/api/ciphers", c.baseURL) req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { @@ -121,8 +159,23 @@ func (c *Client) fetchSecret(name string) (string, error) { } // Set authentication header - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.token)) + var token string + var tokenErr error + + if c.authManager != nil { + // Use API key authentication (preferred) + token, tokenErr = c.authManager.GetAccessToken() + if tokenErr != nil { + return "", fmt.Errorf("failed to get access token: %w", tokenErr) + } + } else { + // Fallback to session token + token = c.token + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) req.Header.Set("Content-Type", "application/json") + req.Header.Set("Device-Type", "14") // SDK type // Execute request resp, err := c.httpClient.Do(req) diff --git a/internal/vaultwarden/client_cli.go b/internal/vaultwarden/client_cli.go new file mode 100644 index 0000000..ba21bff --- /dev/null +++ b/internal/vaultwarden/client_cli.go @@ -0,0 +1,108 @@ +package vaultwarden + +import ( + "encoding/json" + "fmt" + "os/exec" + "strings" + + "github.com/thijsherman/vaultwarden-api/pkg/logger" +) + +type BitwardenItem struct { + ID string `json:"id"` + Name string `json:"name"` + Type int `json:"type"` + Login *struct { + Username string `json:"username"` + Password string `json:"password"` + } `json:"login,omitempty"` + Notes string `json:"notes,omitempty"` + Fields []struct { + Name string `json:"name"` + Value string `json:"value"` + } `json:"fields,omitempty"` +} + +func (c *Client) FetchSecretViaCLI(name string) (string, error) { + if len(name) == 0 || len(name) > 255 { + return "", fmt.Errorf("invalid secret name length") + } + + for _, ch := range name { + if !((ch >= 'a' && ch <= 'z') || + (ch >= 'A' && ch <= 'Z') || + (ch >= '0' && ch <= '9') || + ch == '-' || ch == '_' || ch == '.' || ch == '/') { + return "", fmt.Errorf("invalid character in secret name") + } + } + + cmd := exec.Command("bw", "get", "item", name, "--session", c.token) + output, err := cmd.Output() + + if err != nil { + logger.Info.Printf("Exact match failed, searching for: %s", name) + + searchCmd := exec.Command("bw", "list", "items", "--search", name, "--session", c.token) + searchOutput, searchErr := searchCmd.Output() + if searchErr != nil { + return "", fmt.Errorf("failed to search for item: %w", searchErr) + } + + var items []BitwardenItem + if err := json.Unmarshal(searchOutput, &items); err != nil { + return "", fmt.Errorf("failed to parse search results: %w", err) + } + + if len(items) == 0 { + return "", fmt.Errorf("secret not found: %s", name) + } + + // Use the first matching item + output, err = json.Marshal(items[0]) + if err != nil { + return "", fmt.Errorf("failed to marshal item: %w", err) + } + } + + var item BitwardenItem + if err := json.Unmarshal(output, &item); err != nil { + return "", fmt.Errorf("failed to parse item: %w", err) + } + + return c.extractValueFromItem(item) +} + +func (c *Client) extractValueFromItem(item BitwardenItem) (string, error) { + if item.Login != nil && item.Login.Password != "" { + return item.Login.Password, nil + } + + for _, field := range item.Fields { + fieldName := strings.ToLower(field.Name) + if fieldName == "value" || fieldName == "secret" || fieldName == "api_key" { + return field.Value, nil + } + } + + if item.Notes != "" { + return item.Notes, nil + } + + if len(item.Fields) > 0 { + return item.Fields[0].Value, nil + } + + return "", fmt.Errorf("no secret value found in item") +} + +func (c *Client) SyncVault() error { + cmd := exec.Command("bw", "sync", "--session", c.token) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to sync vault: %w, output: %s", err, output) + } + logger.Info.Println("Vault synced successfully") + return nil +} \ No newline at end of file