Add local login, agent extra log paths, IPv4/IPv6 metrics; remediate security audit findings

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.
This commit is contained in:
2026-08-18 23:53:20 -07:00
parent d2bb9de245
commit 4b5dae5879
87 changed files with 5095 additions and 164 deletions
+96 -4
View File
@@ -7,7 +7,11 @@ package main
import (
"context"
"crypto/rand"
"encoding/base64"
"flag"
"fmt"
"io"
"log/slog"
"net/http"
"os"
@@ -28,6 +32,7 @@ import (
"github.com/sentry/sentry/api/dashboards"
"github.com/sentry/sentry/api/httpserver"
"github.com/sentry/sentry/api/internal/config"
"github.com/sentry/sentry/api/localauth"
"github.com/sentry/sentry/api/queryapi"
"github.com/sentry/sentry/api/querylang/executor"
"github.com/sentry/sentry/api/searchclient"
@@ -48,6 +53,9 @@ func main() {
logger.Error("loading config", "error", err)
os.Exit(1)
}
for _, w := range cfg.DevCredentialWarnings() {
logger.Warn(w)
}
// -healthcheck: a self-check mode for Docker's HEALTHCHECK, not a
// flag anyone runs by hand. The api image is distroless (no shell,
@@ -59,6 +67,13 @@ func main() {
os.Exit(runHealthcheck(cfg.HTTPListenAddr))
}
// -seed-admin: a one-shot action, not part of the normal server
// startup path -- mirrors enterprise-api's -provision-tenant shape
// (declare, flag.Parse(), short-circuit before the rest of main's
// dependencies matter to it). See runSeedAdmin's doc comment.
seedAdmin := flag.Bool("seed-admin", false, "create the default local-auth admin user with a random password if none exists, print it once, and exit")
flag.Parse()
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
@@ -101,12 +116,25 @@ func main() {
os.Exit(1)
}
if *seedAdmin {
os.Exit(runSeedAdmin(ctx, logger, os.Stdout, localauth.NewStore(pgPool)))
}
// authorizer is nil (RequireRole* becomes a no-op) unless
// ENTERPRISE_AUTH_URL is configured -- matches Phase 0-3 behavior
// for a single-tenant deployment with no enterprise/ deployed.
// ENTERPRISE_AUTH_URL or LOCAL_AUTH_ENABLED is configured -- matches
// Phase 0-3 behavior for a single-tenant deployment with neither
// enterprise/ nor local login turned on. EnterpriseAuthURL wins if
// both were somehow set -- a deployment with real SSO configured has
// no use for a second, local auth mechanism (see LocalAuthConfig's
// doc comment).
var authorizer authz.Authorizer
if cfg.EnterpriseAuthURL != "" {
var localAuthStore *localauth.Store
switch {
case cfg.EnterpriseAuthURL != "":
authorizer = authz.NewHTTPAuthorizer(cfg.EnterpriseAuthURL)
case cfg.LocalAuth.Enabled:
localAuthStore = localauth.NewStore(pgPool)
authorizer = localauth.NewAuthorizer(localAuthStore)
}
sqlRunner := executor.NewChRunner(conn)
@@ -139,6 +167,18 @@ func main() {
dashboardsHandler.RegisterRoutes(mux)
agentsHandler.RegisterRoutes(mux)
// Only registered when local auth is actually enabled -- see
// localauth.Handler.RegisterRoutes' doc comment for why a disabled
// deployment gets a plain 404 on /auth/* rather than a dedicated
// "feature off" response.
if localAuthStore != nil {
localauthHandler := localauth.NewHandler(logger, localAuthStore, authorizer, cfg.LocalAuth.SessionTTL, localauth.CookieConfig{
Domain: cfg.LocalAuth.CookieDomain,
Secure: cfg.LocalAuth.CookieSecure,
})
localauthHandler.RegisterRoutes(mux)
}
// AI routes (Phase 7) are only registered at all when OLLAMA_BASE_URL
// is set -- an unconfigured deployment gets a plain 404 on /ai/*
// rather than every request failing against an unreachable
@@ -165,9 +205,19 @@ func main() {
logger.Info("ai routes enabled", "ollama_base_url", cfg.AI.OllamaBaseURL, "model", cfg.AI.OllamaModel)
}
// Once an authorizer is live, requests carry a session cookie/bearer
// token that must survive a cross-origin browser fetch --
// WithCredentialedCORS is WithCORS's sibling for exactly that (see
// httpserver/cors.go). This also fixes a latent gap: previously,
// enterprise mode applied plain WithCORS here despite needing
// cookies too.
corsHandler := httpserver.WithCORS(mux, cfg.CORSAllowedOrigin)
if authorizer != nil {
corsHandler = httpserver.WithCredentialedCORS(mux, cfg.CORSAllowedOrigin)
}
srv := &http.Server{
Addr: cfg.HTTPListenAddr,
Handler: httpserver.WithCORS(mux, cfg.CORSAllowedOrigin),
Handler: corsHandler,
}
errCh := make(chan error, 1)
@@ -191,6 +241,48 @@ func main() {
}
}
// runSeedAdmin is the operator action that bootstraps local login on a
// fresh deployment: idempotent (a no-op if any local user already
// exists, safe to run on every deploy per the runbook), so there's no
// separate "has this already run" flag to track. The generated
// password is printed to stdout exactly once and never stored in
// plaintext anywhere -- losing it means resetting it
// (POST /auth/users/{id}/reset-password), not recovering it.
func runSeedAdmin(ctx context.Context, logger *slog.Logger, stdout io.Writer, store *localauth.Store) int {
n, err := store.CountLocalUsers(ctx)
if err != nil {
logger.Error("counting local users", "error", err)
return 1
}
if n > 0 {
fmt.Fprintln(stdout, "admin already provisioned, skipping")
return 0
}
buf := make([]byte, 20)
if _, err := rand.Read(buf); err != nil {
logger.Error("generating random password", "error", err)
return 1
}
password := base64.RawURLEncoding.EncodeToString(buf)
hash, err := localauth.HashPassword(password)
if err != nil {
logger.Error("hashing password", "error", err)
return 1
}
if _, err := store.CreateUser(ctx, "admin", hash, authz.RoleOwner); err != nil {
logger.Error("creating admin user", "error", err)
return 1
}
fmt.Fprintln(stdout, "created default admin user:")
fmt.Fprintln(stdout, " username: admin")
fmt.Fprintf(stdout, " password: %s\n", password)
fmt.Fprintln(stdout, "this password will not be shown again -- save it now.")
return 0
}
// runHealthcheck GETs its own /healthz and returns an exit code, for
// Docker's HEALTHCHECK to exec directly (see the -healthcheck flag
// above). listenAddr is HTTP_LISTEN_ADDR-shaped (e.g. ":8080") --