fix(sec): webhook SSRF-guard через Dialer.Control (закрытие DNS-rebinding TOCTOU)
This commit is contained in:
@@ -165,6 +165,77 @@ func TestIsAllowedURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialControlBlocksActualConnectingAddress(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
address string
|
||||
blocked bool
|
||||
}{
|
||||
{"loopback v4", "127.0.0.1:80", true},
|
||||
{"loopback v6", "[::1]:80", true},
|
||||
{"metadata link-local", "169.254.169.254:80", true},
|
||||
{"private class a", "10.0.0.1:80", true},
|
||||
{"private class b", "172.16.0.1:80", true},
|
||||
{"private class c", "192.168.1.1:80", true},
|
||||
{"unspecified", "0.0.0.0:80", true},
|
||||
{"multicast", "224.0.0.1:80", true},
|
||||
{"public ip", "93.184.216.34:443", false},
|
||||
}
|
||||
control := dialControl(false)
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := control("tcp", tc.address, nil)
|
||||
if tc.blocked && err == nil {
|
||||
t.Fatalf("expected %q to be blocked", tc.address)
|
||||
}
|
||||
if !tc.blocked && err != nil {
|
||||
t.Fatalf("expected %q to be allowed, got: %v", tc.address, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialControlAllowsEverythingWhenAllowPrivate(t *testing.T) {
|
||||
control := dialControl(true)
|
||||
if err := control("tcp", "127.0.0.1:80", nil); err != nil {
|
||||
t.Fatalf("expected allowPrivate to skip the dial guard, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhookControlBlocksConnectionEvenWhenPreCheckPasses simulates the
|
||||
// DNS-rebinding TOCTOU: allowPrivate=true skips the pre-request isAllowedURL
|
||||
// check (standing in for a rebinding attacker answering a public IP to that
|
||||
// lookup), but the Transport's Control func — wired independently of
|
||||
// Webhook.allowPrivate — still inspects the literal address the dialer
|
||||
// connects to and must still reject it. If Control did not exist, this
|
||||
// request would reach the httptest handler; it must not.
|
||||
func TestWebhookControlBlocksConnectionEvenWhenPreCheckPasses(t *testing.T) {
|
||||
var handlerCalled bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handlerCalled = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
wh := &Webhook{
|
||||
HTTP: &http.Client{Transport: newWebhookTransport(false)},
|
||||
allowPrivate: true, // pre-check bypassed on purpose; Control is not
|
||||
}
|
||||
ev := Event{Project: "proj", Domain: "example.com", Status: "drift", Summary: "x", At: time.Now()}
|
||||
cfg, _ := json.Marshal(map[string]string{"url": srv.URL})
|
||||
|
||||
err := wh.Send(context.Background(), cfg, "", ev)
|
||||
if err == nil {
|
||||
t.Fatal("expected error: Control should have blocked the loopback connection")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "destination not allowed") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if handlerCalled {
|
||||
t.Fatal("Control should have rejected the dial before the handler ran")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Dispatcher ---
|
||||
|
||||
type mockChannelStore struct {
|
||||
|
||||
Reference in New Issue
Block a user