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.
266 lines
8.7 KiB
Go
266 lines
8.7 KiB
Go
// Package httpapi is alerting's REST surface: rule CRUD, notification
|
|
// target CRUD, and a read-only delivery log -- mirrors
|
|
// api/internal/dashboards' Handler/Store shape (narrow interfaces,
|
|
// pgx-backed production implementation, fakes in tests).
|
|
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"github.com/sentry/sentry/alerting/internal/notifystore"
|
|
"github.com/sentry/sentry/alerting/internal/rulestore"
|
|
)
|
|
|
|
type ruleStore interface {
|
|
Create(ctx context.Context, r *rulestore.Rule) error
|
|
List(ctx context.Context) ([]rulestore.RuleWithState, error)
|
|
Get(ctx context.Context, id string) (*rulestore.RuleWithState, error)
|
|
Delete(ctx context.Context, id string) error
|
|
}
|
|
|
|
type targetStore interface {
|
|
Create(ctx context.Context, t *notifystore.Target) error
|
|
List(ctx context.Context) ([]notifystore.Target, error)
|
|
Get(ctx context.Context, id string) (*notifystore.Target, error)
|
|
Delete(ctx context.Context, id string) error
|
|
}
|
|
|
|
type deliveryReader interface {
|
|
ListForRule(ctx context.Context, ruleID string, limit int) ([]rulestore.DeliveryLogEntry, error)
|
|
}
|
|
|
|
type Handler struct {
|
|
logger *slog.Logger
|
|
rules ruleStore
|
|
targets targetStore
|
|
deliveries deliveryReader
|
|
}
|
|
|
|
func NewHandler(logger *slog.Logger, rules ruleStore, targets targetStore, deliveries deliveryReader) *Handler {
|
|
return &Handler{logger: logger, rules: rules, targets: targets, deliveries: deliveries}
|
|
}
|
|
|
|
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
|
mux.HandleFunc("GET /healthz", h.handleHealthz)
|
|
|
|
mux.HandleFunc("POST /rules", h.handleCreateRule)
|
|
mux.HandleFunc("GET /rules", h.handleListRules)
|
|
mux.HandleFunc("GET /rules/{id}", h.handleGetRule)
|
|
mux.HandleFunc("DELETE /rules/{id}", h.handleDeleteRule)
|
|
mux.HandleFunc("GET /rules/{id}/deliveries", h.handleListDeliveries)
|
|
|
|
mux.HandleFunc("POST /targets", h.handleCreateTarget)
|
|
mux.HandleFunc("GET /targets", h.handleListTargets)
|
|
mux.HandleFunc("GET /targets/{id}", h.handleGetTarget)
|
|
mux.HandleFunc("DELETE /targets/{id}", h.handleDeleteTarget)
|
|
}
|
|
|
|
const maxBodyBytes = 1 << 20 // 1 MiB, same cap as api/internal/queryapi and dashboards
|
|
|
|
func (h *Handler) handleHealthz(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
// createRuleRequest mirrors rulestore.Rule except Enabled is a pointer:
|
|
// a plain bool can't distinguish "omitted" from "explicitly false" on
|
|
// decode, and Go's zero value for bool is false -- without this, a
|
|
// create request that simply doesn't mention "enabled" would silently
|
|
// create a rule the evaluator's claim query never picks up (found by
|
|
// actually creating a rule through this endpoint and checking the
|
|
// response). Omitted means enabled; only an explicit "enabled": false
|
|
// creates a disabled rule.
|
|
type createRuleRequest struct {
|
|
rulestore.Rule
|
|
Enabled *bool `json:"enabled"`
|
|
}
|
|
|
|
func (h *Handler) handleCreateRule(w http.ResponseWriter, r *http.Request) {
|
|
var req createRuleRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
rule := req.Rule
|
|
rule.Enabled = req.Enabled == nil || *req.Enabled
|
|
if err := validateRule(&rule); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
if err := h.rules.Create(r.Context(), &rule); err != nil {
|
|
h.logger.Error("creating rule", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "creating rule failed")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, rule)
|
|
}
|
|
|
|
func (h *Handler) handleListRules(w http.ResponseWriter, r *http.Request) {
|
|
rules, err := h.rules.List(r.Context())
|
|
if err != nil {
|
|
h.logger.Error("listing rules", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "listing rules failed")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, rules)
|
|
}
|
|
|
|
func (h *Handler) handleGetRule(w http.ResponseWriter, r *http.Request) {
|
|
rule, err := h.rules.Get(r.Context(), r.PathValue("id"))
|
|
if err != nil {
|
|
h.writeStoreErr(w, err, "fetching rule")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, rule)
|
|
}
|
|
|
|
func (h *Handler) handleDeleteRule(w http.ResponseWriter, r *http.Request) {
|
|
if err := h.rules.Delete(r.Context(), r.PathValue("id")); err != nil {
|
|
h.writeStoreErr(w, err, "deleting rule")
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Handler) handleListDeliveries(w http.ResponseWriter, r *http.Request) {
|
|
entries, err := h.deliveries.ListForRule(r.Context(), r.PathValue("id"), 100)
|
|
if err != nil {
|
|
h.logger.Error("listing deliveries", "rule_id", r.PathValue("id"), "error", err)
|
|
writeError(w, http.StatusInternalServerError, "listing deliveries failed")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, entries)
|
|
}
|
|
|
|
func (h *Handler) handleCreateTarget(w http.ResponseWriter, r *http.Request) {
|
|
var target notifystore.Target
|
|
if !decodeJSON(w, r, &target) {
|
|
return
|
|
}
|
|
if target.Name == "" {
|
|
writeError(w, http.StatusBadRequest, "name must not be empty")
|
|
return
|
|
}
|
|
if !notifystore.ValidKind(target.Kind) {
|
|
writeError(w, http.StatusBadRequest, "kind must be one of webhook, slack, pagerduty")
|
|
return
|
|
}
|
|
if target.WebhookURL == "" {
|
|
writeError(w, http.StatusBadRequest, "webhook_url must not be empty")
|
|
return
|
|
}
|
|
if err := notifystore.ValidateWebhookURL(target.WebhookURL); err != nil {
|
|
writeError(w, http.StatusBadRequest, "webhook_url: "+err.Error())
|
|
return
|
|
}
|
|
if err := h.targets.Create(r.Context(), &target); err != nil {
|
|
h.logger.Error("creating notification target", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "creating notification target failed")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, target)
|
|
}
|
|
|
|
func (h *Handler) handleListTargets(w http.ResponseWriter, r *http.Request) {
|
|
targets, err := h.targets.List(r.Context())
|
|
if err != nil {
|
|
h.logger.Error("listing notification targets", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "listing notification targets failed")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, targets)
|
|
}
|
|
|
|
func (h *Handler) handleGetTarget(w http.ResponseWriter, r *http.Request) {
|
|
target, err := h.targets.Get(r.Context(), r.PathValue("id"))
|
|
if err != nil {
|
|
h.writeStoreErr(w, err, "fetching notification target")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, target)
|
|
}
|
|
|
|
func (h *Handler) handleDeleteTarget(w http.ResponseWriter, r *http.Request) {
|
|
if err := h.targets.Delete(r.Context(), r.PathValue("id")); err != nil {
|
|
h.writeStoreErr(w, err, "deleting notification target")
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// validateRule enforces the shape the evaluator assumes:
|
|
// condition_type-appropriate fields present, a sane interval floor.
|
|
// Mirrors the DB CHECK constraints so bad input is rejected with a
|
|
// clear message here rather than surfacing as an opaque constraint
|
|
// violation from Postgres.
|
|
func validateRule(r *rulestore.Rule) error {
|
|
if r.Name == "" {
|
|
return errBadRequest("name must not be empty")
|
|
}
|
|
if r.Query == "" {
|
|
return errBadRequest("query must not be empty")
|
|
}
|
|
if r.NotificationTargetID == "" {
|
|
return errBadRequest("notification_target_id must not be empty")
|
|
}
|
|
if r.EvalIntervalSeconds < 30 {
|
|
return errBadRequest("eval_interval_seconds must be at least 30")
|
|
}
|
|
switch r.ConditionType {
|
|
case rulestore.ConditionThreshold:
|
|
if r.Comparator == nil || !rulestore.ValidComparator(*r.Comparator) {
|
|
return errBadRequest("threshold rules require a valid comparator (gt, gte, lt, lte, eq, ne)")
|
|
}
|
|
if r.ThresholdValue == nil {
|
|
return errBadRequest("threshold rules require threshold_value")
|
|
}
|
|
case rulestore.ConditionAbsence:
|
|
// No comparator/threshold_value needed -- absence is "the query
|
|
// returned zero rows in its own earliest=/latest= window."
|
|
default:
|
|
return errBadRequest("condition_type must be \"threshold\" or \"absence\"")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type badRequestError string
|
|
|
|
func (e badRequestError) Error() string { return string(e) }
|
|
func errBadRequest(msg string) error { return badRequestError(msg) }
|
|
|
|
func (h *Handler) writeStoreErr(w http.ResponseWriter, err error, action string) {
|
|
if errors.Is(err, rulestore.ErrNotFound) || errors.Is(err, notifystore.ErrNotFound) {
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
return
|
|
}
|
|
h.logger.Error(action, "error", err)
|
|
writeError(w, http.StatusInternalServerError, action+" failed")
|
|
}
|
|
|
|
func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool {
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
|
|
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
type errorResponse struct {
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
func writeError(w http.ResponseWriter, status int, msg string) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(errorResponse{Error: msg})
|
|
}
|