Files
vw-api/internal/ipwhitelist/ipwhitelist.go
T
Jethro Young 76b82d4dba Filtering of synchronized secrets on collection, organization, and/or folder (#12)
* Filtering feature, minor logic changes
+: Add IP validation (to properly log IP even when trusted proxy is missing)
+: Added synchronization of decrypted organization, collection, and folder names (for filtering purposes)
+: Changed sync behavior to prevent collapsing multiple logins with the same name
+: Added possibility to filter logins based on organization, collection, and folder name/id in GetSecret
+: Added support for space character in secret name validation (it is also properly URL-decoded)

* Update README

* Fixes related to issues reported by CodeRabbit:
+: Changed british use of organisations in README
+: Reduced line count in parseSecretFilters by including helper function for filter dimensions resolution
+: Added comment clarifying absence of existence checking on dimension IDs as opposed to names
+: Removed efensive comma split logic on IPs (redundant due to IPValidation setting on Fiber)
+: Changed Query validator to align with name validator (mainly removing hard-coded values)
+: Added comments to decryptVaultLabel for clarity
+: Change empty SyncNameMaps instance with emptySyncNameMaps helper for consistency
+: Add lexical sort for lookupIDByName for deterministic result in case of multiple matches

* Bumped logging to warn and added entity metadata in log

* Unit tests:
+: Added unit tests for every newly introduced or changed function
+: Rewrote NewClient constructor to accept optional preloaded data (so it can be used in test scenarios)

* Changes requested by CodeRabbit
2026-05-20 23:26:29 +02:00

205 lines
4.6 KiB
Go

// Package ipwhitelist provides IP-based access control with GitHub Actions support
package ipwhitelist
import (
"encoding/json"
"fmt"
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/Turbootzz/vaultwarden-api/pkg/logger"
"github.com/gofiber/fiber/v2"
)
// 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[ip.String()] = 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.Debug.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 (normalize IP for consistent matching)
if wl.allowedIPs[ip.String()] {
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
// Returns a stop function that should be called to clean up the goroutine
func (wl *IPWhitelist) StartPeriodicUpdate(interval time.Duration) func() {
if !wl.enableGitHub {
return func() {}
}
ticker := time.NewTicker(interval)
done := make(chan struct{})
go func() {
defer ticker.Stop()
for {
select {
case <-ticker.C:
if err := wl.updateGitHubIPRanges(); err != nil {
logger.Error.Printf("Failed to update GitHub IP ranges: %v", err)
}
case <-done:
return
}
}
}()
logger.Info.Printf("Started GitHub IP range auto-update (every %v)", interval)
return func() { close(done) }
}