This is a large squashed commit covering two batches of prior uncommitted work plus a full security-audit remediation pass, kept together because go.mod/go.sum and several shared files (main.go, handler.go) were touched by both and splitting risked non-building intermediate commits. Features (built earlier, previously uncommitted): - Local username/password login for single-tenant deployments with no SSO configured (api/localauth, alerting/internal/sessioncheck, sentryctl users, web/src/routes/login, metadata migrations 0040/0041). - Remotely-editable additional log file paths for agents, on top of their existing primary source (api/agents, agent/sentry-agent extra-file-path diffing, web agent config UI). - IPv4/IPv6 addresses reported alongside other host system metrics. Security audit remediation (this pass, all live-verified in production): - Critical: block ClickHouse SSRF table functions (url/remote/file/s3/...) in the raw-SQL query escape hatch. - High: deny sensitive paths and require Admin to add agent extra_file_paths (Editor could previously point an agent at /etc/shadow or an SSH key); alerting webhook targets now validate against internal/metadata/loopback addresses, both at creation and send time; alerting's session middleware now enforces an Editor+ floor on mutating requests instead of "any authenticated session"; bumped goxmldsig to close a SAML signature-verification bypass (GO-2026-4753). - Medium: per-IP login rate limiting; security response headers (HSTS/CSP/nosniff/X-Frame-Options/Referrer-Policy/Permissions-Policy) on web/nginx.conf; a DevCredentialWarnings check in every Go service's config loader, logging loudly at startup if a deployment is still on docker-compose.yml's literal dev-only credentials; dependency bumps (golang.org/x/text, grpc, x/net, quick-xml, h2) across every affected Go module and both Rust crates, including a previously-uncovered x/net vulnerability in deploy/operator; a new security-scan.yml CI workflow running cargo-deny/govulncheck/npm-audit, mirroring the existing license-compliance.yml matrix shape. - Low: removed sentryctl's plaintext --password flag (shell history/`ps` exposure) in favor of stdin and a --password-stdin flag for reset-password's optional specific-password path; a dummy bcrypt comparison closes a login response-time username-enumeration side-channel.
279 lines
9.7 KiB
Go
279 lines
9.7 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/sentry/sentry/alerting/internal/notifystore"
|
|
"github.com/sentry/sentry/alerting/internal/rulestore"
|
|
)
|
|
|
|
type fakeRuleStore struct {
|
|
rules map[string]*rulestore.RuleWithState
|
|
}
|
|
|
|
func newFakeRuleStore() *fakeRuleStore {
|
|
return &fakeRuleStore{rules: map[string]*rulestore.RuleWithState{}}
|
|
}
|
|
|
|
func (f *fakeRuleStore) Create(_ context.Context, r *rulestore.Rule) error {
|
|
r.ID = "rule-1"
|
|
f.rules[r.ID] = &rulestore.RuleWithState{Rule: *r, State: rulestore.AlertState{RuleID: r.ID, State: rulestore.StateOK}}
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeRuleStore) List(_ context.Context) ([]rulestore.RuleWithState, error) {
|
|
var out []rulestore.RuleWithState
|
|
for _, r := range f.rules {
|
|
out = append(out, *r)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeRuleStore) Get(_ context.Context, id string) (*rulestore.RuleWithState, error) {
|
|
r, ok := f.rules[id]
|
|
if !ok {
|
|
return nil, rulestore.ErrNotFound
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
func (f *fakeRuleStore) Delete(_ context.Context, id string) error {
|
|
if _, ok := f.rules[id]; !ok {
|
|
return rulestore.ErrNotFound
|
|
}
|
|
delete(f.rules, id)
|
|
return nil
|
|
}
|
|
|
|
type fakeTargetStore struct {
|
|
targets map[string]*notifystore.Target
|
|
}
|
|
|
|
func newFakeTargetStore() *fakeTargetStore {
|
|
return &fakeTargetStore{targets: map[string]*notifystore.Target{}}
|
|
}
|
|
|
|
func (f *fakeTargetStore) Create(_ context.Context, t *notifystore.Target) error {
|
|
t.ID = "target-1"
|
|
f.targets[t.ID] = t
|
|
return nil
|
|
}
|
|
func (f *fakeTargetStore) List(_ context.Context) ([]notifystore.Target, error) {
|
|
var out []notifystore.Target
|
|
for _, t := range f.targets {
|
|
out = append(out, *t)
|
|
}
|
|
return out, nil
|
|
}
|
|
func (f *fakeTargetStore) Get(_ context.Context, id string) (*notifystore.Target, error) {
|
|
t, ok := f.targets[id]
|
|
if !ok {
|
|
return nil, notifystore.ErrNotFound
|
|
}
|
|
return t, nil
|
|
}
|
|
func (f *fakeTargetStore) Delete(_ context.Context, id string) error {
|
|
if _, ok := f.targets[id]; !ok {
|
|
return notifystore.ErrNotFound
|
|
}
|
|
delete(f.targets, id)
|
|
return nil
|
|
}
|
|
|
|
type fakeDeliveryReader struct {
|
|
entries []rulestore.DeliveryLogEntry
|
|
}
|
|
|
|
func (f *fakeDeliveryReader) ListForRule(_ context.Context, _ string, _ int) ([]rulestore.DeliveryLogEntry, error) {
|
|
return f.entries, nil
|
|
}
|
|
|
|
func newTestMux(rules ruleStore, targets targetStore, deliveries deliveryReader) *http.ServeMux {
|
|
h := NewHandler(slog.New(slog.NewTextHandler(io.Discard, nil)), rules, targets, deliveries)
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
return mux
|
|
}
|
|
|
|
func doRequest(t *testing.T, mux *http.ServeMux, method, path, body string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
var r io.Reader
|
|
if body != "" {
|
|
r = strings.NewReader(body)
|
|
}
|
|
req := httptest.NewRequest(method, path, r)
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
func TestCreateThresholdRule(t *testing.T) {
|
|
targets := newFakeTargetStore()
|
|
targets.targets["target-1"] = ¬ifystore.Target{ID: "target-1"}
|
|
mux := newTestMux(newFakeRuleStore(), targets, &fakeDeliveryReader{})
|
|
|
|
rec := doRequest(t, mux, http.MethodPost, "/rules", `{
|
|
"name": "High error rate", "query": "service=api | where status>=500 | stats count",
|
|
"condition_type": "threshold", "comparator": "gt", "threshold_value": 100,
|
|
"eval_interval_seconds": 60, "notification_target_id": "target-1"
|
|
}`)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestCreateRuleDefaultsToEnabledWhenOmitted guards against a real bug
|
|
// caught by actually calling this endpoint: a plain `bool` JSON field
|
|
// can't distinguish "omitted" from "explicitly false," and Go's zero
|
|
// value for bool is false -- without createRuleRequest's *bool handling,
|
|
// a create request that simply didn't mention "enabled" silently created
|
|
// a rule the evaluator's claim query would never pick up.
|
|
func TestCreateRuleDefaultsToEnabledWhenOmitted(t *testing.T) {
|
|
rules := newFakeRuleStore()
|
|
mux := newTestMux(rules, newFakeTargetStore(), &fakeDeliveryReader{})
|
|
|
|
rec := doRequest(t, mux, http.MethodPost, "/rules", `{
|
|
"name": "no enabled field", "query": "service=api", "condition_type": "absence",
|
|
"eval_interval_seconds": 60, "notification_target_id": "target-1"
|
|
}`)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if !rules.rules["rule-1"].Enabled {
|
|
t.Fatalf("expected a rule created without an explicit \"enabled\" field to default to enabled=true")
|
|
}
|
|
}
|
|
|
|
func TestCreateRuleRespectsExplicitDisabled(t *testing.T) {
|
|
rules := newFakeRuleStore()
|
|
mux := newTestMux(rules, newFakeTargetStore(), &fakeDeliveryReader{})
|
|
|
|
rec := doRequest(t, mux, http.MethodPost, "/rules", `{
|
|
"name": "explicitly disabled", "query": "service=api", "condition_type": "absence",
|
|
"eval_interval_seconds": 60, "notification_target_id": "target-1", "enabled": false
|
|
}`)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if rules.rules["rule-1"].Enabled {
|
|
t.Fatalf("expected an explicit \"enabled\": false to be respected")
|
|
}
|
|
}
|
|
|
|
func TestCreateThresholdRuleRejectsMissingComparator(t *testing.T) {
|
|
mux := newTestMux(newFakeRuleStore(), newFakeTargetStore(), &fakeDeliveryReader{})
|
|
rec := doRequest(t, mux, http.MethodPost, "/rules", `{
|
|
"name": "bad rule", "query": "service=api", "condition_type": "threshold",
|
|
"eval_interval_seconds": 60, "notification_target_id": "target-1"
|
|
}`)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCreateAbsenceRuleDoesNotRequireComparator(t *testing.T) {
|
|
mux := newTestMux(newFakeRuleStore(), newFakeTargetStore(), &fakeDeliveryReader{})
|
|
rec := doRequest(t, mux, http.MethodPost, "/rules", `{
|
|
"name": "no heartbeat", "query": "service=payments earliest=-5m", "condition_type": "absence",
|
|
"eval_interval_seconds": 60, "notification_target_id": "target-1"
|
|
}`)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCreateRuleRejectsShortInterval(t *testing.T) {
|
|
mux := newTestMux(newFakeRuleStore(), newFakeTargetStore(), &fakeDeliveryReader{})
|
|
rec := doRequest(t, mux, http.MethodPost, "/rules", `{
|
|
"name": "too fast", "query": "service=api", "condition_type": "absence",
|
|
"eval_interval_seconds": 5, "notification_target_id": "target-1"
|
|
}`)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestGetRuleNotFound(t *testing.T) {
|
|
mux := newTestMux(newFakeRuleStore(), newFakeTargetStore(), &fakeDeliveryReader{})
|
|
rec := doRequest(t, mux, http.MethodGet, "/rules/nope", "")
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestDeleteRule(t *testing.T) {
|
|
rules := newFakeRuleStore()
|
|
rules.rules["rule-1"] = &rulestore.RuleWithState{Rule: rulestore.Rule{ID: "rule-1"}}
|
|
mux := newTestMux(rules, newFakeTargetStore(), &fakeDeliveryReader{})
|
|
|
|
rec := doRequest(t, mux, http.MethodDelete, "/rules/rule-1", "")
|
|
if rec.Code != http.StatusNoContent {
|
|
t.Fatalf("status = %d, want 204", rec.Code)
|
|
}
|
|
if _, ok := rules.rules["rule-1"]; ok {
|
|
t.Fatalf("expected rule to be deleted")
|
|
}
|
|
}
|
|
|
|
func TestCreateTargetRejectsInvalidKind(t *testing.T) {
|
|
mux := newTestMux(newFakeRuleStore(), newFakeTargetStore(), &fakeDeliveryReader{})
|
|
rec := doRequest(t, mux, http.MethodPost, "/targets", `{"name": "x", "kind": "carrier-pigeon", "webhook_url": "https://example.com"}`)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCreateSlackTarget(t *testing.T) {
|
|
mux := newTestMux(newFakeRuleStore(), newFakeTargetStore(), &fakeDeliveryReader{})
|
|
// A literal public IP, not a real hostname like hooks.slack.com --
|
|
// ValidateWebhookURL (see notifystore/ssrf.go) now resolves the
|
|
// target host and rejects internal/metadata addresses, so this test
|
|
// stays deterministic without depending on live DNS; ssrf_test.go
|
|
// covers the validation logic itself in depth.
|
|
rec := doRequest(t, mux, http.MethodPost, "/targets", `{"name": "oncall", "kind": "slack", "webhook_url": "https://8.8.8.8/services/x"}`)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestCreateTargetRejectsSSRFWebhookURL is the regression test for the
|
|
// security-audit finding that target creation performed no URL
|
|
// validation at all -- any authenticated user could point a webhook at
|
|
// an internal or cloud-metadata address.
|
|
func TestCreateTargetRejectsSSRFWebhookURL(t *testing.T) {
|
|
mux := newTestMux(newFakeRuleStore(), newFakeTargetStore(), &fakeDeliveryReader{})
|
|
rec := doRequest(t, mux, http.MethodPost, "/targets", `{"name": "x", "kind": "webhook", "webhook_url": "http://169.254.169.254/latest/meta-data/"}`)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestListDeliveriesForRule(t *testing.T) {
|
|
deliveries := &fakeDeliveryReader{entries: []rulestore.DeliveryLogEntry{
|
|
{ID: 1, RuleID: "rule-1", EventType: "firing", Status: "sent"},
|
|
}}
|
|
mux := newTestMux(newFakeRuleStore(), newFakeTargetStore(), deliveries)
|
|
|
|
rec := doRequest(t, mux, http.MethodGet, "/rules/rule-1/deliveries", "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if !strings.Contains(rec.Body.String(), `"status":"sent"`) {
|
|
t.Fatalf("expected delivery entry in response, got: %s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandleHealthz(t *testing.T) {
|
|
mux := newTestMux(newFakeRuleStore(), newFakeTargetStore(), &fakeDeliveryReader{})
|
|
rec := doRequest(t, mux, http.MethodGet, "/healthz", "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rec.Code)
|
|
}
|
|
}
|