Files
nezha_domains/pkg/ddns/webhook/webhook_test.go
T
naibaandnaiba/CloudCode ea7ad67f03 fix(ddns): apply SSRF defense to webhook provider
GHSA-6x26-5727-rrm9: a low-privilege member could point a DDNS webhook
at internal or loopback hosts and the dashboard would dial them with the
unrestricted utils.HttpClient.

Extract the notification SSRF defenses (CIDR blocklist, IP-pin DialContext,
SNI preservation, redirect rejection) into reusable helpers in pkg/utils
(NewRestrictedHTTPClient / ResolveAllowedHTTPURL / buildRestrictedHTTPClient)
and route the DDNS webhook through the same path. Replace the notification
inline implementation with a thin wrapper to keep behaviour identical.

Side improvements collected by the refactor:
- prepareRequest now resolves DNS once and returns the paired client, so
  the dialer's pinned IP and the validated URL stay in sync (no more
  double resolution between prepareRequest and SetRecords).
- response body is drained and closed.
- HttpClient / HttpClientSkipTlsVerify are explicitly tagged unsafe for
  attacker-controlled URLs.

Tests cover: hermetic SNI preservation, redirect rejection, dial pin to
the vetted IP, the full blocked-CIDR list at the webhook entry point,
and the verifyTLS↔skipVerifyTLS inversion in the notification wrapper.

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
2026-05-18 15:14:27 +00:00

173 lines
4.2 KiB
Go

package webhook
import (
"context"
"strings"
"testing"
"github.com/nezhahq/nezha/model"
)
var (
reqTypeForm = "application/x-www-form-urlencoded"
reqTypeJSON = "application/json"
)
type testSt struct {
profile model.DDNSProfile
expectURL string
expectBody string
expectContentType string
expectHeader map[string]string
}
func execCase(t *testing.T, item testSt) {
pw := Provider{DDNSProfile: &item.profile}
pw.ipAddr = "1.1.1.1"
pw.domain = item.profile.Domains[0]
pw.ipType = "ipv4"
pw.recordType = "A"
pw.DDNSProfile = &item.profile
reqUrl, err := pw.reqUrl()
if err != nil {
t.Fatalf("Error: %s", err)
}
if item.expectURL != reqUrl.String() {
t.Fatalf("Expected %s, but got %s", item.expectURL, reqUrl.String())
}
reqBody, err := pw.reqBody()
if err != nil {
t.Fatalf("Error: %s", err)
}
if item.expectBody != reqBody {
t.Fatalf("Expected %s, but got %s", item.expectBody, reqBody)
}
req, _, err := pw.prepareRequest(context.Background())
if err != nil {
t.Fatalf("Error: %s", err)
}
if item.expectContentType != req.Header.Get("Content-Type") {
t.Fatalf("Expected %s, but got %s", item.expectContentType, req.Header.Get("Content-Type"))
}
for k, v := range item.expectHeader {
if v != req.Header.Get(k) {
t.Fatalf("Expected %s, but got %s", v, req.Header.Get(k))
}
}
}
func TestWebhookRequest(t *testing.T) {
ipv4 := true
cases := []testSt{
{
profile: model.DDNSProfile{
Domains: []string{"www.example.com"},
MaxRetries: 1,
EnableIPv4: &ipv4,
WebhookURL: "http://1.1.1.1/?ip=#ip#",
WebhookMethod: methodGET,
WebhookHeaders: `{"ip":"#ip#","record":"#record#"}`,
},
expectURL: "http://1.1.1.1/?ip=1.1.1.1",
expectContentType: "",
expectHeader: map[string]string{
"ip": "1.1.1.1",
"record": "A",
},
},
{
profile: model.DDNSProfile{
Domains: []string{"www.example.com"},
MaxRetries: 1,
EnableIPv4: &ipv4,
WebhookURL: "http://1.1.1.1/api",
WebhookMethod: methodPOST,
WebhookRequestType: requestTypeJSON,
WebhookRequestBody: `{"ip":"#ip#","record":"#record#"}`,
},
expectURL: "http://1.1.1.1/api",
expectContentType: reqTypeJSON,
expectBody: `{"ip":"1.1.1.1","record":"A"}`,
},
{
profile: model.DDNSProfile{
Domains: []string{"www.example.com"},
MaxRetries: 1,
EnableIPv4: &ipv4,
WebhookURL: "http://1.1.1.1/api",
WebhookMethod: methodPOST,
WebhookRequestType: requestTypeForm,
WebhookRequestBody: `{"ip":"#ip#","record":"#record#"}`,
},
expectURL: "http://1.1.1.1/api",
expectContentType: reqTypeForm,
expectBody: "ip=1.1.1.1&record=A",
},
}
for _, c := range cases {
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())
}
})
}
}