fix(phase3): skip templateless domains in scheduler; block CGNAT range in webhook SSRF guard

Domains imported without a template (TemplateID == nil) are a valid,
unconfigured state, not a failure — RunOnce now skips them before
calling checkDomain instead of letting LoadDomain's "no template" error
turn into StatusError and a spammy unknown->error notification.

isBlockedIP now also rejects 100.64.0.0/10 (RFC 6598 carrier-grade
NAT), which net.IP.IsPrivate() does not cover, closing an SSRF gap in
the webhook destination guard (both the pre-request check and the
per-dial check use isBlockedIP).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
This commit is contained in:
2026-07-04 14:58:09 +07:00
parent 34422420ca
commit 504c4c081f
4 changed files with 117 additions and 9 deletions
+26
View File
@@ -3,6 +3,7 @@ package notify
import (
"context"
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"strings"
@@ -195,6 +196,31 @@ func TestDialControlBlocksActualConnectingAddress(t *testing.T) {
}
}
func TestIsBlockedIPCGNATRange(t *testing.T) {
cases := []struct {
name string
ip string
blocked bool
}{
{"cgnat start", "100.64.0.1", true},
{"cgnat end", "100.127.255.255", true},
{"just below cgnat", "100.63.255.255", false},
{"just above cgnat", "100.128.0.0", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ip := net.ParseIP(tc.ip)
if ip == nil {
t.Fatalf("failed to parse %q", tc.ip)
}
got := isBlockedIP(ip)
if got != tc.blocked {
t.Fatalf("isBlockedIP(%q) = %v, want %v", tc.ip, got, tc.blocked)
}
})
}
}
func TestDialControlAllowsEverythingWhenAllowPrivate(t *testing.T) {
control := dialControl(true)
if err := control("tcp", "127.0.0.1:80", nil); err != nil {
+21 -4
View File
@@ -83,17 +83,34 @@ func isAllowedURL(rawurl string) error {
return nil
}
// cgnatBlock is the shared address space reserved for carrier-grade NAT
// (RFC 6598, 100.64.0.0/10). net.IP.IsPrivate() only covers RFC1918/RFC4193
// and does not treat this range as private, so it must be checked
// explicitly or CGNAT-addressed internal services would be reachable via
// webhook SSRF.
var cgnatBlock = func() *net.IPNet {
_, block, err := net.ParseCIDR("100.64.0.0/10")
if err != nil {
panic(err)
}
return block
}()
// isBlockedIP reports whether ip must never be connected to: loopback,
// private (RFC1918 etc.), link-local, unspecified, or multicast. Used both
// by isAllowedURL's pre-request check and by dialControl's per-connection
// check.
// private (RFC1918 etc.), link-local, unspecified, multicast, or
// carrier-grade NAT (RFC 6598). Used both by isAllowedURL's pre-request
// check and by dialControl's per-connection check.
func isBlockedIP(ip net.IP) bool {
if v4 := ip.To4(); v4 != nil {
ip = v4
}
return ip.IsLoopback() ||
ip.IsPrivate() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsUnspecified() ||
ip.IsMulticast()
ip.IsMulticast() ||
cgnatBlock.Contains(ip)
}
// dialControl returns a net.Dialer.Control function enforcing the SSRF guard