security update

This commit is contained in:
Turbootzz committed 2025-10-24 13:25:05 +02:00
1 parent 5b9f19e237
commit 4131345add
11 files changed
+424 -44

No files matched your search

+21 -14
View File
@@ -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
+8 -2
View File
@@ -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",
+45
View File
@@ -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
}
+138
View File
@@ -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])
}
+58 -5
View File
@@ -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)
+108
View File
@@ -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
}