mirror of
https://github.com/amir20/dozzle.git
synced 2026-08-07 10:14:44 +00:00
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
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:
@@ -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"))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user