fix(notifications): harden webhook dispatcher against SSRF (#4670)
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Amir Raminfar
2026-05-03 06:49:48 -07:00
committed by GitHub
parent 543f96198f
commit fa7479e1ec
2 changed files with 183 additions and 4 deletions
+72 -4
View File
@@ -4,9 +4,12 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"text/template"
"time"
@@ -15,6 +18,53 @@ import (
"github.com/rs/zerolog/log"
)
// errBlockedAddress is returned when a webhook URL resolves to a blocked
// address range. Loopback and link-local addresses are refused to prevent SSRF
// against the Dozzle host's own services and cloud metadata endpoints
// (e.g. 169.254.169.254). RFC1918 private ranges are intentionally allowed —
// self-hosted webhooks (Home Assistant, internal Mattermost, etc.) commonly
// live on private LANs.
var errBlockedAddress = errors.New("webhook target resolves to a blocked address range")
func isBlockedIP(ip net.IP) bool {
return ip.IsLoopback() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsMulticast() ||
ip.IsInterfaceLocalMulticast() ||
ip.IsUnspecified()
}
func safeDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
if err != nil {
return nil, err
}
var dialer net.Dialer
var lastErr error
for _, ip := range ips {
if isBlockedIP(ip) {
lastErr = errBlockedAddress
continue
}
conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
if err == nil {
return conn, nil
}
lastErr = err
}
if lastErr == nil {
lastErr = errBlockedAddress
}
return nil, lastErr
}
// UserAgent is set by the application at startup
var UserAgent = "Dozzle/head"
@@ -30,14 +80,28 @@ type WebhookDispatcher struct {
// NewWebhookDispatcher creates a new webhook dispatcher
// If templateStr is empty, the notification will be marshaled as JSON directly
func NewWebhookDispatcher(name, url, templateStr string, headers map[string]string) (*WebhookDispatcher, error) {
func NewWebhookDispatcher(name, rawURL, templateStr string, headers map[string]string) (*WebhookDispatcher, error) {
parsed, err := url.Parse(rawURL)
if err != nil {
return nil, fmt.Errorf("invalid webhook URL: %w", err)
}
if scheme := strings.ToLower(parsed.Scheme); scheme != "http" && scheme != "https" {
return nil, fmt.Errorf("invalid webhook URL scheme %q: only http and https are allowed", parsed.Scheme)
}
w := &WebhookDispatcher{
Name: name,
URL: url,
URL: rawURL,
TemplateText: templateStr,
Headers: headers,
client: &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
DialContext: safeDialContext,
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
},
},
}
@@ -98,12 +162,16 @@ func (w *WebhookDispatcher) SendTest(ctx context.Context, notification types.Not
resp, err := w.client.Do(req)
if err != nil {
if errors.Is(err, errBlockedAddress) {
return TestResult{Success: false, Error: errBlockedAddress.Error()}
}
return TestResult{Success: false, Error: fmt.Sprintf("failed to send webhook: %v", err)}
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// Limit response body to 1MB to prevent memory exhaustion
// Limit response body to 1MB; only used for operator-side debug logging,
// never reflected back through the API response (would be an SSRF exfil sink).
limitedReader := io.LimitReader(resp.Body, 1024*1024)
responseBody, _ := io.ReadAll(limitedReader)
log.Debug().
@@ -116,7 +184,7 @@ func (w *WebhookDispatcher) SendTest(ctx context.Context, notification types.Not
return TestResult{
Success: false,
StatusCode: resp.StatusCode,
Error: fmt.Sprintf("webhook returned status code %d: %s", resp.StatusCode, string(responseBody)),
Error: fmt.Sprintf("webhook returned status code %d", resp.StatusCode),
}
}
@@ -1,7 +1,12 @@
package dispatcher
import (
"context"
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
@@ -151,3 +156,109 @@ func TestExecuteJSONTemplate_InvalidJSONFallsBackToTextTemplate(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, "my-container: some log", string(payload))
}
func TestNewWebhookDispatcher_RejectsNonHTTPSchemes(t *testing.T) {
cases := []string{
"file:///etc/passwd",
"gopher://example.com/",
"ftp://example.com/",
"javascript:alert(1)",
}
for _, raw := range cases {
_, err := NewWebhookDispatcher("t", raw, "", nil)
assert.Error(t, err, "scheme %q should be rejected", raw)
}
}
func TestNewWebhookDispatcher_AcceptsHTTPAndHTTPS(t *testing.T) {
for _, raw := range []string{"http://example.com/hook", "https://example.com/hook", "HTTP://example.com/hook"} {
_, err := NewWebhookDispatcher("t", raw, "", nil)
assert.NoError(t, err, "scheme in %q should be allowed", raw)
}
}
func TestSendTest_RejectsLoopbackTarget(t *testing.T) {
w, err := NewWebhookDispatcher("t", "http://127.0.0.1:1/hook", "", nil)
require.NoError(t, err)
result := w.SendTest(context.Background(), newTestNotification("x"))
assert.False(t, result.Success)
assert.Contains(t, result.Error, "blocked address range")
}
func TestSendTest_RejectsLinkLocalTarget(t *testing.T) {
w, err := NewWebhookDispatcher("t", "http://169.254.169.254/latest/meta-data/", "", nil)
require.NoError(t, err)
result := w.SendTest(context.Background(), newTestNotification("x"))
assert.False(t, result.Success)
assert.Contains(t, result.Error, "blocked address range")
}
// TestSendTest_DoesNotReflectResponseBody confirms that an attacker controlling
// a webhook target that returns non-2xx with sensitive body content cannot
// recover that body through the TestResult.Error field.
func TestSendTest_DoesNotReflectResponseBody(t *testing.T) {
const secret = "SUPER_SECRET_TOKEN_abcdef123"
srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
rw.WriteHeader(http.StatusUnauthorized)
rw.Write([]byte(secret))
}))
defer srv.Close()
w, err := NewWebhookDispatcher("t", srv.URL, "", nil)
require.NoError(t, err)
// Allow loopback for this test only by swapping in a default transport.
w.client = &http.Client{Timeout: 5 * time.Second}
result := w.SendTest(context.Background(), newTestNotification("x"))
assert.False(t, result.Success)
require.NotNil(t, result.StatusCode)
assert.Equal(t, http.StatusUnauthorized, result.StatusCode)
assert.NotContains(t, result.Error, secret, "response body must not leak into Error")
}
func TestIsBlockedIP(t *testing.T) {
blocked := []string{
"127.0.0.1",
"::1",
"169.254.169.254",
"fe80::1",
"224.0.0.1",
"0.0.0.0",
}
for _, s := range blocked {
ip := net.ParseIP(s)
require.NotNil(t, ip, s)
assert.True(t, isBlockedIP(ip), "%s should be blocked", s)
}
allowed := []string{
"192.168.1.50",
"10.0.0.5",
"172.16.5.10",
"8.8.8.8",
"2606:4700:4700::1111",
}
for _, s := range allowed {
ip := net.ParseIP(s)
require.NotNil(t, ip, s)
assert.False(t, isBlockedIP(ip), "%s should be allowed", s)
}
}
// guard against accidental reintroduction of the body in the Error field
func TestSendTest_ErrorOmitsResponseBodySubstring(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
rw.WriteHeader(http.StatusInternalServerError)
rw.Write([]byte("internal-marker-9f7c"))
}))
defer srv.Close()
w, err := NewWebhookDispatcher("t", srv.URL, "", nil)
require.NoError(t, err)
w.client = &http.Client{Timeout: 5 * time.Second}
result := w.SendTest(context.Background(), newTestNotification("x"))
assert.False(t, strings.Contains(result.Error, "internal-marker"))
}