fix(sec): санитизация Telegram-ошибок, SSRF-guard Webhook, чистка логов test-канала, go mod tidy, histogram-бакеты
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user