fixed Fiber IP security and cleanup code
This commit is contained in:
1 parent
8cea6d1f8c
commit
70481d0350
11 files changed
+87
-366
No files matched your search
@@ -20,6 +20,16 @@ ALLOWED_IPS=
|
|||||||
# Updates every 24 hours automatically
|
# Updates every 24 hours automatically
|
||||||
ENABLE_GITHUB_IP_RANGES=false
|
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
|
# CORS Configuration
|
||||||
# IMPORTANT: Update this for production deployments
|
# IMPORTANT: Update this for production deployments
|
||||||
CORS_ALLOWED_ORIGINS=http://localhost:3000
|
CORS_ALLOWED_ORIGINS=http://localhost:3000
|
||||||
|
|||||||
+39
-3
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -79,8 +80,9 @@ func main() {
|
|||||||
ErrorHandler: customErrorHandler(cfg.IsProd()),
|
ErrorHandler: customErrorHandler(cfg.IsProd()),
|
||||||
// Enable trusted proxy check to prevent IP spoofing
|
// Enable trusted proxy check to prevent IP spoofing
|
||||||
EnableTrustedProxyCheck: true,
|
EnableTrustedProxyCheck: true,
|
||||||
// Trust proxy headers from these IPs (Nginx Proxy Manager, Docker network)
|
// IMPORTANT: Only trust your actual reverse proxy IP
|
||||||
TrustedProxies: []string{"127.0.0.1", "::1", "172.16.0.0/12", "10.0.0.0/8", "192.168.0.0/16"},
|
// 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
|
// Use X-Forwarded-For header to get real client IP
|
||||||
ProxyHeader: fiber.HeaderXForwardedFor,
|
ProxyHeader: fiber.HeaderXForwardedFor,
|
||||||
})
|
})
|
||||||
@@ -124,6 +126,12 @@ func main() {
|
|||||||
<-sigChan
|
<-sigChan
|
||||||
|
|
||||||
logger.Info.Println("Shutting down gracefully...")
|
logger.Info.Println("Shutting down gracefully...")
|
||||||
|
|
||||||
|
// Stop IP update goroutine if running
|
||||||
|
if stopIPUpdate != nil {
|
||||||
|
stopIPUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
if err := app.Shutdown(); err != nil {
|
if err := app.Shutdown(); err != nil {
|
||||||
logger.Error.Printf("Error during shutdown: %v", err)
|
logger.Error.Printf("Error during shutdown: %v", err)
|
||||||
}
|
}
|
||||||
@@ -132,10 +140,38 @@ func main() {
|
|||||||
// Start server
|
// Start server
|
||||||
addr := fmt.Sprintf(":%s", cfg.Port)
|
addr := fmt.Sprintf(":%s", cfg.Port)
|
||||||
if err := app.Listen(addr); err != nil {
|
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
|
// customErrorHandler creates a custom error handler
|
||||||
func customErrorHandler(isProd bool) fiber.ErrorHandler {
|
func customErrorHandler(isProd bool) fiber.ErrorHandler {
|
||||||
return func(c *fiber.Ctx, err error) error {
|
return func(c *fiber.Ctx, err error) error {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package auth provides API key authentication middleware
|
||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
+11
-12
@@ -1,3 +1,4 @@
|
|||||||
|
// Package config handles application configuration and validation
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -18,15 +19,15 @@ type Config struct {
|
|||||||
WriteTimeout time.Duration
|
WriteTimeout time.Duration
|
||||||
|
|
||||||
// Security
|
// Security
|
||||||
APIKey string
|
APIKey string
|
||||||
AllowedIPs []string
|
AllowedIPs []string
|
||||||
EnableGitHubIPRanges bool
|
EnableGitHubIPRanges bool
|
||||||
|
|
||||||
VaultwardenURL string
|
// Vaultwarden
|
||||||
VaultwardenToken string
|
VaultwardenURL string
|
||||||
VaultwardenClientID string
|
VaultwardenToken string
|
||||||
VaultwardenSecret string
|
|
||||||
|
|
||||||
|
// Performance
|
||||||
CacheTTL time.Duration
|
CacheTTL time.Duration
|
||||||
CORSAllowedOrigins string
|
CORSAllowedOrigins string
|
||||||
}
|
}
|
||||||
@@ -38,10 +39,8 @@ func Load() (*Config, error) {
|
|||||||
Environment: getEnv("ENVIRONMENT", "development"),
|
Environment: getEnv("ENVIRONMENT", "development"),
|
||||||
APIKey: os.Getenv("API_KEY"),
|
APIKey: os.Getenv("API_KEY"),
|
||||||
|
|
||||||
VaultwardenURL: os.Getenv("VAULTWARDEN_URL"),
|
VaultwardenURL: os.Getenv("VAULTWARDEN_URL"),
|
||||||
VaultwardenToken: os.Getenv("VAULTWARDEN_ACCESS_TOKEN"),
|
VaultwardenToken: os.Getenv("VAULTWARDEN_ACCESS_TOKEN"),
|
||||||
VaultwardenClientID: os.Getenv("VAULTWARDEN_CLIENT_ID"),
|
|
||||||
VaultwardenSecret: os.Getenv("VAULTWARDEN_CLIENT_SECRET"),
|
|
||||||
|
|
||||||
ReadTimeout: parseDuration(getEnv("READ_TIMEOUT", "10s")),
|
ReadTimeout: parseDuration(getEnv("READ_TIMEOUT", "10s")),
|
||||||
WriteTimeout: parseDuration(getEnv("WRITE_TIMEOUT", "10s")),
|
WriteTimeout: parseDuration(getEnv("WRITE_TIMEOUT", "10s")),
|
||||||
@@ -79,7 +78,7 @@ func Load() (*Config, error) {
|
|||||||
// Validate and normalize URL
|
// Validate and normalize URL
|
||||||
parsedURL, err := url.Parse(cfg.VaultwardenURL)
|
parsedURL, err := url.Parse(cfg.VaultwardenURL)
|
||||||
if err != nil {
|
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" {
|
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
|
||||||
return nil, fmt.Errorf("VAULTWARDEN_URL must use http or https scheme")
|
return nil, fmt.Errorf("VAULTWARDEN_URL must use http or https scheme")
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package handlers provides HTTP request handlers for the API
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package ipwhitelist provides IP-based access control with GitHub Actions support
|
||||||
package ipwhitelist
|
package ipwhitelist
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -85,12 +86,20 @@ func (wl *IPWhitelist) Middleware() fiber.Handler {
|
|||||||
return c.Next()
|
return c.Next()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get client IP from Fiber
|
||||||
clientIP := c.IP()
|
clientIP := c.IP()
|
||||||
|
|
||||||
// Handle comma-separated IPs from X-Forwarded-For header
|
// If c.IP() returns comma-separated IPs, parse them manually
|
||||||
// Take the first IP (original client IP before proxies)
|
// This happens when Fiber doesn't parse X-Forwarded-For despite TrustedProxies config
|
||||||
ips := strings.Split(clientIP, ",")
|
// We take the FIRST IP (leftmost = original client, rightmost = trusted proxy)
|
||||||
realClientIP := strings.TrimSpace(ips[0])
|
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) {
|
if wl.IsAllowed(realClientIP) {
|
||||||
logger.Debug.Printf("IP allowed: %s", realClientIP)
|
logger.Debug.Printf("IP allowed: %s", realClientIP)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package validators provides input validation for secret names
|
||||||
package validators
|
package validators
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -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])
|
|
||||||
}
|
|
||||||
@@ -1,23 +1,19 @@
|
|||||||
|
// Package vaultwarden provides a client for interacting with Vaultwarden via CLI
|
||||||
package vaultwarden
|
package vaultwarden
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/thijsherman/vaultwarden-api/pkg/logger"
|
"github.com/thijsherman/vaultwarden-api/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Client handles communication with Vaultwarden API
|
// Client handles communication with Vaultwarden via CLI
|
||||||
type Client struct {
|
type Client struct {
|
||||||
baseURL string
|
baseURL string
|
||||||
token string // Legacy: session token for CLI fallback
|
token string // Session token from CLI authentication
|
||||||
authManager *AuthManager
|
cache *secretCache
|
||||||
httpClient *http.Client
|
|
||||||
cache *secretCache
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// secretCache provides a simple in-memory cache with TTL
|
// secretCache provides a simple in-memory cache with TTL
|
||||||
@@ -33,32 +29,7 @@ type cacheItem struct {
|
|||||||
expiresAt time.Time
|
expiresAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// CipherResponse represents a Bitwarden/Vaultwarden cipher (item)
|
// NewClient creates a new Vaultwarden client with CLI session token
|
||||||
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)
|
|
||||||
func NewClient(baseURL, token string, cacheTTL time.Duration) *Client {
|
func NewClient(baseURL, token string, cacheTTL time.Duration) *Client {
|
||||||
cache := &secretCache{
|
cache := &secretCache{
|
||||||
items: make(map[string]*cacheItem),
|
items: make(map[string]*cacheItem),
|
||||||
@@ -73,34 +44,7 @@ func NewClient(baseURL, token string, cacheTTL time.Duration) *Client {
|
|||||||
return &Client{
|
return &Client{
|
||||||
baseURL: baseURL,
|
baseURL: baseURL,
|
||||||
token: token,
|
token: token,
|
||||||
httpClient: &http.Client{
|
cache: cache,
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,119 +79,9 @@ func (c *Client) GetSecret(name string) (string, error) {
|
|||||||
return value, nil
|
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) {
|
func (c *Client) fetchSecret(name string) (string, error) {
|
||||||
// Only try CLI if using session token (not client credentials)
|
return c.FetchSecretViaCLI(name)
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearCache clears all cached secrets
|
// ClearCache clears all cached secrets
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package vaultwarden provides CLI-based secret retrieval
|
||||||
package vaultwarden
|
package vaultwarden
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
+1
-31
@@ -1,10 +1,10 @@
|
|||||||
|
// Package logger provides structured logging for the application
|
||||||
package logger
|
package logger
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -29,33 +29,3 @@ func init() {
|
|||||||
Warn = log.New(os.Stdout, "WARN: ", log.Ldate|log.Ltime|log.Lshortfile)
|
Warn = log.New(os.Stdout, "WARN: ", log.Ldate|log.Ltime|log.Lshortfile)
|
||||||
Error = log.New(os.Stderr, "ERROR: ", 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...)
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user