fix(sec): санитизация Telegram-ошибок, SSRF-guard Webhook, чистка логов test-канала, go mod tidy, histogram-бакеты

This commit is contained in:
2026-07-04 13:40:29 +07:00
parent 5a2903ca1e
commit 29f448d4b5
8 changed files with 197 additions and 6 deletions
+80 -2
View File
@@ -55,6 +55,30 @@ func TestTelegramSendServerError(t *testing.T) {
}
}
func TestTelegramSendTransportErrorDoesNotLeakSecret(t *testing.T) {
// Bind and immediately close a server: its address is now unreachable
// (connection refused), which makes http.Client.Do return a *url.Error
// whose Error() embeds the full request URL — including /bot<secret>/.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
deadURL := srv.URL
srv.Close()
tg := &Telegram{BaseURL: deadURL, HTTP: srv.Client()}
ev := Event{Project: "proj", Domain: "example.com", Status: "drift", Summary: "x", At: time.Now()}
const secret = "super-secret-bot-token"
err := tg.Send(context.Background(), json.RawMessage(`{"chat_id":"1"}`), secret, ev)
if err == nil {
t.Fatal("expected error for unreachable host")
}
if strings.Contains(err.Error(), secret) {
t.Fatalf("error leaks secret: %v", err)
}
if strings.Contains(err.Error(), deadURL) {
t.Fatalf("error leaks request URL: %v", err)
}
}
func TestWebhookSendSuccess(t *testing.T) {
var gotEvent Event
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -66,7 +90,10 @@ func TestWebhookSendSuccess(t *testing.T) {
}))
defer srv.Close()
wh := &Webhook{HTTP: srv.Client()}
// allowPrivate: true — httptest.Server listens on 127.0.0.1, which the
// SSRF guard would otherwise reject; production dispatchers never set
// this (see TestIsAllowedURL / TestNewDispatcherDoesNotAllowPrivate).
wh := &Webhook{HTTP: srv.Client(), allowPrivate: true}
ev := Event{Project: "proj", Domain: "example.com", Status: "in_sync", Summary: "resolved", At: time.Now()}
cfg, _ := json.Marshal(map[string]string{"url": srv.URL})
@@ -84,7 +111,7 @@ func TestWebhookSendNonSuccessStatus(t *testing.T) {
}))
defer srv.Close()
wh := &Webhook{HTTP: srv.Client()}
wh := &Webhook{HTTP: srv.Client(), allowPrivate: true}
ev := Event{Project: "proj", Domain: "example.com", Status: "drift", Summary: "x", At: time.Now()}
cfg, _ := json.Marshal(map[string]string{"url": srv.URL})
@@ -93,6 +120,51 @@ func TestWebhookSendNonSuccessStatus(t *testing.T) {
}
}
func TestWebhookSendRejectsPrivateDestinationByDefault(t *testing.T) {
wh := &Webhook{HTTP: http.DefaultClient} // allowPrivate not set: SSRF guard active
ev := Event{Project: "proj", Domain: "example.com", Status: "drift", Summary: "x", At: time.Now()}
cfg, _ := json.Marshal(map[string]string{"url": "http://127.0.0.1:1/hook"})
err := wh.Send(context.Background(), cfg, "", ev)
if err == nil {
t.Fatal("expected error for loopback destination")
}
if !strings.Contains(err.Error(), "destination not allowed") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestIsAllowedURL(t *testing.T) {
cases := []struct {
name string
rawurl string
allowed bool
}{
{"localhost hostname", "http://localhost/hook", false},
{"loopback ip", "http://127.0.0.1/hook", false},
{"loopback ipv6", "http://[::1]/hook", false},
{"link-local metadata", "http://169.254.169.254/latest/meta-data", false},
{"private class a", "http://10.0.0.1/hook", false},
{"private class c", "http://192.168.1.1/hook", false},
{"private class b", "http://172.16.0.1/hook", false},
{"unspecified", "http://0.0.0.0/hook", false},
{"multicast", "http://224.0.0.1/hook", false},
{"non-http scheme", "ftp://example.com/hook", false},
{"public ip", "http://93.184.216.34/hook", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := isAllowedURL(tc.rawurl)
if tc.allowed && err != nil {
t.Fatalf("expected %q to be allowed, got error: %v", tc.rawurl, err)
}
if !tc.allowed && err == nil {
t.Fatalf("expected %q to be rejected, got nil error", tc.rawurl)
}
})
}
}
// --- Dispatcher ---
type mockChannelStore struct {
@@ -156,6 +228,9 @@ func TestDispatcherSendsToAllChannelsAndAggregatesErrors(t *testing.T) {
tg := &Telegram{BaseURL: tgSrv.URL, HTTP: tgSrv.Client()}
return tg.Send(ctx, cfg, secret, ev)
})
// httptest servers listen on loopback, which the SSRF guard rejects by
// default; swap in an allowPrivate webhook so this test can still hit it.
d.byType["webhook"] = &Webhook{HTTP: whSrv.Client(), allowPrivate: true}
ev := Event{Project: "proj", Domain: "example.com", Status: "drift", Summary: "changed", At: time.Now()}
err := d.Send(context.Background(), projectID, ev)
@@ -199,6 +274,9 @@ func TestDispatcherDecryptFailureIsAggregatedNotFatal(t *testing.T) {
{ID: uuid.New(), ProjectID: projectID, Type: "webhook", Config: json.RawMessage(`{"url":"` + whSrv.URL + `"}`), Enabled: true},
}
d := NewDispatcher(&mockChannelStore{channels: channels}, &mockDecryptor{fail: true})
// httptest servers listen on loopback, which the SSRF guard rejects by
// default; swap in an allowPrivate webhook so this test can still hit it.
d.byType["webhook"] = &Webhook{HTTP: whSrv.Client(), allowPrivate: true}
err := d.Send(context.Background(), projectID, Event{Project: "p", Domain: "d", Status: "drift"})
if err == nil {