- Webhook registration via MCP (register_webhook, list_webhooks, delete_webhook) - HMAC-SHA256 payload signing, SSRF-safe HTTP client, loop detection (depth 5) - 8-worker goroutine delivery pool with exponential backoff retry (1s/5s/30s) - Dead letter queue with auto-purge, auto-disable after 50 consecutive failures - Per-agent rate limiting (60 deliveries/min) - K8s Job runner (register_k8s_handler, list_k8s_handlers, delete_k8s_handler) - Auto-detect in-cluster via InClusterConfig, NoopRunner fallback - REST API for webhook deliveries, dead letters, K8s job runs and logs - Web UI: webhook management, K8s handler pages, dead letters view - MultiDispatcher fan-out pattern for webhook + K8s event dispatch - SQLite migration 009: webhooks, webhook_deliveries, k8s_handlers, k8s_job_runs - 51 tests across 9 test packages, all passing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
207 lines
5.6 KiB
Go
207 lines
5.6 KiB
Go
package webhooks
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
// ComputeHMACSignature computes the HMAC-SHA256 signature of the payload using the given secret.
|
|
// Returns the signature as "sha256=<hex digest>".
|
|
func ComputeHMACSignature(secret, payload []byte) string {
|
|
mac := hmac.New(sha256.New, secret)
|
|
mac.Write(payload)
|
|
return "sha256=" + hex.EncodeToString(mac.Sum(nil))
|
|
}
|
|
|
|
// ValidateWebhookURL validates a webhook URL for registration.
|
|
// It checks scheme (HTTPS required unless allowHTTP), parses the URL,
|
|
// and optionally resolves DNS to check for private IPs.
|
|
func ValidateWebhookURL(rawURL string, allowHTTP, allowPrivate bool) error {
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid URL: %w", err)
|
|
}
|
|
|
|
// Check scheme
|
|
switch u.Scheme {
|
|
case "https":
|
|
// Always allowed
|
|
case "http":
|
|
if !allowHTTP {
|
|
return fmt.Errorf("webhook URL must use HTTPS (set SYNAPBUS_ALLOW_HTTP_WEBHOOKS=true for development)")
|
|
}
|
|
default:
|
|
return fmt.Errorf("webhook URL must use HTTPS scheme, got %q", u.Scheme)
|
|
}
|
|
|
|
// Check host is not empty
|
|
host := u.Hostname()
|
|
if host == "" {
|
|
return fmt.Errorf("webhook URL must have a hostname")
|
|
}
|
|
|
|
// If allowPrivate, skip IP validation
|
|
if allowPrivate {
|
|
return nil
|
|
}
|
|
|
|
// Check if host is a literal IP
|
|
if ip := net.ParseIP(host); ip != nil {
|
|
if IsPrivateIP(ip) {
|
|
return fmt.Errorf("webhook URL resolves to a private network address; set SYNAPBUS_ALLOW_PRIVATE_NETWORKS=true to override")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Resolve hostname and check all IPs
|
|
ips, err := net.LookupHost(host)
|
|
if err != nil {
|
|
// Don't fail registration on DNS lookup failure — the URL might be valid later.
|
|
// We'll re-check at delivery time.
|
|
return nil
|
|
}
|
|
|
|
for _, ipStr := range ips {
|
|
ip := net.ParseIP(ipStr)
|
|
if ip != nil && IsPrivateIP(ip) {
|
|
return fmt.Errorf("webhook URL resolves to a private network address; set SYNAPBUS_ALLOW_PRIVATE_NETWORKS=true to override")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// IsPrivateIP checks whether an IP address belongs to a private/reserved range.
|
|
// Blocks: RFC1918, loopback, link-local, IPv6 ULA, IPv6 link-local.
|
|
func IsPrivateIP(ip net.IP) bool {
|
|
privateRanges := []struct {
|
|
network *net.IPNet
|
|
}{
|
|
{mustParseCIDR("10.0.0.0/8")},
|
|
{mustParseCIDR("172.16.0.0/12")},
|
|
{mustParseCIDR("192.168.0.0/16")},
|
|
{mustParseCIDR("127.0.0.0/8")},
|
|
{mustParseCIDR("169.254.0.0/16")},
|
|
{mustParseCIDR("::1/128")},
|
|
{mustParseCIDR("fc00::/7")},
|
|
{mustParseCIDR("fe80::/10")},
|
|
}
|
|
|
|
for _, r := range privateRanges {
|
|
if r.network.Contains(ip) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func mustParseCIDR(s string) *net.IPNet {
|
|
_, n, err := net.ParseCIDR(s)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("invalid CIDR %q: %v", s, err))
|
|
}
|
|
return n
|
|
}
|
|
|
|
// NewSSRFSafeTransport creates an http.Transport that blocks connections to private IP addresses.
|
|
// It uses a custom DialContext that resolves DNS and validates the IP before connecting.
|
|
func NewSSRFSafeTransport(allowPrivate bool) *http.Transport {
|
|
dialer := &net.Dialer{
|
|
Timeout: 5 * time.Second,
|
|
KeepAlive: 30 * time.Second,
|
|
}
|
|
|
|
return &http.Transport{
|
|
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
if !allowPrivate {
|
|
host, port, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid address %q: %w", addr, err)
|
|
}
|
|
|
|
// Resolve DNS
|
|
ips, err := net.DefaultResolver.LookupHost(ctx, host)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("DNS resolution failed for %q: %w", host, err)
|
|
}
|
|
|
|
// Check all resolved IPs
|
|
for _, ipStr := range ips {
|
|
ip := net.ParseIP(ipStr)
|
|
if ip != nil && IsPrivateIP(ip) {
|
|
return nil, fmt.Errorf("webhook URL resolved to blocked IP address (%s)", ipStr)
|
|
}
|
|
}
|
|
|
|
// Connect to the first valid IP
|
|
if len(ips) > 0 {
|
|
addr = net.JoinHostPort(ips[0], port)
|
|
}
|
|
}
|
|
|
|
return dialer.DialContext(ctx, network, addr)
|
|
},
|
|
// Also use Control to catch any remaining private IPs at the socket level
|
|
ForceAttemptHTTP2: true,
|
|
TLSHandshakeTimeout: 5 * time.Second,
|
|
ResponseHeaderTimeout: 5 * time.Second,
|
|
MaxIdleConns: 100,
|
|
MaxIdleConnsPerHost: 10,
|
|
IdleConnTimeout: 90 * time.Second,
|
|
}
|
|
}
|
|
|
|
// NewSSRFSafeClient creates an http.Client with SSRF protection and no redirect following.
|
|
func NewSSRFSafeClient(allowPrivate bool) *http.Client {
|
|
return &http.Client{
|
|
Transport: NewSSRFSafeTransport(allowPrivate),
|
|
Timeout: 10 * time.Second,
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
return fmt.Errorf("redirects not allowed")
|
|
},
|
|
}
|
|
}
|
|
|
|
// controlFunc returns a syscall.RawConn Control function that blocks private IPs.
|
|
// This is a defense-in-depth check at the socket level.
|
|
func controlFunc(network string, address string, conn syscall.RawConn) error {
|
|
host, _, err := net.SplitHostPort(address)
|
|
if err != nil {
|
|
// address might not have a port
|
|
host = address
|
|
}
|
|
// Remove brackets from IPv6
|
|
host = strings.Trim(host, "[]")
|
|
|
|
ip := net.ParseIP(host)
|
|
if ip != nil && IsPrivateIP(ip) {
|
|
return fmt.Errorf("connection to private IP %s blocked", host)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidURLSchemes contains the allowed URL schemes for webhooks.
|
|
var ValidURLSchemes = []string{"https", "http"}
|
|
|
|
// ValidEvents contains the valid webhook event types.
|
|
var ValidEvents = []string{"message.received", "message.mentioned", "channel.message"}
|
|
|
|
// IsValidEvent checks if the given event type is valid.
|
|
func IsValidEvent(event string) bool {
|
|
for _, e := range ValidEvents {
|
|
if e == event {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|