Files
nezha_domains/pkg/ddns/webhook/webhook.go
T
naibaandnaiba/CloudCode e7c2e453c0 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

200 lines
4.6 KiB
Go

package webhook
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/libdns/libdns"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/pkg/utils"
)
const (
_ = iota
methodGET
methodPOST
methodPATCH
methodDELETE
methodPUT
)
const (
_ = iota
requestTypeJSON
requestTypeForm
)
var requestTypes = map[uint8]string{
methodGET: "GET",
methodPOST: "POST",
methodPATCH: "PATCH",
methodDELETE: "DELETE",
methodPUT: "PUT",
}
// Internal use
type Provider struct {
ipAddr string
ipType string
recordType string
domain string
DDNSProfile *model.DDNSProfile
}
func (provider *Provider) SetRecords(ctx context.Context, zone string,
recs []libdns.Record) ([]libdns.Record, error) {
for _, rec := range recs {
switch rec.(type) {
case libdns.Address:
rr := rec.RR()
provider.recordType = rr.Type
provider.ipType = recordToIPType(provider.recordType)
provider.ipAddr = rr.Data
provider.domain = fmt.Sprintf("%s.%s", rr.Name, strings.TrimSuffix(zone, "."))
// 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)
}
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)
}
}
return recs, nil
}
func (provider *Provider) prepareRequest(ctx context.Context) (*http.Request, *http.Client, error) {
u, err := provider.reqUrl()
if err != nil {
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, nil, err
}
headers, err := utils.GjsonIter(
provider.formatWebhookString(provider.DDNSProfile.WebhookHeaders))
if err != nil {
return nil, nil, err
}
req, err := http.NewRequestWithContext(ctx, requestTypes[provider.DDNSProfile.WebhookMethod], u.String(), strings.NewReader(body))
if err != nil {
return nil, nil, err
}
provider.setContentType(req)
for k, v := range headers {
req.Header.Set(k, v)
}
return req, client, nil
}
func (provider *Provider) setContentType(req *http.Request) {
if provider.DDNSProfile.WebhookMethod == methodGET {
return
}
if provider.DDNSProfile.WebhookRequestType == requestTypeForm {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
} else {
req.Header.Set("Content-Type", "application/json")
}
}
func (provider *Provider) reqUrl() (*url.URL, error) {
formattedUrl := strings.ReplaceAll(provider.DDNSProfile.WebhookURL, "#", "%23")
u, err := url.Parse(formattedUrl)
if err != nil {
return nil, err
}
// Only handle queries here
q := u.Query()
for p, vals := range q {
for n, v := range vals {
vals[n] = provider.formatWebhookString(v)
}
q[p] = vals
}
u.RawQuery = q.Encode()
return u, nil
}
func (provider *Provider) reqBody() (string, error) {
if provider.DDNSProfile.WebhookMethod == methodGET ||
provider.DDNSProfile.WebhookMethod == methodDELETE {
return "", nil
}
switch provider.DDNSProfile.WebhookRequestType {
case requestTypeJSON:
return provider.formatWebhookString(provider.DDNSProfile.WebhookRequestBody), nil
case requestTypeForm:
data, err := utils.GjsonIter(provider.DDNSProfile.WebhookRequestBody)
if err != nil {
return "", err
}
params := url.Values{}
for k, v := range data {
params.Add(k, provider.formatWebhookString(v))
}
return params.Encode(), nil
default:
return "", errors.New("request type not supported")
}
}
func (provider *Provider) formatWebhookString(s string) string {
r := strings.NewReplacer(
"#ip#", provider.ipAddr,
"#domain#", provider.domain,
"#type#", provider.ipType,
"#record#", provider.recordType,
"#access_id#", provider.DDNSProfile.AccessID,
"#access_secret#", provider.DDNSProfile.AccessSecret,
"\r", "",
)
result := r.Replace(strings.TrimSpace(s))
return result
}
func recordToIPType(record string) string {
switch record {
case "A":
return "ipv4"
case "AAAA":
return "ipv6"
default:
return ""
}
}