diff --git a/.env.example b/.env.example index 208aacb..d571581 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,16 @@ ALLOWED_IPS= # Updates every 24 hours automatically ENABLE_GITHUB_IP_RANGES=false +# Trusted Proxy Configuration +# Specify the IP of your reverse proxy (Nginx Proxy Manager, Traefik, etc.) +# Default: only localhost (127.0.0.1, ::1) +# Note: The actual client IP is extracted from X-Forwarded-For header +# Examples: +# Docker networks: TRUSTED_PROXY_IP=172.16.0.0/12 +# Single proxy: TRUSTED_PROXY_IP=172.18.0.5 +# Multiple proxies: TRUSTED_PROXY_IP=172.18.0.5,192.168.1.10 +TRUSTED_PROXY_IP= + # CORS Configuration # IMPORTANT: Update this for production deployments CORS_ALLOWED_ORIGINS=http://localhost:3000 diff --git a/cmd/api/main.go b/cmd/api/main.go index b077764..bd00ff2 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "os/signal" + "strings" "syscall" "time" @@ -79,8 +80,9 @@ func main() { ErrorHandler: customErrorHandler(cfg.IsProd()), // Enable trusted proxy check to prevent IP spoofing EnableTrustedProxyCheck: true, - // Trust proxy headers from these IPs (Nginx Proxy Manager, Docker network) - TrustedProxies: []string{"127.0.0.1", "::1", "172.16.0.0/12", "10.0.0.0/8", "192.168.0.0/16"}, + // IMPORTANT: Only trust your actual reverse proxy IP + // Default to localhost - update TRUSTED_PROXY_IP env var with your Nginx Proxy Manager IP + TrustedProxies: getTrustedProxies(), // Use X-Forwarded-For header to get real client IP ProxyHeader: fiber.HeaderXForwardedFor, }) @@ -124,6 +126,12 @@ func main() { <-sigChan logger.Info.Println("Shutting down gracefully...") + + // Stop IP update goroutine if running + if stopIPUpdate != nil { + stopIPUpdate() + } + if err := app.Shutdown(); err != nil { logger.Error.Printf("Error during shutdown: %v", err) } @@ -132,10 +140,38 @@ func main() { // Start server addr := fmt.Sprintf(":%s", cfg.Port) if err := app.Listen(addr); err != nil { - logger.Error.Fatalf("Failed to start server: %v", err) + // Clean up before exit + if stopIPUpdate != nil { + stopIPUpdate() + } + logger.Error.Printf("Failed to start server: %v", err) + os.Exit(1) } } +// getTrustedProxies returns the list of trusted proxy IPs +func getTrustedProxies() []string { + // Default to localhost only (most secure) + defaultProxies := []string{"127.0.0.1", "::1"} + + // Allow configuration via environment variable + if proxyIP := os.Getenv("TRUSTED_PROXY_IP"); proxyIP != "" { + proxies := strings.Split(proxyIP, ",") + result := make([]string, 0, len(defaultProxies)+len(proxies)) + result = append(result, defaultProxies...) + + for _, ip := range proxies { + trimmed := strings.TrimSpace(ip) + if trimmed != "" { + result = append(result, trimmed) + } + } + return result + } + + return defaultProxies +} + // customErrorHandler creates a custom error handler func customErrorHandler(isProd bool) fiber.ErrorHandler { return func(c *fiber.Ctx, err error) error { diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go index f0681c4..945dff5 100644 --- a/internal/auth/middleware.go +++ b/internal/auth/middleware.go @@ -1,3 +1,4 @@ +// Package auth provides API key authentication middleware package auth import ( diff --git a/internal/config/config.go b/internal/config/config.go index c83cef8..d822236 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,3 +1,4 @@ +// Package config handles application configuration and validation package config import ( @@ -18,15 +19,15 @@ type Config struct { WriteTimeout time.Duration // Security - APIKey string - AllowedIPs []string - EnableGitHubIPRanges bool + APIKey string + AllowedIPs []string + EnableGitHubIPRanges bool - VaultwardenURL string - VaultwardenToken string - VaultwardenClientID string - VaultwardenSecret string + // Vaultwarden + VaultwardenURL string + VaultwardenToken string + // Performance CacheTTL time.Duration CORSAllowedOrigins string } @@ -38,10 +39,8 @@ func Load() (*Config, error) { Environment: getEnv("ENVIRONMENT", "development"), APIKey: os.Getenv("API_KEY"), - VaultwardenURL: os.Getenv("VAULTWARDEN_URL"), - VaultwardenToken: os.Getenv("VAULTWARDEN_ACCESS_TOKEN"), - VaultwardenClientID: os.Getenv("VAULTWARDEN_CLIENT_ID"), - VaultwardenSecret: os.Getenv("VAULTWARDEN_CLIENT_SECRET"), + VaultwardenURL: os.Getenv("VAULTWARDEN_URL"), + VaultwardenToken: os.Getenv("VAULTWARDEN_ACCESS_TOKEN"), ReadTimeout: parseDuration(getEnv("READ_TIMEOUT", "10s")), WriteTimeout: parseDuration(getEnv("WRITE_TIMEOUT", "10s")), @@ -79,7 +78,7 @@ func Load() (*Config, error) { // Validate and normalize URL parsedURL, err := url.Parse(cfg.VaultwardenURL) if err != nil { - return nil, fmt.Errorf("VAULTWARDEN_URL is invalid: %w", err) + return nil, fmt.Errorf("invalid VAULTWARDEN_URL: %w", err) } if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { return nil, fmt.Errorf("VAULTWARDEN_URL must use http or https scheme") diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index f0d239b..0cc1fee 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -1,3 +1,4 @@ +// Package handlers provides HTTP request handlers for the API package handlers import ( diff --git a/internal/ipwhitelist/ipwhitelist.go b/internal/ipwhitelist/ipwhitelist.go index 2c41cf8..a9a3b76 100644 --- a/internal/ipwhitelist/ipwhitelist.go +++ b/internal/ipwhitelist/ipwhitelist.go @@ -1,3 +1,4 @@ +// Package ipwhitelist provides IP-based access control with GitHub Actions support package ipwhitelist import ( @@ -85,12 +86,20 @@ func (wl *IPWhitelist) Middleware() fiber.Handler { return c.Next() } + // Get client IP from Fiber clientIP := c.IP() - // Handle comma-separated IPs from X-Forwarded-For header - // Take the first IP (original client IP before proxies) - ips := strings.Split(clientIP, ",") - realClientIP := strings.TrimSpace(ips[0]) + // If c.IP() returns comma-separated IPs, parse them manually + // This happens when Fiber doesn't parse X-Forwarded-For despite TrustedProxies config + // We take the FIRST IP (leftmost = original client, rightmost = trusted proxy) + var realClientIP string + if strings.Contains(clientIP, ",") { + ips := strings.Split(clientIP, ",") + // Take first IP (original client before any proxies) + realClientIP = strings.TrimSpace(ips[0]) + } else { + realClientIP = clientIP + } if wl.IsAllowed(realClientIP) { logger.Debug.Printf("IP allowed: %s", realClientIP) diff --git a/internal/validators/validators.go b/internal/validators/validators.go index c99ae7e..39c8909 100644 --- a/internal/validators/validators.go +++ b/internal/validators/validators.go @@ -1,3 +1,4 @@ +// Package validators provides input validation for secret names package validators import ( diff --git a/internal/vaultwarden/auth.go b/internal/vaultwarden/auth.go deleted file mode 100644 index 3ac04f5..0000000 --- a/internal/vaultwarden/auth.go +++ /dev/null @@ -1,141 +0,0 @@ -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) - } - - am.accessToken = tokenResp.AccessToken - - expiryBuffer := 300 - if tokenResp.ExpiresIn < expiryBuffer { - expiryBuffer = tokenResp.ExpiresIn / 2 - } - am.tokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn-expiryBuffer) * 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 e725eac..d5179f5 100644 --- a/internal/vaultwarden/client.go +++ b/internal/vaultwarden/client.go @@ -1,23 +1,19 @@ +// Package vaultwarden provides a client for interacting with Vaultwarden via CLI package vaultwarden import ( - "context" - "encoding/json" "fmt" - "net/http" "sync" "time" "github.com/thijsherman/vaultwarden-api/pkg/logger" ) -// Client handles communication with Vaultwarden API +// Client handles communication with Vaultwarden via CLI type Client struct { - baseURL string - token string // Legacy: session token for CLI fallback - authManager *AuthManager - httpClient *http.Client - cache *secretCache + baseURL string + token string // Session token from CLI authentication + cache *secretCache } // secretCache provides a simple in-memory cache with TTL @@ -33,32 +29,7 @@ type cacheItem struct { expiresAt time.Time } -// CipherResponse represents a Bitwarden/Vaultwarden cipher (item) -type CipherResponse struct { - Data []struct { - ID string `json:"id"` - Type int `json:"type"` // 1 = Login, 2 = Note, 3 = Card, 4 = Identity - Name string `json:"name"` - Login *struct { - Username string `json:"username"` - Password string `json:"password"` - URIs []struct { - URI string `json:"uri"` - } `json:"uris"` - } `json:"login,omitempty"` - Notes string `json:"notes,omitempty"` - Fields []struct { - Name string `json:"name"` - Value string `json:"value"` - Type int `json:"type"` // 0 = Text, 1 = Hidden, 2 = Boolean - } `json:"fields,omitempty"` - } `json:"data"` -} - -// 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) +// NewClient creates a new Vaultwarden client with CLI session token func NewClient(baseURL, token string, cacheTTL time.Duration) *Client { cache := &secretCache{ items: make(map[string]*cacheItem), @@ -73,34 +44,7 @@ func NewClient(baseURL, token string, cacheTTL time.Duration) *Client { return &Client{ baseURL: baseURL, token: token, - httpClient: &http.Client{ - Timeout: 30 * time.Second, - }, - cache: cache, - } -} - -// 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, + cache: cache, } } @@ -135,119 +79,9 @@ func (c *Client) GetSecret(name string) (string, error) { return value, nil } -// fetchSecret queries the Vaultwarden API for a secret +// fetchSecret queries the Vaultwarden for a secret via CLI func (c *Client) fetchSecret(name string) (string, error) { - // Only try CLI if using session token (not client credentials) - if c.authManager == nil { - if value, err := c.FetchSecretViaCLI(name); err == nil { - return value, nil - } else { - logger.Warn.Printf("CLI method failed, trying API: %v", err) - } - } - - // Use API method - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - url := fmt.Sprintf("%s/api/ciphers", c.baseURL) - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - return "", fmt.Errorf("failed to create request: %w", err) - } - - // Set authentication header - 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) - if err != nil { - return "", fmt.Errorf("failed to execute request: %w", err) - } - defer resp.Body.Close() - - // Check response status - if resp.StatusCode != http.StatusOK { - // SECURITY: Do NOT log response body - may contain sensitive data - logger.Error.Printf("Vaultwarden API error (status %d)", resp.StatusCode) - return "", fmt.Errorf("vaultwarden api returned status %d", resp.StatusCode) - } - - // Parse response - var cipherResp CipherResponse - if err := json.NewDecoder(resp.Body).Decode(&cipherResp); err != nil { - return "", fmt.Errorf("failed to decode response: %w", err) - } - - for _, cipher := range cipherResp.Data { - if cipher.Name == name { - return c.extractSecretValue(cipher) - } - } - - return "", fmt.Errorf("secret not found: %s", name) -} - -// extractSecretValue extracts the secret value from a cipher based on its type -func (c *Client) extractSecretValue(cipher struct { - ID string `json:"id"` - Type int `json:"type"` - Name string `json:"name"` - Login *struct { - Username string `json:"username"` - Password string `json:"password"` - URIs []struct { - URI string `json:"uri"` - } `json:"uris"` - } `json:"login,omitempty"` - Notes string `json:"notes,omitempty"` - Fields []struct { - Name string `json:"name"` - Value string `json:"value"` - Type int `json:"type"` - } `json:"fields,omitempty"` -}) (string, error) { - // Type 1 = Login item - if cipher.Type == 1 && cipher.Login != nil { - // Return password by default for login items - if cipher.Login.Password != "" { - return cipher.Login.Password, nil - } - } - - // Type 2 = Secure note - if cipher.Type == 2 && cipher.Notes != "" { - return cipher.Notes, nil - } - - // Check custom fields - for _, field := range cipher.Fields { - if field.Name == "value" || field.Name == "secret" { - return field.Value, nil - } - } - - // If no specific field, return notes if available - if cipher.Notes != "" { - return cipher.Notes, nil - } - - return "", fmt.Errorf("could not extract secret value from cipher") + return c.FetchSecretViaCLI(name) } // ClearCache clears all cached secrets diff --git a/internal/vaultwarden/client_cli.go b/internal/vaultwarden/client_cli.go index dd50060..31e03db 100644 --- a/internal/vaultwarden/client_cli.go +++ b/internal/vaultwarden/client_cli.go @@ -1,3 +1,4 @@ +// Package vaultwarden provides CLI-based secret retrieval package vaultwarden import ( diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index 1e5b7ff..ec97c84 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -1,10 +1,10 @@ +// Package logger provides structured logging for the application package logger import ( "io" "log" "os" - "strings" ) var ( @@ -29,33 +29,3 @@ func init() { Warn = log.New(os.Stdout, "WARN: ", log.Ldate|log.Ltime|log.Lshortfile) Error = log.New(os.Stderr, "ERROR: ", log.Ldate|log.Ltime|log.Lshortfile) } - -// Sanitize removes sensitive data from log messages -// This prevents accidental logging of secrets, tokens, or API keys -func Sanitize(msg string) string { - // List of sensitive keywords to redact - sensitive := []string{"token", "key", "password", "secret", "apikey", "bearer"} - - lower := strings.ToLower(msg) - for _, keyword := range sensitive { - if strings.Contains(lower, keyword) { - return "[REDACTED - Contains sensitive data]" - } - } - return msg -} - -// InfoSafe logs an info message after sanitizing it -func InfoSafe(format string, v ...interface{}) { - Info.Printf(Sanitize(format), v...) -} - -// WarnSafe logs a warning message after sanitizing it -func WarnSafe(format string, v ...interface{}) { - Warn.Printf(Sanitize(format), v...) -} - -// ErrorSafe logs an error message after sanitizing it -func ErrorSafe(format string, v ...interface{}) { - Error.Printf(Sanitize(format), v...) -}