mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-09-19 17:50:12 +00:00
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>
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user