Files
silo-server/internal/notifications/webhook_http.go
T
QuickandClaude Fable 5 e5b210589d fix(notifications): address PR #136 review findings
Codex + CodeRabbit review fixes, all verified against current behavior:

- Web Push: single-writer VAPID provisioning via a new conditional
  SetIfAbsent settings write (no split-brain identity across nodes), and
  read/decode failures now surface instead of silently rotating the
  keypair; the eager-provisioning goroutine joins the shutdown WaitGroup
- Web Push: endpoint reassignment purges the previous owner's pending
  attempts inside the upsert transaction, with an ownership re-check at
  send time
- Webhooks: per-profile cap enforced atomically (advisory-locked
  count+insert), typed pgconn unique-violation mapping, create-time
  type/URL mismatch rejection, send-time HTTPS re-check, and Retry-After
  HTTP-date support (shared, clamped parser also used by web push)
- Delivery workers: transient delivery-row lookup errors leave the claim
  to lease expiry instead of permanently failing the attempt
- Interest: history-only imports now feed the index (userstore history
  hooks + completed-history folding in recompute/rebuild), rebuild also
  recomputes existing interest rows so removed sources get cleaned up,
  and failed flush mutations requeue (bounded) instead of dropping
- Retention: read notifications age from read_at, not created_at
- Startup: scan queue workers start only after the availability detector
  is wired, so resumed scans cannot skip availability recording
- mail: settings-store read failures propagate instead of reading as
  "not configured"
- DB: new migration adds episode ordinal/key CHECK constraints
- Web: service worker restricts notification clicks to same-origin URLs,
  preferences popover gets an error+retry state, and the realtime
  profile-rebind backoff grows to 5 minutes to keep shared channels
  stable through notifications-only outages

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:16:27 -04:00

190 lines
6.0 KiB
Go

package notifications
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"strconv"
"strings"
"syscall"
"time"
)
const (
webhookRequestTimeout = 10 * time.Second
webhookMaxRedirects = 3
webhookUserAgent = "Silo-Webhook/1.0"
)
// webhookSendResult is the structured outcome of one webhook POST.
type webhookSendResult struct {
OK bool
HTTPStatus int // 0 when no HTTP response was received
RetryAfter time.Duration // from a 429 Retry-After header, when present
Duration time.Duration
// Message is a short, non-sensitive diagnostic suitable for
// failure_message ("404 Not Found", "dns lookup failed", ...). Never
// includes URLs or payload contents.
Message string
}
// newWebhookHTTPClient builds the delivery client: 10s total timeout,
// non-overridable TLS verification, bounded redirects, and a dialer Control
// hook that re-validates every resolved address at connect time (DNS
// rebinding mitigation — the guard runs on the address actually being
// connected to, each redirect hop included).
func newWebhookHTTPClient(allowPrivate func() bool) *http.Client {
dialer := &net.Dialer{
Timeout: 5 * time.Second,
Control: func(network, address string, _ syscall.RawConn) error {
if allowPrivate != nil && allowPrivate() {
return nil
}
host, _, err := net.SplitHostPort(address)
if err != nil {
return fmt.Errorf("webhook dial: %w", err)
}
ip := net.ParseIP(host)
if ip == nil || !webhookIPAllowed(ip) {
return errPrivateDestination
}
return nil
},
}
transport := &http.Transport{
DialContext: dialer.DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 16,
IdleConnTimeout: 60 * time.Second,
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: webhookRequestTimeout,
}
return &http.Client{
Timeout: webhookRequestTimeout,
Transport: transport,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= webhookMaxRedirects {
return errors.New("too many redirects")
}
if req.URL.Scheme != schemeHTTPS {
return errors.New("redirect to non-https destination")
}
return nil
},
}
}
var errPrivateDestination = errors.New("destination resolves to a private or special-use network")
// sendWebhook POSTs body to url with the given extra headers and classifies
// the outcome. Success is any 2xx. The response body is drained (bounded) and
// discarded — Silo never consumes webhook responses for state.
func sendWebhook(ctx context.Context, client *http.Client, url string, body []byte, headers map[string]string) webhookSendResult {
started := time.Now()
result := func(ok bool, status int, message string) webhookSendResult {
return webhookSendResult{
OK: ok,
HTTPStatus: status,
Duration: time.Since(started),
Message: message,
}
}
ctx, cancel := context.WithTimeout(ctx, webhookRequestTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return result(false, 0, "invalid webhook URL")
}
// HTTPS is enforced at registration; re-check at the last layer before
// the wire so delivery never depends on upstream validation staying
// perfect (webhook URLs are credentials and must not travel cleartext).
if req.URL.Scheme != schemeHTTPS {
return result(false, 0, "invalid webhook URL")
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", webhookUserAgent)
for key, value := range headers {
req.Header.Set(key, value)
}
resp, err := client.Do(req)
if err != nil {
return result(false, 0, classifyWebhookError(err))
}
defer func() { _ = resp.Body.Close() }()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return result(true, resp.StatusCode, "")
}
out := result(false, resp.StatusCode, http.StatusText(resp.StatusCode))
if out.Message == "" {
out.Message = fmt.Sprintf("HTTP %d", resp.StatusCode)
} else {
out.Message = fmt.Sprintf("%d %s", resp.StatusCode, out.Message)
}
if resp.StatusCode == http.StatusTooManyRequests {
out.RetryAfter = parseRetryAfter(resp.Header.Get("Retry-After"), time.Now())
}
return out
}
// maxRetryAfter caps how far a destination's Retry-After header can push the
// next attempt; the longest scheduled backoff is 24h and a buggy or hostile
// header must not park deliveries beyond it.
const maxRetryAfter = 24 * time.Hour
// parseRetryAfter interprets a Retry-After header in both RFC 9110 forms —
// delta-seconds and HTTP-date — returning 0 when absent or unusable.
func parseRetryAfter(header string, now time.Time) time.Duration {
header = strings.TrimSpace(header)
if header == "" {
return 0
}
delay := time.Duration(0)
if seconds, err := strconv.Atoi(header); err == nil {
delay = time.Duration(seconds) * time.Second
} else if when, err := http.ParseTime(header); err == nil {
delay = when.Sub(now)
}
if delay <= 0 {
return 0
}
return min(delay, maxRetryAfter)
}
// classifyWebhookError maps transport errors to short diagnostic classes.
// Messages must stay free of URLs and payload contents.
func classifyWebhookError(err error) string {
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
return "dns lookup failed"
}
if errors.Is(err, errPrivateDestination) {
return "destination resolves to a private network"
}
if errors.Is(err, context.DeadlineExceeded) {
return "request timed out"
}
message := err.Error()
switch {
case strings.Contains(message, "tls"):
return "tls handshake failed"
case strings.Contains(message, "connection refused"):
return "connection refused"
case strings.Contains(message, "too many redirects"):
return "too many redirects"
case strings.Contains(message, "redirect to non-https"):
return "redirect to non-https destination"
case strings.Contains(message, "timeout") || strings.Contains(message, "deadline"):
return "request timed out"
default:
return "connection failed"
}
}