mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 18:45:53 +00:00
d4ce19219c
The net.IP predicates every SSRF guard is built from read only the literal address bytes, so an IPv6 transition address smuggles an internal IPv4 destination past all of them. 64:ff9b::a9fe:a9fe reaches 169.254.169.254 while IsLoopback, IsPrivate, IsLinkLocalUnicast and To4 all report an ordinary public address, defeating both the webhook URL validator and the restricted outbound transport with the same input. Add securityutil.EmbeddedIPv4Candidates, which unwraps NAT64 (RFC 6052 well-known and RFC 8215 local-use prefixes), 6to4, Teredo, ISATAP, IPv4-compatible and IPv4-translated encodings, and run every candidate through the caller's own policy in both layers. The embedded destination inherits the outer policy rather than a stricter one, so a NAT64 address wrapping a permitted public target stays permitted and AllowPrivateIPs / AllowLoopback relax the embedded check the same way they relax the outer. Reported by tonghuaroot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
352 lines
8.3 KiB
Go
352 lines
8.3 KiB
Go
package audit
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/rs/zerolog/log"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/securityutil"
|
|
)
|
|
|
|
const (
|
|
webhookQueueSize = 1000
|
|
webhookMaxRetries = 3
|
|
webhookTimeout = 30 * time.Second
|
|
webhookWorkerCount = 3
|
|
)
|
|
|
|
var webhookBackoff = []time.Duration{1 * time.Second, 5 * time.Second, 30 * time.Second}
|
|
var resolveWebhookIPs = net.DefaultResolver.LookupIPAddr
|
|
|
|
// WebhookDelivery handles async webhook delivery with retries.
|
|
type WebhookDelivery struct {
|
|
mu sync.RWMutex
|
|
urls []string
|
|
client *http.Client
|
|
queue chan Event
|
|
stopChan chan struct{}
|
|
stopOnce sync.Once
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
// WebhookPayload is the JSON payload sent to webhooks.
|
|
type WebhookPayload struct {
|
|
Event string `json:"event"`
|
|
Timestamp time.Time `json:"timestamp"`
|
|
Data Event `json:"data"`
|
|
}
|
|
|
|
// NewWebhookDelivery creates a new webhook delivery worker.
|
|
func NewWebhookDelivery(urls []string) *WebhookDelivery {
|
|
return &WebhookDelivery{
|
|
urls: urls,
|
|
client: securityutil.NewRestrictedOutboundHTTPClient(webhookTimeout, securityutil.RestrictedOutboundHTTPOptions{
|
|
AllowedSchemes: []string{"http", "https"},
|
|
ResolveIPAddrs: resolveWebhookIPs,
|
|
}),
|
|
queue: make(chan Event, webhookQueueSize),
|
|
stopChan: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
// Start begins the delivery worker goroutines.
|
|
func (w *WebhookDelivery) Start() {
|
|
for i := 0; i < webhookWorkerCount; i++ {
|
|
w.wg.Add(1)
|
|
go w.worker(i)
|
|
}
|
|
log.Debug().Int("workers", webhookWorkerCount).Msg("Audit webhook delivery started")
|
|
}
|
|
|
|
// Stop gracefully stops the delivery workers.
|
|
func (w *WebhookDelivery) Stop() {
|
|
w.stopOnce.Do(func() {
|
|
close(w.stopChan)
|
|
w.wg.Wait()
|
|
if w.client != nil {
|
|
w.client.CloseIdleConnections()
|
|
}
|
|
log.Debug().Msg("Audit webhook delivery stopped")
|
|
})
|
|
}
|
|
|
|
// Enqueue adds an event to the delivery queue.
|
|
// Non-blocking - drops events if queue is full.
|
|
func (w *WebhookDelivery) Enqueue(event Event) {
|
|
select {
|
|
case w.queue <- event:
|
|
// Enqueued successfully
|
|
default:
|
|
log.Warn().
|
|
Str("event_id", event.ID).
|
|
Str("event_type", event.EventType).
|
|
Msg("Audit webhook queue full, dropping event")
|
|
}
|
|
}
|
|
|
|
// UpdateURLs updates the webhook URLs.
|
|
func (w *WebhookDelivery) UpdateURLs(urls []string) {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
w.urls = urls
|
|
}
|
|
|
|
// GetURLs returns the current webhook URLs.
|
|
func (w *WebhookDelivery) GetURLs() []string {
|
|
w.mu.RLock()
|
|
defer w.mu.RUnlock()
|
|
result := make([]string, len(w.urls))
|
|
copy(result, w.urls)
|
|
return result
|
|
}
|
|
|
|
// worker processes events from the queue.
|
|
func (w *WebhookDelivery) worker(id int) {
|
|
defer w.wg.Done()
|
|
|
|
for {
|
|
select {
|
|
case <-w.stopChan:
|
|
// Drain remaining events on shutdown (with timeout)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
w.drainQueue(ctx)
|
|
cancel()
|
|
return
|
|
case event := <-w.queue:
|
|
w.deliverToAll(event)
|
|
}
|
|
}
|
|
}
|
|
|
|
// drainQueue processes remaining events during shutdown.
|
|
func (w *WebhookDelivery) drainQueue(ctx context.Context) {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
remaining := len(w.queue)
|
|
if remaining > 0 {
|
|
log.Warn().Int("remaining", remaining).Msg("Audit webhook shutdown timeout, dropping remaining events")
|
|
}
|
|
return
|
|
case event := <-w.queue:
|
|
w.deliverToAll(event)
|
|
default:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// deliverToAll sends an event to all configured webhooks.
|
|
func (w *WebhookDelivery) deliverToAll(event Event) {
|
|
w.mu.RLock()
|
|
urls := make([]string, len(w.urls))
|
|
copy(urls, w.urls)
|
|
w.mu.RUnlock()
|
|
|
|
for _, url := range urls {
|
|
if err := w.deliverWithRetry(url, event); err != nil {
|
|
log.Error().
|
|
Err(err).
|
|
Str("url", url).
|
|
Str("event_id", event.ID).
|
|
Msg("Failed to deliver audit webhook after retries")
|
|
}
|
|
}
|
|
}
|
|
|
|
// deliverWithRetry attempts to deliver an event with exponential backoff.
|
|
func (w *WebhookDelivery) deliverWithRetry(url string, event Event) error {
|
|
var lastErr error
|
|
|
|
for attempt := 0; attempt <= webhookMaxRetries; attempt++ {
|
|
if attempt > 0 {
|
|
// Wait before retry
|
|
backoffIdx := attempt - 1
|
|
if backoffIdx >= len(webhookBackoff) {
|
|
backoffIdx = len(webhookBackoff) - 1
|
|
}
|
|
time.Sleep(webhookBackoff[backoffIdx])
|
|
}
|
|
|
|
err := w.deliver(url, event)
|
|
if err == nil {
|
|
if attempt > 0 {
|
|
log.Debug().
|
|
Str("url", url).
|
|
Str("event_id", event.ID).
|
|
Int("attempt", attempt+1).
|
|
Msg("Audit webhook delivered after retry")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
lastErr = err
|
|
log.Debug().
|
|
Err(err).
|
|
Str("url", url).
|
|
Str("event_id", event.ID).
|
|
Int("attempt", attempt+1).
|
|
Int("maxAttempts", webhookMaxRetries+1).
|
|
Msg("Audit webhook delivery attempt failed")
|
|
}
|
|
|
|
return lastErr
|
|
}
|
|
|
|
// deliver sends a single event to a webhook URL.
|
|
func (w *WebhookDelivery) deliver(url string, event Event) error {
|
|
payload := WebhookPayload{
|
|
Event: "audit." + event.EventType,
|
|
Timestamp: event.Timestamp,
|
|
Data: event,
|
|
}
|
|
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal webhook payload: %w", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), webhookTimeout)
|
|
defer cancel()
|
|
|
|
if err := validateWebhookURL(ctx, url); err != nil {
|
|
return fmt.Errorf("webhook URL blocked: %w", err)
|
|
}
|
|
targetURL, err := securityutil.NormalizeAbsoluteHTTPURL(url)
|
|
if err != nil {
|
|
return fmt.Errorf("webhook URL blocked: %w", err)
|
|
}
|
|
|
|
req, err := securityutil.NewValidatedRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create webhook request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("User-Agent", "Pulse-Audit-Webhook/1.0")
|
|
req.Header.Set("X-Pulse-Event", event.EventType)
|
|
req.Header.Set("X-Pulse-Event-ID", event.ID)
|
|
|
|
resp, err := w.client.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("webhook request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Consider 2xx as success
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return fmt.Errorf("webhook returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validateWebhookURL(ctx context.Context, rawURL string) error {
|
|
if strings.TrimSpace(rawURL) == "" {
|
|
return fmt.Errorf("URL cannot be empty")
|
|
}
|
|
|
|
parsed, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid URL format")
|
|
}
|
|
|
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
|
return fmt.Errorf("URL scheme must be http or https")
|
|
}
|
|
|
|
if parsed.Host == "" {
|
|
return fmt.Errorf("URL must have a host")
|
|
}
|
|
|
|
hostname := parsed.Hostname()
|
|
if hostname == "" {
|
|
return fmt.Errorf("URL must have a hostname")
|
|
}
|
|
|
|
if hostname == "localhost" || hostname == "127.0.0.1" || hostname == "::1" {
|
|
return fmt.Errorf("localhost URLs are not allowed")
|
|
}
|
|
|
|
if ip := net.ParseIP(hostname); ip != nil {
|
|
if isPrivateOrReservedIP(ip) {
|
|
return fmt.Errorf("private or reserved IP addresses are not allowed")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
lowerHost := strings.ToLower(hostname)
|
|
blockedPatterns := []string{
|
|
"metadata.google",
|
|
"169.254.169.254",
|
|
"metadata.azure",
|
|
"internal",
|
|
".local",
|
|
".localhost",
|
|
}
|
|
for _, pattern := range blockedPatterns {
|
|
if strings.Contains(lowerHost, pattern) {
|
|
return fmt.Errorf("internal hostnames are not allowed")
|
|
}
|
|
}
|
|
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
resolveCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
|
defer cancel()
|
|
addrs, err := resolveWebhookIPs(resolveCtx, hostname)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to resolve hostname")
|
|
}
|
|
if len(addrs) == 0 {
|
|
return fmt.Errorf("hostname did not resolve")
|
|
}
|
|
for _, addr := range addrs {
|
|
if isPrivateOrReservedIP(addr.IP) {
|
|
return fmt.Errorf("hostname resolves to private or reserved IP addresses")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func isPrivateOrReservedIP(ip net.IP) bool {
|
|
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
|
return true
|
|
}
|
|
|
|
if ip4 := ip.To4(); ip4 != nil {
|
|
if ip4[0] == 169 && ip4[1] == 254 {
|
|
return true
|
|
}
|
|
if ip4[0] == 0 {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// NAT64, 6to4, Teredo and ISATAP addresses tunnel to an embedded IPv4
|
|
// destination that none of the checks above can see.
|
|
for _, embedded := range securityutil.EmbeddedIPv4Candidates(ip) {
|
|
if isPrivateOrReservedIP(embedded) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// QueueLength returns the current number of events in the queue.
|
|
func (w *WebhookDelivery) QueueLength() int {
|
|
return len(w.queue)
|
|
}
|