Merge branch 'upstream/master' into master and preserve domain extensions

This commit is contained in:
Bot
2026-08-31 02:46:42 +08:00
758 changed files with 89269 additions and 1366 deletions
+14 -5
View File
@@ -54,16 +54,25 @@ func (provider *Provider) updateDomain(ctx context.Context, domain string) error
return err
}
// 当IPv4和IPv6同时成功才算作成功
// 独立处理 IPv4 更新
if *provider.DDNSProfile.EnableIPv4 {
if err = provider.addDomainRecord(ctx, "A", provider.IPAddrs.IPv4Addr); err != nil {
return err
if provider.IPAddrs.IPv4Addr == "" {
log.Printf("NEZHA>> Skip IPv4 update for domain %s: IPv4 address is empty", domain)
} else {
if err = provider.addDomainRecord(ctx, "A", provider.IPAddrs.IPv4Addr); err != nil {
return err
}
}
}
// 独立处理 IPv6 更新
if *provider.DDNSProfile.EnableIPv6 {
if err = provider.addDomainRecord(ctx, "AAAA", provider.IPAddrs.IPv6Addr); err != nil {
return err
if provider.IPAddrs.IPv6Addr == "" {
log.Printf("NEZHA>> Skip IPv6 update for domain %s: IPv6 address is empty", domain)
} else {
if err = provider.addDomainRecord(ctx, "AAAA", provider.IPAddrs.IPv6Addr); err != nil {
return err
}
}
}
+21 -8
View File
@@ -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) {
+63 -7
View File
@@ -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())
}
})
}
}