added IP whitelisting

This commit is contained in:
Turbootzz committed 2025-10-24 19:30:56 +02:00
1 parent 3055c413ef
commit e48098090d
5 files changed
+295 -14

No files matched your search

+15
View File
@@ -5,6 +5,21 @@ ENVIRONMENT=development # IMPORTANT: Change to 'production' for production depl
# Security - REQUIRED
API_KEY=CHANGE-THIS-USE-32-CHARS-MINIMUM!!
# IP Whitelist (Optional but RECOMMENDED for production)
# Comma-separated list of allowed IPs or CIDR ranges
# Leave empty to allow all IPs (NOT recommended for production)
# Examples:
# Single IP: ALLOWED_IPS=203.0.113.50
# Multiple IPs: ALLOWED_IPS=203.0.113.50,198.51.100.25
# CIDR range: ALLOWED_IPS=192.168.1.0/24
# Mix: ALLOWED_IPS=203.0.113.50,192.168.1.0/24,10.0.0.0/8
ALLOWED_IPS=
# Enable GitHub Actions IP ranges (automatically whitelists GitHub's IPs)
# Set to 'true' to allow GitHub Actions to access your API
# Updates every 24 hours automatically
ENABLE_GITHUB_IP_RANGES=false
# CORS Configuration
# IMPORTANT: Update this for production deployments
CORS_ALLOWED_ORIGINS=http://localhost:3000
+15 -1
View File
@@ -5,6 +5,7 @@ import (
"os"
"os/signal"
"syscall"
"time"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/compress"
@@ -15,6 +16,7 @@ import (
"github.com/thijsherman/vaultwarden-api/internal/auth"
"github.com/thijsherman/vaultwarden-api/internal/config"
"github.com/thijsherman/vaultwarden-api/internal/handlers"
"github.com/thijsherman/vaultwarden-api/internal/ipwhitelist"
"github.com/thijsherman/vaultwarden-api/internal/vaultwarden"
"github.com/thijsherman/vaultwarden-api/pkg/logger"
)
@@ -46,12 +48,23 @@ func main() {
logger.Warn.Println("Using provided session token (will expire)")
vaultClient = vaultwarden.NewClient(cfg.VaultwardenURL, cfg.VaultwardenToken, cfg.CacheTTL)
} else {
logger.Error.Fatal("No authentication configured. Set VAULTWARDEN_CLIENT_ID+VAULTWARDEN_CLIENT_SECRET+VAULTWARDEN_PASSWORD")
logger.Error.Fatal("No authentication configured. Set either (VAULTWARDEN_CLIENT_ID + VAULTWARDEN_CLIENT_SECRET + VAULTWARDEN_PASSWORD) or VAULTWARDEN_ACCESS_TOKEN")
}
// Initialize handlers
h := handlers.NewHandler(vaultClient)
// Initialize IP whitelist
ipWhitelist, err := ipwhitelist.New(cfg.AllowedIPs, cfg.EnableGitHubIPRanges)
if err != nil {
logger.Error.Fatalf("Failed to initialize IP whitelist: %v", err)
}
// Start periodic GitHub IP range updates (every 24 hours)
if cfg.EnableGitHubIPRanges {
ipWhitelist.StartPeriodicUpdate(24 * time.Hour)
}
// Create Fiber app with security configurations
app := fiber.New(fiber.Config{
AppName: "Vaultwarden API v1.0",
@@ -66,6 +79,7 @@ func main() {
app.Use(helmet.New())
app.Use(recover.New())
app.Use(ipWhitelist.Middleware())
app.Use(compress.New(compress.Config{
Level: compress.LevelBestSpeed,
}))
+16 -1
View File
@@ -17,7 +17,9 @@ type Config struct {
WriteTimeout time.Duration
// Security
APIKey string
APIKey string
AllowedIPs []string
EnableGitHubIPRanges bool
VaultwardenURL string
VaultwardenToken string
@@ -44,6 +46,19 @@ func Load() (*Config, error) {
WriteTimeout: parseDuration(getEnv("WRITE_TIMEOUT", "10s")),
CacheTTL: parseDuration(getEnv("CACHE_TTL", "5m")),
CORSAllowedOrigins: getEnv("CORS_ALLOWED_ORIGINS", "http://localhost:3000"),
EnableGitHubIPRanges: getEnv("ENABLE_GITHUB_IP_RANGES", "false") == "true",
}
// Parse allowed IPs
if allowedIPsStr := os.Getenv("ALLOWED_IPS"); allowedIPsStr != "" {
ips := strings.Split(allowedIPsStr, ",")
for _, ip := range ips {
trimmed := strings.TrimSpace(ip)
if trimmed != "" {
cfg.AllowedIPs = append(cfg.AllowedIPs, trimmed)
}
}
}
// Validate required fields
+194
View File
@@ -0,0 +1,194 @@
package ipwhitelist
import (
"encoding/json"
"fmt"
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/gofiber/fiber/v2"
"github.com/thijsherman/vaultwarden-api/pkg/logger"
)
// IPWhitelist manages IP-based access control
type IPWhitelist struct {
mu sync.RWMutex
allowedIPs map[string]bool
allowedCIDRs []*net.IPNet
githubIPRanges []*net.IPNet
enableGitHub bool
lastGitHubUpdate time.Time
}
// GitHubMeta represents GitHub's API response for IP ranges
type GitHubMeta struct {
Actions []string `json:"actions"`
}
// New creates a new IP whitelist
func New(allowedIPs []string, enableGitHub bool) (*IPWhitelist, error) {
wl := &IPWhitelist{
allowedIPs: make(map[string]bool),
enableGitHub: enableGitHub,
}
// Parse allowed IPs and CIDRs
for _, ipStr := range allowedIPs {
ipStr = strings.TrimSpace(ipStr)
if ipStr == "" {
continue
}
// Check if it's a CIDR
if strings.Contains(ipStr, "/") {
_, cidr, err := net.ParseCIDR(ipStr)
if err != nil {
logger.Warn.Printf("Invalid CIDR '%s': %v", ipStr, err)
continue
}
wl.allowedCIDRs = append(wl.allowedCIDRs, cidr)
logger.Info.Printf("Added CIDR to whitelist: %s", ipStr)
} else {
// Single IP
ip := net.ParseIP(ipStr)
if ip == nil {
logger.Warn.Printf("Invalid IP '%s'", ipStr)
continue
}
wl.allowedIPs[ipStr] = true
logger.Info.Printf("Added IP to whitelist: %s", ipStr)
}
}
// Fetch GitHub IP ranges if enabled
if enableGitHub {
if err := wl.updateGitHubIPRanges(); err != nil {
logger.Warn.Printf("Failed to fetch GitHub IP ranges: %v", err)
}
}
return wl, nil
}
// Middleware creates a Fiber middleware for IP whitelisting
func (wl *IPWhitelist) Middleware() fiber.Handler {
return func(c *fiber.Ctx) error {
// If no IPs configured and GitHub not enabled, allow all
wl.mu.RLock()
hasWhitelist := len(wl.allowedIPs) > 0 || len(wl.allowedCIDRs) > 0 || len(wl.githubIPRanges) > 0
wl.mu.RUnlock()
if !hasWhitelist {
return c.Next()
}
clientIP := c.IP()
if wl.IsAllowed(clientIP) {
logger.Info.Printf("IP allowed: %s", clientIP)
return c.Next()
}
logger.Warn.Printf("IP blocked (not whitelisted): %s on %s %s", clientIP, c.Method(), c.Path())
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
"error": "access denied: IP not whitelisted",
})
}
}
// IsAllowed checks if an IP is whitelisted
func (wl *IPWhitelist) IsAllowed(ipStr string) bool {
wl.mu.RLock()
defer wl.mu.RUnlock()
ip := net.ParseIP(ipStr)
if ip == nil {
return false
}
// Check single IPs
if wl.allowedIPs[ipStr] {
return true
}
// Check CIDRs
for _, cidr := range wl.allowedCIDRs {
if cidr.Contains(ip) {
return true
}
}
// Check GitHub IP ranges
for _, cidr := range wl.githubIPRanges {
if cidr.Contains(ip) {
return true
}
}
return false
}
// updateGitHubIPRanges fetches GitHub Actions IP ranges
func (wl *IPWhitelist) updateGitHubIPRanges() error {
logger.Info.Println("Fetching GitHub Actions IP ranges...")
client := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := client.Get("https://api.github.com/meta")
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("github api returned status %d", resp.StatusCode)
}
var meta GitHubMeta
if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil {
return err
}
wl.mu.Lock()
defer wl.mu.Unlock()
wl.githubIPRanges = nil
for _, cidrStr := range meta.Actions {
_, cidr, err := net.ParseCIDR(cidrStr)
if err != nil {
logger.Warn.Printf("Invalid GitHub CIDR '%s': %v", cidrStr, err)
continue
}
wl.githubIPRanges = append(wl.githubIPRanges, cidr)
}
wl.lastGitHubUpdate = time.Now()
logger.Info.Printf("Loaded %d GitHub Actions IP ranges", len(wl.githubIPRanges))
return nil
}
// StartPeriodicUpdate starts a goroutine that updates GitHub IP ranges periodically
func (wl *IPWhitelist) StartPeriodicUpdate(interval time.Duration) {
if !wl.enableGitHub {
return
}
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
if err := wl.updateGitHubIPRanges(); err != nil {
logger.Error.Printf("Failed to update GitHub IP ranges: %v", err)
}
}
}()
logger.Info.Printf("Started GitHub IP range auto-update (every %v)", interval)
}
+55 -12
View File
@@ -20,26 +20,69 @@ func InitializeBitwardenCLI(serverURL, clientID, clientSecret, password string)
logger.Info.Println("Initializing Bitwarden CLI...")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
cmd := exec.CommandContext(ctx, "bw", "config", "server", serverURL)
if output, err := cmd.CombinedOutput(); err != nil {
cancel()
return "", fmt.Errorf("config server failed: %w - %s", err, output)
}
cancel()
cmd = exec.CommandContext(ctx, "bw", "login", "--apikey")
cmd.Env = append(os.Environ(),
fmt.Sprintf("BW_CLIENTID=%s", clientID),
fmt.Sprintf("BW_CLIENTSECRET=%s", clientSecret),
)
output, err := cmd.CombinedOutput()
ctx, cancel = context.WithTimeout(context.Background(), 5*time.Second)
cmd = exec.CommandContext(ctx, "bw", "status")
statusOutput, _ := cmd.CombinedOutput()
cancel()
outputStr := strings.TrimSpace(string(output))
if err != nil && !strings.Contains(outputStr, "You are logged in!") {
return "", fmt.Errorf("login failed: %w - %s", err, outputStr)
statusStr := strings.TrimSpace(string(statusOutput))
isLoggedIn := strings.Contains(statusStr, `"status":"unlocked"`) || strings.Contains(statusStr, `"status":"locked"`)
if !isLoggedIn {
logger.Info.Println("Logging in to Bitwarden...")
maxRetries := 3
var loginErr error
for attempt := 1; attempt <= maxRetries; attempt++ {
if attempt > 1 {
backoff := time.Duration(attempt*attempt) * 5 * time.Second
logger.Info.Printf("Retry attempt %d/%d after %v...", attempt, maxRetries, backoff)
time.Sleep(backoff)
}
ctx, cancel = context.WithTimeout(context.Background(), 15*time.Second)
cmd = exec.CommandContext(ctx, "bw", "login", "--apikey")
cmd.Env = append(os.Environ(),
fmt.Sprintf("BW_CLIENTID=%s", clientID),
fmt.Sprintf("BW_CLIENTSECRET=%s", clientSecret),
)
output, err := cmd.CombinedOutput()
cancel()
outputStr := strings.TrimSpace(string(output))
if err == nil || strings.Contains(outputStr, "You are logged in!") {
logger.Info.Println("Login successful")
loginErr = nil
break
}
if strings.Contains(outputStr, "Rate limit") {
logger.Warn.Printf("Rate limited (attempt %d/%d)", attempt, maxRetries)
loginErr = fmt.Errorf("rate limited: %s", outputStr)
continue
}
loginErr = fmt.Errorf("login failed: %w - %s", err, outputStr)
break
}
if loginErr != nil {
return "", loginErr
}
} else {
logger.Info.Println("Already logged in, skipping login")
}
ctx, cancel = context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd = exec.CommandContext(ctx, "bw", "unlock", "--passwordenv", "BW_PASSWORD", "--raw")
cmd.Env = append(os.Environ(), fmt.Sprintf("BW_PASSWORD=%s", password))
sessionToken, err := cmd.CombinedOutput()