diff --git a/server/internal/app/pat_binding_test.go b/server/internal/app/pat_binding_test.go new file mode 100644 index 000000000..d58f15784 --- /dev/null +++ b/server/internal/app/pat_binding_test.go @@ -0,0 +1,216 @@ +package app + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/fclairamb/solidping/server/internal/config" + "github.com/fclairamb/solidping/server/internal/db/models" + "github.com/fclairamb/solidping/server/internal/handlers/auth" + "github.com/fclairamb/solidping/server/internal/handlers/base" + "github.com/fclairamb/solidping/server/internal/utils/passwords" +) + +const patBindingPassword = "alicepass1234" + +// patBindingEnv is the real server with one user, admin of org A and org B, +// a PAT created in org A and a session from a password login (spec +// 2026-10-28-01). +type patBindingEnv struct { + *impersonationEnv + + user *models.User + pat string + session string +} + +func newPATBindingEnv(t *testing.T) *patBindingEnv { + t.Helper() + r := require.New(t) + ctx := context.Background() + + cfg := &config.Config{} + cfg.Database.Type = dbTypeSQLiteMemory + cfg.Server.BaseURL = "http://localhost" + cfg.Auth.JWTSecret = "pat-binding-secret" + cfg.Auth.AccessTokenExpiry = time.Hour + cfg.Auth.RefreshTokenExpiry = 24 * time.Hour + cfg.Auth.WebAuthn.Enabled = true + + server, err := NewServer(ctx, cfg) + r.NoError(err) + t.Cleanup(func() { _ = server.dbService.Close() }) + + r.NoError(server.Initialize(ctx)) + r.NoError(server.InitializeSystemConfig(ctx, cfg)) + server.SetupRoutes(ctx) + + ts := httptest.NewServer(server.Handler()) + t.Cleanup(ts.Close) + + env := &patBindingEnv{impersonationEnv: &impersonationEnv{t: t, server: server, ts: ts}} + + env.orgA = models.NewOrganization("pata", "Acme A") + r.NoError(server.dbService.CreateOrganization(ctx, env.orgA)) + env.orgB = models.NewOrganization("patb", "Acme B") + r.NoError(server.dbService.CreateOrganization(ctx, env.orgB)) + r.NoError(server.dbService.CreateCheck(ctx, models.NewCheck(env.orgA.UID, "a-check", "http"))) + r.NoError(server.dbService.CreateCheck(ctx, models.NewCheck(env.orgB.UID, "b-check", "http"))) + + hash, err := passwords.Hash(patBindingPassword) + r.NoError(err) + + env.user = models.NewUser("alice@acme.com") + env.user.PasswordHash = &hash + r.NoError(server.dbService.CreateUser(ctx, env.user)) + + now := time.Now() + for _, org := range []*models.Organization{env.orgA, env.orgB} { + member := models.NewOrganizationMember(org.UID, env.user.UID, models.MemberRoleAdmin) + member.JoinedAt = &now + r.NoError(server.dbService.CreateOrganizationMember(ctx, member)) + } + + created, err := server.authService.CreatePAT(ctx, env.orgA.Slug, env.user.UID, auth.CreateTokenRequest{Name: "ci"}) + r.NoError(err) + env.pat = created.Token + + status, body := env.do(http.MethodPost, "/api/v1/auth/login", "", + `{"org":"pata","email":"alice@acme.com","password":"`+patBindingPassword+`"}`) + r.Equal(http.StatusOK, status, body) + env.session, _ = body["accessToken"].(string) + r.NotEmpty(env.session) + + return env +} + +func (e *patBindingEnv) tokenCount(tokenType models.TokenType) int { + e.t.Helper() + + tokens, err := e.server.dbService.ListUserTokensByType(context.Background(), e.user.UID, tokenType) + require.NoError(e.t, err) + + return len(tokens) +} + +func (e *patBindingEnv) twoFACounterExists() bool { + e.t.Helper() + + entry, err := e.server.dbService.GetStateEntry(context.Background(), nil, "twofa_attempt_count:"+e.user.UID) + + return err == nil && entry != nil +} + +// TestPATCannotLeaveItsOrg is the regression for finding X1: every route that +// would let a PAT step out of its org binding answers 403 PAT_FORBIDDEN, and +// the same calls made from a password session still go through. +// +//nolint:paralleltest,tparallel // sub-tests share one server and its token rows +func TestPATCannotLeaveItsOrg(t *testing.T) { + t.Parallel() + env := newPATBindingEnv(t) + + forbidden := []struct { + name, method, path, body string + }{ + {"switch org", http.MethodPost, "/api/v1/auth/switch-org", `{"org":"patb"}`}, + {"org creation", http.MethodPost, "/api/v1/orgs", `{"slug":"patc","name":"Acme C"}`}, + {"accept invite", http.MethodPost, "/api/v1/auth/accept-invite", `{"token":"x"}`}, + {"change password", http.MethodPost, "/api/v1/auth/change-password", `{"newPassword":"hijack-1234567"}`}, + {"2FA setup", http.MethodPost, "/api/v1/auth/2fa/setup", `{}`}, + {"2FA confirm", http.MethodPost, "/api/v1/auth/2fa/confirm", `{"code":"123456"}`}, + {"2FA removal", http.MethodDelete, "/api/v1/auth/2fa", `{"code":"123456"}`}, + {"passkey add (begin)", http.MethodPost, "/api/v1/auth/passkeys/register/begin", `{}`}, + {"passkey add (finish)", http.MethodPost, "/api/v1/auth/passkeys/register/finish", `{}`}, + {"passkey removal", http.MethodDelete, "/api/v1/auth/passkeys/abc", ``}, + { + "device consent", http.MethodPost, "/api/v1/auth/device/consent", + `{"userCode":"ABCD-EFGH","org":"patb","approve":true}`, + }, + {"discord link", http.MethodPost, "/api/v1/orgs/pata/users/me/discord/link-start", `{}`}, + {"discord connect", http.MethodPost, "/api/v1/orgs/pata/users/me/discord/connect", `{}`}, + {"telegram link", http.MethodPost, "/api/v1/orgs/pata/users/me/telegram/link", `{}`}, + } + + refreshBefore := env.tokenCount(models.TokenTypeRefresh) + patBefore := env.tokenCount(models.TokenTypePAT) + + for _, call := range forbidden { + t.Run(call.name, func(t *testing.T) { + r := require.New(t) + + status, body := env.do(call.method, call.path, env.pat, call.body) + r.Equal(http.StatusForbidden, status, body) + r.Equal(string(base.ErrorCodePATForbidden), body["code"]) + }) + } + + r := require.New(t) + r.Equal(refreshBefore, env.tokenCount(models.TokenTypeRefresh), "no session was minted") + r.Equal(patBefore, env.tokenCount(models.TokenTypePAT), "no PAT was minted") + r.False(env.twoFACounterExists(), "DELETE /auth/2fa spent no 2FA attempt") + + // The PAT still reads its own org, and only its own org. + status, body := env.do(http.MethodGet, "/api/v1/orgs/pata/checks", env.pat, "") + r.Equal(http.StatusOK, status, body) + status, _ = env.do(http.MethodGet, "/api/v1/orgs/patb/checks", env.pat, "") + r.Equal(http.StatusForbidden, status) + + // The PAT still creates a PAT in its own org. + status, body = env.do(http.MethodPost, "/api/v1/orgs/pata/tokens", env.pat, `{"name":"second"}`) + r.Equal(http.StatusCreated, status, body) + + // The PAT still edits the profile, but not the sign-in email. + status, body = env.do(http.MethodPatch, "/api/v1/auth/me", env.pat, `{"name":"Alice"}`) + r.Equal(http.StatusOK, status, body) + status, body = env.do(http.MethodPatch, "/api/v1/auth/me", env.pat, + `{"email":"mallory@acme.com","currentPassword":"`+patBindingPassword+`"}`) + r.Equal(http.StatusForbidden, status, body) + r.Equal(string(base.ErrorCodePATForbidden), body["code"]) + + // Positive controls: the password session does what the PAT may not. + status, body = env.do(http.MethodPost, "/api/v1/auth/passkeys/register/begin", env.session, `{}`) + r.Equal(http.StatusOK, status, body) + + status, body = env.do(http.MethodPost, "/api/v1/auth/switch-org", env.session, `{"org":"patb"}`) + r.Equal(http.StatusOK, status, body) + r.Equal(refreshBefore+1, env.tokenCount(models.TokenTypeRefresh)) + switched, _ := body["accessToken"].(string) + status, _ = env.do(http.MethodGet, "/api/v1/orgs/patb/checks", switched, "") + r.Equal(http.StatusOK, status) + + // DELETE /auth/2fa from a session still spends the shared login budget + // (spec 2026-10-27-02): 2FA is off, so the call is refused, but counted. + status, _ = env.do(http.MethodDelete, "/api/v1/auth/2fa", env.session, `{"code":"123456"}`) + r.NotEqual(http.StatusForbidden, status) + r.True(env.twoFACounterExists()) +} + +// TestPATDenylistNamesRealRoutes keeps the denylist honest: every entry must +// be a registered route, or a renamed endpoint would silently fall out of the +// guard. +func TestPATDenylistNamesRealRoutes(t *testing.T) { + t.Parallel() + r := require.New(t) + env := newImpersonationEnv(t, true) + + registered := map[string]bool{} + r.NoError(env.server.router.Walk(func(method, pattern string) error { + registered[method+" "+pattern] = true + + return nil + })) + + denylist := auth.PATForbiddenRoutes() + r.NotEmpty(denylist) + + for _, entry := range denylist { + r.Truef(registered[entry[0]+" "+entry[1]], + "PAT denylist entry %s %s is not a registered route", entry[0], entry[1]) + } +} diff --git a/server/internal/app/server.go b/server/internal/app/server.go index 26291ab4a..1461cf182 100644 --- a/server/internal/app/server.go +++ b/server/internal/app/server.go @@ -2137,7 +2137,8 @@ func (s *Server) SetupRoutes(ctx context.Context) { // shared by the authed admin routes (below) and the public routes (further // down, outside RequireAuth). statusSubscribersService := statussubscribers.NewService(s.dbService, s.services.Credentials, - statussubscribers.WithEgressGuard(s.services.EgressGuard)) + statussubscribers.WithEgressGuard(s.services.EgressGuard), + statussubscribers.WithConfirmBudget(s.config.StatusSubscribers)) statusSubscribersHandler := statussubscribers.NewHandler( statusSubscribersService, s.dbService, s.services.EmailSender, s.services.EmailFormatter, s.config) // Authed admin: list (count + redactable addresses) and remove. @@ -2250,9 +2251,16 @@ func (s *Server) SetupRoutes(ctx context.Context) { api.GET("/public/custom-domains/allowed", statusPagesHandler.CustomDomainAllowed) // Public status-page subscription endpoints (no authentication). The - // subscribe endpoint inherits the global per-IP rate limit on /api/v1/; - // double opt-in is the primary anti-abuse control. Confirm/unsubscribe are - // single-purpose token links that render an HTML landing page. + // subscribe endpoint inherits the global per-IP rate limit on /api/v1/, + // which is far too loose to protect a third-party mailbox on its own, so + // the confirm mail is budgeted per recipient address (across all pages), + // per page and per trusted client IP (status_subscribers.* config); over + // budget it still answers 202 and sends nothing. A live confirmed + // subscription is never touched by a repeat subscribe (no mail, same 202), + // so the endpoint cannot cancel a subscriber nor reveal one. Confirm links + // expire after 7 days and the state-cleanup job reaps the unconfirmed rows + // (spec 2026-10-28-02). Confirm/unsubscribe are single-purpose token links + // that render an HTML landing page. // Email-only, public. Its operator-side sibling deliberately lives at // `.../subscribers/endpoints` — see the comment there for why they cannot // share this pattern. diff --git a/server/internal/checkers/checksnmp/config/secret_fields.go b/server/internal/checkers/checksnmp/config/secret_fields.go index 7ee63a30e..bfa1cce80 100644 --- a/server/internal/checkers/checksnmp/config/secret_fields.go +++ b/server/internal/checkers/checksnmp/config/secret_fields.go @@ -3,5 +3,5 @@ package config // SecretFields declares which top-level config keys carry secrets and must // be encrypted at rest. Implements credentials.SecretFielder. func (c *SNMPConfig) SecretFields() []string { - return []string{"authPassword", "privPassword"} + return []string{"community", "authPassword", "privPassword"} } diff --git a/server/internal/checkers/checksnmp/config/secret_fields_test.go b/server/internal/checkers/checksnmp/config/secret_fields_test.go new file mode 100644 index 000000000..ff5c7d594 --- /dev/null +++ b/server/internal/checkers/checksnmp/config/secret_fields_test.go @@ -0,0 +1,15 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSecretFieldsIncludeCommunity(t *testing.T) { + t.Parallel() + + require.ElementsMatch(t, + []string{"community", "authPassword", "privPassword"}, + (&SNMPConfig{}).SecretFields()) +} diff --git a/server/internal/checkers/schemas/snmp.json b/server/internal/checkers/schemas/snmp.json index c760c35a5..fd823d88f 100644 --- a/server/internal/checkers/schemas/snmp.json +++ b/server/internal/checkers/schemas/snmp.json @@ -12,7 +12,9 @@ "type": "string" }, "community": { - "type": "string" + "type": "string", + "format": "solidping-secret-ref", + "description": "Stored encrypted. Prefer a `${env:VAR}` or `${param:key}` reference over an inlined credential in a config-as-code manifest." }, "oid": { "type": "string" @@ -58,6 +60,7 @@ "x-solidping-notes": [], "x-solidping-secret-fields": [ "authPassword", + "community", "privPassword" ], "x-solidping-validation": { diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 1c17770da..6f1f996a4 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -639,6 +639,9 @@ type Config struct { UserAgent string `koanf:"useragent"` // Identity string for protocol checks (SP_USERAGENT) LogLevel slog.Level `koanf:"-"` // Logging level (SP_LOG_LEVEL, LOG_LEVEL as fallback) LogFormat LogFormat `koanf:"-"` // Logging output format (parsed from SP_LOG_FORMAT env var) + + // StatusSubscribers budgets the public status-page subscribe confirm mail. + StatusSubscribers StatusSubscribersConfig `koanf:"status_subscribers"` } // ACMEConfig turns on in-server TLS: certmagic obtains and renews Let's Encrypt @@ -2177,6 +2180,8 @@ func Load() (*Config, error) { // In-server ACME is opt-in; the listen addresses are pre-filled so // enabling it is a one-flag change on a host with :80/:443 free. Heartbeat: DefaultHeartbeatConfig(), + // Confirm-mail budgets of the public subscribe endpoint (spec 2026-10-28-02). + StatusSubscribers: DefaultStatusSubscribersConfig(), ACME: ACMEConfig{ Enabled: false, ListenHTTP: DefaultACMEListenHTTP, @@ -2470,6 +2475,7 @@ func Load() (*Config, error) { applyWorkerPoolEnv(&cfg) applyACMEEnv(&cfg.ACME) applyHeartbeatEnv(&cfg.Heartbeat) + applyStatusSubscribersEnv(&cfg.StatusSubscribers) applyEncryptionEnv(&cfg.Encryption) applyAuditEnv(&cfg.Audit) applyDemoEnv(&cfg.Demo, cfg.RunMode) diff --git a/server/internal/config/envvars.go b/server/internal/config/envvars.go index 5c195c06d..00db4a233 100644 --- a/server/internal/config/envvars.go +++ b/server/internal/config/envvars.go @@ -178,6 +178,10 @@ func manualReaderServerEnvVars() []string { // env provider, so Load reads these by hand (see envInt calls). "SP_ENTITLEMENTS_SMS_RUNAWAY_PER_HOUR", "SP_ENTITLEMENTS_CALL_RUNAWAY_PER_HOUR", + // applyStatusSubscribersEnv — snake_case segments (spec 2026-10-28-02). + EnvStatusSubscriberConfirmPerRecipient, + EnvStatusSubscriberConfirmPerPage, + EnvStatusSubscriberConfirmPerIP, "SP_SHUTDOWN_TIMEOUT", "SP_SERVER_MAX_REQUEST_DURATION", "SP_DB_RESET", diff --git a/server/internal/config/status_subscribers.go b/server/internal/config/status_subscribers.go new file mode 100644 index 000000000..fa8b2d967 --- /dev/null +++ b/server/internal/config/status_subscribers.go @@ -0,0 +1,68 @@ +package config + +import ( + "os" + "strconv" +) + +// Defaults for StatusSubscribersConfig (spec 2026-10-28-02). Exported so the +// subscribers service and its tests cannot drift from the documented values. +const ( + // DefaultStatusSubscriberConfirmPerRecipientPerHour bounds the confirm + // mails one address receives, across every status page of the instance. + DefaultStatusSubscriberConfirmPerRecipientPerHour = 3 + // DefaultStatusSubscriberConfirmPerPagePerHour bounds the confirm mails + // one status page sends. + DefaultStatusSubscriberConfirmPerPagePerHour = 100 + // DefaultStatusSubscriberConfirmPerIPPerHour bounds the confirm mails one + // client IP (trusted-proxy resolution) can trigger. + DefaultStatusSubscriberConfirmPerIPPerHour = 20 +) + +// Env names of the confirm-mail budgets. Their key segments are snake_case, +// which koanf's env provider collapses to dots, so applyStatusSubscribersEnv +// reads them by hand. +const ( + EnvStatusSubscriberConfirmPerRecipient = "SP_STATUS_SUBSCRIBERS_CONFIRM_PER_RECIPIENT_PER_HOUR" + EnvStatusSubscriberConfirmPerPage = "SP_STATUS_SUBSCRIBERS_CONFIRM_PER_PAGE_PER_HOUR" + EnvStatusSubscriberConfirmPerIP = "SP_STATUS_SUBSCRIBERS_CONFIRM_PER_IP_PER_HOUR" +) + +// StatusSubscribersConfig budgets the double opt-in mail of the public +// status-page subscribe endpoint. Every budget is a count per sliding hour; 0 +// disables that budget. Over budget the endpoint still answers 202 but sends +// nothing, so it is neither a mail cannon nor an oracle. +type StatusSubscribersConfig struct { + ConfirmPerRecipientPerHour int `koanf:"confirm_per_recipient_per_hour"` + ConfirmPerPagePerHour int `koanf:"confirm_per_page_per_hour"` + ConfirmPerIPPerHour int `koanf:"confirm_per_ip_per_hour"` +} + +// DefaultStatusSubscribersConfig returns the shipped budgets: 3 per recipient, +// 100 per page and 20 per client IP, each per hour. +func DefaultStatusSubscribersConfig() StatusSubscribersConfig { + return StatusSubscribersConfig{ + ConfirmPerRecipientPerHour: DefaultStatusSubscriberConfirmPerRecipientPerHour, + ConfirmPerPagePerHour: DefaultStatusSubscriberConfirmPerPagePerHour, + ConfirmPerIPPerHour: DefaultStatusSubscriberConfirmPerIPPerHour, + } +} + +// applyStatusSubscribersEnv reads the SP_STATUS_SUBSCRIBERS_* budgets. A +// non-negative integer applies (0 disables the budget); anything else is +// ignored and the configured value stays. +func applyStatusSubscribersEnv(cfg *StatusSubscribersConfig) { + read := func(raw string, dst *int) { + if raw == "" { + return + } + + if n, err := strconv.Atoi(raw); err == nil && n >= 0 { + *dst = n + } + } + + read(os.Getenv("SP_STATUS_SUBSCRIBERS_CONFIRM_PER_RECIPIENT_PER_HOUR"), &cfg.ConfirmPerRecipientPerHour) + read(os.Getenv("SP_STATUS_SUBSCRIBERS_CONFIRM_PER_PAGE_PER_HOUR"), &cfg.ConfirmPerPagePerHour) + read(os.Getenv("SP_STATUS_SUBSCRIBERS_CONFIRM_PER_IP_PER_HOUR"), &cfg.ConfirmPerIPPerHour) +} diff --git a/server/internal/config/status_subscribers_test.go b/server/internal/config/status_subscribers_test.go new file mode 100644 index 000000000..40f081110 --- /dev/null +++ b/server/internal/config/status_subscribers_test.go @@ -0,0 +1,35 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestStatusSubscribersBudgetDefaults pins the documented confirm-mail budgets +// (spec 2026-10-28-02): 3 per recipient, 100 per page, 20 per IP, per hour. +func TestStatusSubscribersBudgetDefaults(t *testing.T) { + t.Parallel() + + r := require.New(t) + cfg, err := Load() + r.NoError(err) + r.Equal(3, cfg.StatusSubscribers.ConfirmPerRecipientPerHour) + r.Equal(100, cfg.StatusSubscribers.ConfirmPerPagePerHour) + r.Equal(20, cfg.StatusSubscribers.ConfirmPerIPPerHour) +} + +// TestStatusSubscribersBudgetEnvBinds proves the snake_case env names land on +// their fields, 0 included (it disables a budget), and garbage is ignored. +func TestStatusSubscribersBudgetEnvBinds(t *testing.T) { + t.Setenv(EnvStatusSubscriberConfirmPerRecipient, "5") + t.Setenv(EnvStatusSubscriberConfirmPerPage, "0") + t.Setenv(EnvStatusSubscriberConfirmPerIP, "not-a-number") + + r := require.New(t) + cfg, err := Load() + r.NoError(err) + r.Equal(5, cfg.StatusSubscribers.ConfirmPerRecipientPerHour) + r.Equal(0, cfg.StatusSubscribers.ConfirmPerPagePerHour) + r.Equal(DefaultStatusSubscriberConfirmPerIPPerHour, cfg.StatusSubscribers.ConfirmPerIPPerHour) +} diff --git a/server/internal/credmigrate/plaintext_test.go b/server/internal/credmigrate/plaintext_test.go index 18e474f1d..aa89ed809 100644 --- a/server/internal/credmigrate/plaintext_test.go +++ b/server/internal/credmigrate/plaintext_test.go @@ -136,3 +136,35 @@ func privateKeysOf(t *testing.T, raw *string) []string { return keys } + +// TestRunPlaintextMovesSNMPCommunity: an SNMP row written before "community" +// became a declared secret ends up with it in the private envelope. +func TestRunPlaintextMovesSNMPCommunity(t *testing.T) { + t.Parallel() + + ctx := t.Context() + r := require.New(t) + + dbSvc, err := sqlite.New(ctx, sqlite.Config{InMemory: true}) + r.NoError(err) + r.NoError(dbSvc.Initialize(ctx)) + t.Cleanup(func() { _ = dbSvc.Close() }) + + org := models.NewOrganization("snmp-mig", "SNMP Migration Org") + r.NoError(dbSvc.CreateOrganization(ctx, org)) + + check := models.NewCheck(org.UID, "old-snmp", "snmp") + check.Config = models.JSONMap{"host": "10.0.0.1", "oid": "1.3.6", "community": "s3cr3t-acme"} + r.NoError(dbSvc.CreateCheck(ctx, check)) + + stats, err := credmigrate.RunPlaintext(ctx, dbSvc, credmigrate.Options{}) + r.NoError(err) + r.Equal(1, stats.ChecksMigrated) + + row, err := dbSvc.GetCheck(ctx, org.UID, check.UID) + r.NoError(err) + r.NotContains(row.Config, "community") + r.NotNil(row.ConfigPrivate) + r.NotNil(row.ConfigPrivateKeys) + r.Contains(*row.ConfigPrivateKeys, "community") +} diff --git a/server/internal/db/models/status_page_subscriber.go b/server/internal/db/models/status_page_subscriber.go index 8c39bac45..24391ff8e 100644 --- a/server/internal/db/models/status_page_subscriber.go +++ b/server/internal/db/models/status_page_subscriber.go @@ -77,13 +77,17 @@ type StatusPageSubscriber struct { OrganizationUID string `bun:"organization_uid,notnull"` StatusPageUID string `bun:"status_page_uid,notnull"` // Email is nil for a webhook/slack subscriber. - Email *string `bun:"email"` - Channel SubscriberChannel `bun:"channel,notnull"` - ConfirmedAt *time.Time `bun:"confirmed_at"` - ConfirmToken string `bun:"confirm_token,notnull"` - UnsubscribeToken string `bun:"unsubscribe_token,notnull"` - Scope SubscriberScope `bun:"scope,notnull"` - IncidentUID *string `bun:"incident_uid"` + Email *string `bun:"email"` + Channel SubscriberChannel `bun:"channel,notnull"` + ConfirmedAt *time.Time `bun:"confirmed_at"` + ConfirmToken string `bun:"confirm_token,notnull"` + // ConfirmSentAt is when the current confirm token was issued. The token + // expires SubscriberConfirmTTL later (see ConfirmIssuedAt). nil on rows + // older than the column and on operator-created (born confirmed) rows. + ConfirmSentAt *time.Time `bun:"confirm_sent_at"` + UnsubscribeToken string `bun:"unsubscribe_token,notnull"` + Scope SubscriberScope `bun:"scope,notnull"` + IncidentUID *string `bun:"incident_uid"` // EndpointPrivate is the AES-GCM envelope holding {url, signingSecret} for // an endpoint channel. NEVER serialized. EndpointPrivate *string `bun:"endpoint_private"` @@ -104,6 +108,27 @@ type StatusPageSubscriber struct { DeletedAt *time.Time `bun:"deleted_at"` } +// SubscriberConfirmTTL is how long a confirm link stays valid. Past it the +// link is refused and the state-cleanup job hard-deletes the unconfirmed row, +// so anonymous subscribe traffic cannot grow the table without bound. +const SubscriberConfirmTTL = 7 * 24 * time.Hour + +// ConfirmIssuedAt is when the current confirm token was issued: ConfirmSentAt, +// or CreatedAt for rows written before that column existed. +func (s *StatusPageSubscriber) ConfirmIssuedAt() time.Time { + if s.ConfirmSentAt != nil { + return *s.ConfirmSentAt + } + + return s.CreatedAt +} + +// ConfirmExpired reports whether the subscription is still unconfirmed and its +// confirm token is older than SubscriberConfirmTTL at now. +func (s *StatusPageSubscriber) ConfirmExpired(now time.Time) bool { + return s.ConfirmedAt == nil && now.Sub(s.ConfirmIssuedAt()) > SubscriberConfirmTTL +} + // EmailAddress returns the subscriber's email, or "" for an endpoint channel. func (s *StatusPageSubscriber) EmailAddress() string { if s == nil || s.Email == nil { @@ -116,7 +141,10 @@ func (s *StatusPageSubscriber) EmailAddress() string { // NewStatusPageSubscriber creates an unconfirmed subscriber with a generated // UID and CreatedAt. Tokens are set by the service. func NewStatusPageSubscriber(orgUID, statusPageUID, email string, scope SubscriberScope) *StatusPageSubscriber { + sentAt := time.Now().UTC() + return &StatusPageSubscriber{ + ConfirmSentAt: &sentAt, UID: uuid.New().String(), OrganizationUID: orgUID, StatusPageUID: statusPageUID, diff --git a/server/internal/db/postgres/migrations/031_v0_43_0.tx.down.sql b/server/internal/db/postgres/migrations/031_v0_43_0.tx.down.sql index 1a88d79fb..7d5e5ee55 100644 --- a/server/internal/db/postgres/migrations/031_v0_43_0.tx.down.sql +++ b/server/internal/db/postgres/migrations/031_v0_43_0.tx.down.sql @@ -1,5 +1,9 @@ -- v0.43.0 — Postgres rollback (parity only; downs are never run in production). +-- SECTION: subscriber-confirm-expiry +alter table status_page_subscriber drop column if exists confirm_sent_at; +--bun:split + -- SECTION: device-requester alter table device_auth_requests drop column if exists requester_user_agent; --bun:split diff --git a/server/internal/db/postgres/migrations/031_v0_43_0.tx.up.sql b/server/internal/db/postgres/migrations/031_v0_43_0.tx.up.sql index 5cc08365f..bc4f17127 100644 --- a/server/internal/db/postgres/migrations/031_v0_43_0.tx.up.sql +++ b/server/internal/db/postgres/migrations/031_v0_43_0.tx.up.sql @@ -3,6 +3,7 @@ -- Frozen once tagged, like every migration: a later change goes in 032. -- -- SECTION: device-requester device_auth_requests.requester_ip / requester_user_agent +-- SECTION: subscriber-confirm-expiry status_page_subscriber.confirm_sent_at -- ========================================================================== -- SECTION: device-requester (spec 2026-10-27-01) @@ -22,3 +23,16 @@ comment on column device_auth_requests.requester_ip is 'Client IP that opened th --bun:split comment on column device_auth_requests.requester_user_agent is 'User-Agent header of the client that opened the request, truncated. NULL when absent.'; + +--bun:split + +-- ========================================================================== +-- SECTION: subscriber-confirm-expiry (spec 2026-10-28-02) +-- ========================================================================== +-- When the current confirm token was issued, so a confirm link expires and +-- the state-cleanup job can reap unconfirmed rows nobody came back for. +alter table status_page_subscriber add column if not exists confirm_sent_at timestamptz; + +--bun:split + +comment on column status_page_subscriber.confirm_sent_at is 'When the current confirm token was issued (subscribe or re-subscribe). The token expires 7 days later; unconfirmed rows past that are hard-deleted by the state-cleanup job. NULL for rows created before v0.43.0, which fall back to created_at.'; diff --git a/server/internal/db/postgres/status_page_subscriber.go b/server/internal/db/postgres/status_page_subscriber.go index 44ba2ce9a..18978a0b5 100644 --- a/server/internal/db/postgres/status_page_subscriber.go +++ b/server/internal/db/postgres/status_page_subscriber.go @@ -2,6 +2,7 @@ package postgres import ( "context" + "fmt" "time" "github.com/uptrace/bun" @@ -146,7 +147,8 @@ func (s *Service) ConfirmSubscriber(ctx context.Context, uid string, confirmedAt } // ResubscribeSubscriber soft-undeletes an existing row and refreshes its tokens -// and confirmation state so a returning visitor must confirm again. +// and confirmation state so a returning visitor must confirm again. The new +// confirm token's issue time (confirm_sent_at) restarts its expiry. func (s *Service) ResubscribeSubscriber( ctx context.Context, uid, confirmToken, unsubscribeToken string, ) error { @@ -156,12 +158,39 @@ func (s *Service) ResubscribeSubscriber( Set("confirmed_at = NULL"). Set("confirm_token = ?", confirmToken). Set("unsubscribe_token = ?", unsubscribeToken). + Set("confirm_sent_at = ?", time.Now().UTC()). Where("uid = ?", uid). Exec(ctx) return err } +// DeleteExpiredUnconfirmedSubscribers hard-deletes subscriptions that were +// never confirmed and whose confirm token was issued before `before` (rows +// older than confirm_sent_at fall back to created_at). Confirmed rows are +// never touched, whatever their age. +func (s *Service) DeleteExpiredUnconfirmedSubscribers(ctx context.Context, before time.Time) (int64, error) { + result, err := s.db.NewDelete(). + Model((*models.StatusPageSubscriber)(nil)). + Where("confirmed_at IS NULL"). + WhereGroup(" AND ", func(q *bun.DeleteQuery) *bun.DeleteQuery { + return q. + Where("confirm_sent_at IS NOT NULL AND confirm_sent_at < ?", before). + WhereOr("confirm_sent_at IS NULL AND created_at < ?", before) + }). + Exec(ctx) + if err != nil { + return 0, fmt.Errorf("delete expired unconfirmed subscribers: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return 0, fmt.Errorf("delete expired unconfirmed subscribers rows: %w", err) + } + + return rows, nil +} + // UpdateSubscriberDelivery records the outcome of a webhook/Slack delivery: // the consecutive-failure counter and, when the circuit breaker trips, the // disable timestamp. disabledAt nil CLEARS the column, so a re-enable goes diff --git a/server/internal/db/service.go b/server/internal/db/service.go index e11132bbc..9af3de4ab 100644 --- a/server/internal/db/service.go +++ b/server/internal/db/service.go @@ -1207,6 +1207,9 @@ type Service interface { ) (*models.StatusPageSubscriber, error) ConfirmSubscriber(ctx context.Context, uid string, confirmedAt time.Time) error ResubscribeSubscriber(ctx context.Context, uid, confirmToken, unsubscribeToken string) error + // DeleteExpiredUnconfirmedSubscribers hard-deletes never-confirmed rows + // whose confirm token was issued before `before`; returns the count. + DeleteExpiredUnconfirmedSubscribers(ctx context.Context, before time.Time) (int64, error) SoftDeleteSubscriber(ctx context.Context, uid string) error // UpdateSubscriberDelivery records a webhook/Slack delivery outcome: the // consecutive-failure counter and the circuit-breaker timestamp (nil diff --git a/server/internal/db/sqlite/migrations/031_v0_43_0.tx.down.sql b/server/internal/db/sqlite/migrations/031_v0_43_0.tx.down.sql index 5d8f2233c..7b068ac38 100644 --- a/server/internal/db/sqlite/migrations/031_v0_43_0.tx.down.sql +++ b/server/internal/db/sqlite/migrations/031_v0_43_0.tx.down.sql @@ -1,5 +1,8 @@ -- v0.43.0 — SQLite rollback (parity only; downs are never run in production). +-- SECTION: subscriber-confirm-expiry +alter table status_page_subscriber drop column confirm_sent_at; + -- SECTION: device-requester alter table device_auth_requests drop column requester_user_agent; alter table device_auth_requests drop column requester_ip; diff --git a/server/internal/db/sqlite/migrations/031_v0_43_0.tx.up.sql b/server/internal/db/sqlite/migrations/031_v0_43_0.tx.up.sql index 792744878..5fc78e69f 100644 --- a/server/internal/db/sqlite/migrations/031_v0_43_0.tx.up.sql +++ b/server/internal/db/sqlite/migrations/031_v0_43_0.tx.up.sql @@ -9,3 +9,9 @@ alter table device_auth_requests add column requester_ip text; -- device_auth_requests.requester_user_agent: User-Agent of that client, -- truncated; NULL when absent. alter table device_auth_requests add column requester_user_agent text; + +-- SECTION: subscriber-confirm-expiry (spec 2026-10-28-02) +-- status_page_subscriber.confirm_sent_at: when the current confirm token was +-- issued; the token expires 7 days later and unconfirmed rows past that are +-- reaped by the state-cleanup job. NULL for older rows (created_at applies). +alter table status_page_subscriber add column confirm_sent_at text; diff --git a/server/internal/db/sqlite/status_page_subscriber.go b/server/internal/db/sqlite/status_page_subscriber.go index 18b7ab621..a95c3e52a 100644 --- a/server/internal/db/sqlite/status_page_subscriber.go +++ b/server/internal/db/sqlite/status_page_subscriber.go @@ -2,6 +2,7 @@ package sqlite import ( "context" + "fmt" "time" "github.com/uptrace/bun" @@ -146,7 +147,8 @@ func (s *Service) ConfirmSubscriber(ctx context.Context, uid string, confirmedAt } // ResubscribeSubscriber soft-undeletes an existing row and refreshes its tokens -// and confirmation state so a returning visitor must confirm again. +// and confirmation state so a returning visitor must confirm again. The new +// confirm token's issue time (confirm_sent_at) restarts its expiry. func (s *Service) ResubscribeSubscriber( ctx context.Context, uid, confirmToken, unsubscribeToken string, ) error { @@ -156,12 +158,39 @@ func (s *Service) ResubscribeSubscriber( Set("confirmed_at = NULL"). Set("confirm_token = ?", confirmToken). Set("unsubscribe_token = ?", unsubscribeToken). + Set("confirm_sent_at = ?", time.Now().UTC()). Where("uid = ?", uid). Exec(ctx) return err } +// DeleteExpiredUnconfirmedSubscribers hard-deletes subscriptions that were +// never confirmed and whose confirm token was issued before `before` (rows +// older than confirm_sent_at fall back to created_at). Confirmed rows are +// never touched, whatever their age. +func (s *Service) DeleteExpiredUnconfirmedSubscribers(ctx context.Context, before time.Time) (int64, error) { + result, err := s.db.NewDelete(). + Model((*models.StatusPageSubscriber)(nil)). + Where("confirmed_at IS NULL"). + WhereGroup(" AND ", func(q *bun.DeleteQuery) *bun.DeleteQuery { + return q. + Where("confirm_sent_at IS NOT NULL AND confirm_sent_at < ?", before). + WhereOr("confirm_sent_at IS NULL AND created_at < ?", before) + }). + Exec(ctx) + if err != nil { + return 0, fmt.Errorf("delete expired unconfirmed subscribers: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return 0, fmt.Errorf("delete expired unconfirmed subscribers rows: %w", err) + } + + return rows, nil +} + // UpdateSubscriberDelivery records the outcome of a webhook/Slack delivery: // the consecutive-failure counter and, when the circuit breaker trips, the // disable timestamp. disabledAt nil CLEARS the column, so a re-enable goes diff --git a/server/internal/email/supportreply.go b/server/internal/email/supportreply.go index eecfa4eeb..6f2b54f1a 100644 --- a/server/internal/email/supportreply.go +++ b/server/internal/email/supportreply.go @@ -52,6 +52,7 @@ const ( TemplateIncidentDegradedResolv = "incident-degraded-resolved.html" TemplateEmailChanged = "email-changed.html" TemplateDeviceTokenCreated = "device-token-created.html" + TemplatePasskeyAdded = "passkey-added.html" TemplateIncidentAcknowledged = "incident-acknowledged.html" TemplateIncidentUnacknowledged = "incident-unacknowledged.html" TemplateIncidentComment = "incident-comment.html" @@ -143,6 +144,7 @@ var supportReplyableTemplates = map[string]bool{ TemplatePasswordChanged: false, TemplateEmailChanged: false, TemplateDeviceTokenCreated: false, + TemplatePasskeyAdded: false, TemplatePasswordReset: false, TemplatePasswordResetSSO: false, TemplateRegistration: false, diff --git a/server/internal/email/templates/passkey-added.html b/server/internal/email/templates/passkey-added.html new file mode 100644 index 000000000..e03368134 --- /dev/null +++ b/server/internal/email/templates/passkey-added.html @@ -0,0 +1,18 @@ +{{define "subject"}}{{t "account.passkeyAdded.subject"}}{{end}} +{{define "preheader"}}{{t "account.passkeyAdded.preheader" "name" .PasskeyName}}{{end}} +{{template "base.html" .}} +{{define "content"}} +

{{t "account.passkeyAdded.title"}}

+

{{t "account.passkeyAdded.body" "name" .PasskeyName "date" (date .CreatedAt)}}

+

{{t "account.passkeyAdded.revoke"}}

+
{{template "button" dict "URL" .SecurityURL "Label" (t "account.passkeyAdded.cta") "Variant" "primary"}}
+

{{t "account.securityNotice.warning"}}

+{{end}} +{{define "text"}}{{t "account.passkeyAdded.title"}} + +{{t "account.passkeyAdded.body" "name" .PasskeyName "date" (date .CreatedAt)}} + +{{t "account.passkeyAdded.revoke"}} {{.SecurityURL}} + +{{t "account.securityNotice.warning"}} +{{end}} diff --git a/server/internal/envcheck/docs_defaults_test.go b/server/internal/envcheck/docs_defaults_test.go index d9440656f..31e2cc0a7 100644 --- a/server/internal/envcheck/docs_defaults_test.go +++ b/server/internal/envcheck/docs_defaults_test.go @@ -143,6 +143,16 @@ var docsDefaultAccessors = map[string]func(*config.Config) string{ "SP_LDAP_USER_FILTER": fixed(config.LDAPDefaultUserFilter), "SP_LDAP_EMAIL_ATTRIBUTE": fixed(config.LDAPDefaultEmailAttribute), "SP_LDAP_NAME_ATTRIBUTE": fixed(config.LDAPDefaultNameAttribute), + + "SP_STATUS_SUBSCRIBERS_CONFIRM_PER_RECIPIENT_PER_HOUR": intOf(func(c *config.Config) int { + return c.StatusSubscribers.ConfirmPerRecipientPerHour + }), + "SP_STATUS_SUBSCRIBERS_CONFIRM_PER_PAGE_PER_HOUR": intOf(func(c *config.Config) int { + return c.StatusSubscribers.ConfirmPerPagePerHour + }), + "SP_STATUS_SUBSCRIBERS_CONFIRM_PER_IP_PER_HOUR": intOf(func(c *config.Config) int { + return c.StatusSubscribers.ConfirmPerIPPerHour + }), } func fixed(v string) func(*config.Config) string { return func(*config.Config) string { return v } } diff --git a/server/internal/handlers/auth/device_handler.go b/server/internal/handlers/auth/device_handler.go index 9191ed446..f7be66e2d 100644 --- a/server/internal/handlers/auth/device_handler.go +++ b/server/internal/handlers/auth/device_handler.go @@ -212,6 +212,9 @@ func (h *Handler) RespondToDeviceConsent(writer http.ResponseWriter, req *http.R // writeDeviceConsentError maps consent failures onto the house error shape. func (h *Handler) writeDeviceConsentError(writer http.ResponseWriter, request *http.Request, err error) error { switch { + case errors.Is(err, ErrPATForbidden): + return h.WriteErrorErr(writer, request, http.StatusForbidden, base.ErrorCodePATForbidden, + PATForbiddenMessage, err) case errors.Is(err, ErrDeviceAuthNotFound): return h.WriteError(writer, http.StatusNotFound, base.ErrorCodeNotFound, "Device authorization request not found or expired") diff --git a/server/internal/handlers/auth/device_service.go b/server/internal/handlers/auth/device_service.go index d4131d009..efe0a8e2e 100644 --- a/server/internal/handlers/auth/device_service.go +++ b/server/internal/handlers/auth/device_service.go @@ -248,6 +248,14 @@ func (s *Service) GetDeviceConsent(ctx context.Context, rawUserCode string) (*De func (s *Service) RespondToDeviceConsent( ctx context.Context, claims *Claims, rawUserCode, orgSlug string, approve bool, ) error { + // A PAT must not approve a device grant (spec 2026-10-28-01): the org is + // taken from the body, so it would mint a PAT for any org of its owner. + // RequireAuth refuses the route already; this holds if it is ever reached + // another way. + if claims.IsPAT() || IsPATCredential(ctx) { + return ErrPATForbidden + } + userCode := NormalizeDeviceUserCode(rawUserCode) if userCode == "" { return ErrDeviceAuthNotFound diff --git a/server/internal/handlers/auth/email_change.go b/server/internal/handlers/auth/email_change.go index 4c1fd335b..7737fefab 100644 --- a/server/internal/handlers/auth/email_change.go +++ b/server/internal/handlers/auth/email_change.go @@ -268,6 +268,12 @@ func (s *Service) changeOwnEmail(ctx context.Context, claims *Claims, req Update return false, ErrImpersonationForbidden } + // The sign-in email is a sign-in factor: a PAT bound to one org must not + // change it (spec 2026-10-28-01). The rest of PATCH /auth/me stays open. + if claims.IsPAT() { + return false, ErrPATForbidden + } + user, err := s.db.GetUser(ctx, claims.UserUID) if err != nil || user == nil { return false, ErrUserNotFound diff --git a/server/internal/handlers/auth/handler.go b/server/internal/handlers/auth/handler.go index ab311727f..8709bcb00 100644 --- a/server/internal/handlers/auth/handler.go +++ b/server/internal/handlers/auth/handler.go @@ -371,6 +371,9 @@ func (h *Handler) handleEmailChangeError( case errors.Is(err, ErrImpersonationForbidden): return true, h.WriteErrorErr(writer, req, http.StatusForbidden, base.ErrorCodeImpersonationForbidden, ImpersonationForbiddenMessage, err) + case errors.Is(err, ErrPATForbidden): + return true, h.WriteErrorErr(writer, req, http.StatusForbidden, base.ErrorCodePATForbidden, + PATForbiddenMessage, err) case errors.Is(err, ErrEmailChangeNotSuperAdmin): return true, h.WriteErrorErr(writer, req, http.StatusForbidden, base.ErrorCodeForbidden, err.Error(), err) case errors.Is(err, ErrRateLimited): @@ -551,6 +554,8 @@ func (h *Handler) handleAuthError(writer http.ResponseWriter, request *http.Requ case errors.Is(err, ErrInvalidCredentials), errors.Is(err, ErrOrganizationNotFound), errors.Is(err, ErrUserNotFound): return h.WriteErrorErr( writer, request, http.StatusUnauthorized, base.ErrorCodeInvalidCredentials, "Invalid credentials", err) + case errors.Is(err, ErrPATForbidden): + return h.WriteErrorErr(writer, request, http.StatusForbidden, base.ErrorCodePATForbidden, PATForbiddenMessage, err) default: return h.WriteInternalError(writer, request, err) } diff --git a/server/internal/handlers/auth/invite_hash_test.go b/server/internal/handlers/auth/invite_hash_test.go index 8bdb5cc94..245dfb2ef 100644 --- a/server/internal/handlers/auth/invite_hash_test.go +++ b/server/internal/handlers/auth/invite_hash_test.go @@ -110,19 +110,18 @@ func createHashTestInvite( return resp } -// writeLegacyInvite stores an invite the way CreateInvitation did before -// spec 2026-09-26-02: keyed by the plaintext token, token in the value. -func writeLegacyInvite( +// writePreIndexInvite stores an org-scoped invite the way CreateInvitation +// did before spec 2026-10-28-03: hashed key, no global index entry. +func writePreIndexInvite( t *testing.T, dbSvc db.Service, org *models.Organization, inviterUID, email, token string, ) string { t.Helper() ctx := t.Context() - key := inviteKeyPrefix + token + key := inviteKeyPrefix + hashPendingToken(token) ttl := 24 * time.Hour require.NoError(t, dbSvc.SetStateEntry(ctx, &org.UID, key, &models.JSONMap{ - keyToken: token, keyEmail: email, "role": string(models.MemberRoleViewer), "inviterUID": inviterUID, @@ -252,38 +251,94 @@ func runInviteHashContract(t *testing.T, svc *Service, dbSvc db.Service) { r.ErrorIs(err, ErrInvitationNotFound) }) - t.Run("a legacy plaintext-keyed invite is still accepted and deleted", func(t *testing.T) { + t.Run("a legacy plaintext-keyed invite is not accepted", func(t *testing.T) { r := require.New(t) ctx := t.Context() org, inviter := inviteHashFixture(t, dbSvc, "ih-legacy") + // Shape of an invite written before spec 2026-09-26-02: keyed by the + // plaintext token, token in the value. Its fallback branch is gone + // (spec 2026-10-28-03). const legacyToken = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" - key := writeLegacyInvite(t, dbSvc, org, inviter.UID, "legacy@acme.com", legacyToken) + ttl := 24 * time.Hour + r.NoError(dbSvc.SetStateEntry(ctx, &org.UID, inviteKeyPrefix+legacyToken, &models.JSONMap{ + keyToken: legacyToken, + keyEmail: "legacy@acme.com", + "role": string(models.MemberRoleViewer), + "inviterUID": inviter.UID, + }, &ttl)) - info, err := svc.GetInviteInfo(ctx, legacyToken) - r.NoError(err) - r.Equal(org.Slug, info.OrgSlug) - r.Equal(string(models.MemberRoleViewer), info.Role) + _, err := svc.GetInviteInfo(ctx, legacyToken) + r.ErrorIs(err, ErrInvitationNotFound) - login, err := svc.AcceptInvite(ctx, AcceptInviteRequest{ + _, err = svc.AcceptInvite(ctx, AcceptInviteRequest{ Token: legacyToken, Name: "Bob", Password: inviteHashTestPassword, }, "") + r.ErrorIs(err, ErrInvitationNotFound) + }) + + t.Run("an invite without its index entry is not found", func(t *testing.T) { + r := require.New(t) + ctx := t.Context() + org, inviter := inviteHashFixture(t, dbSvc, "ih-noindex") + + const token = "abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd" + + writePreIndexInvite(t, dbSvc, org, inviter.UID, "noindex@acme.com", token) + + _, err := svc.GetInviteInfo(ctx, token) + r.ErrorIs(err, ErrInvitationNotFound, "pre-upgrade invites must be re-sent, no org scan") + }) + + t.Run("creation writes a global index entry pointing at the org", func(t *testing.T) { + r := require.New(t) + ctx := t.Context() + org, inviter := inviteHashFixture(t, dbSvc, "ih-index") + + resp := createHashTestInvite(t, svc, org.Slug, inviter.UID, "index@acme.com") + key := inviteKeyPrefix + hashPendingToken(resp.Token) + + index, err := dbSvc.GetStateEntry(ctx, nil, key) r.NoError(err) - r.Equal(org.Slug, login.Organization.Slug) + r.NotNil(index) + r.Nil(index.OrganizationUID) + r.NotNil(index.Value) + r.Equal(org.UID, (*index.Value)[keyInviteOrgUID]) + r.NotNil(index.ExpiresAt, "the index entry expires with the invite") - entry, err := dbSvc.GetStateEntry(ctx, &org.UID, key) + for field, v := range *index.Value { + if s, ok := v.(string); ok { + r.NotContains(s, resp.Token, "index field %q leaks the plaintext token", field) + } + } + + _, err = svc.AcceptInvite(ctx, AcceptInviteRequest{ + Token: resp.Token, Name: "Ida", Password: inviteHashTestPassword, + }, "") r.NoError(err) - r.Nil(entry, "the legacy entry must be deleted on acceptance") - user, err := dbSvc.GetUserByEmail(ctx, "legacy@acme.com") + index, err = dbSvc.GetStateEntry(ctx, nil, key) r.NoError(err) - member, err := dbSvc.GetMemberByUserAndOrg(ctx, user.UID, org.UID) + r.Nil(index, "acceptance deletes the index entry") + }) + + t.Run("an index entry whose org entry is gone is not found", func(t *testing.T) { + r := require.New(t) + ctx := t.Context() + org, inviter := inviteHashFixture(t, dbSvc, "ih-dangling") + + resp := createHashTestInvite(t, svc, org.Slug, inviter.UID, "dangling@acme.com") + key := inviteKeyPrefix + hashPendingToken(resp.Token) + + _, err := dbSvc.DeleteStateEntry(ctx, &org.UID, key) r.NoError(err) - r.Equal(models.MemberRoleViewer, member.Role) + + _, err = svc.GetInviteInfo(ctx, resp.Token) + r.ErrorIs(err, ErrInvitationNotFound) }) - t.Run("already-member acceptance deletes the matched key, legacy and hashed", func(t *testing.T) { + t.Run("already-member acceptance deletes the invite and its index", func(t *testing.T) { r := require.New(t) ctx := t.Context() org, inviter := inviteHashFixture(t, dbSvc, "ih-member") @@ -293,25 +348,18 @@ func runInviteHashContract(t *testing.T, svc *Service, dbSvc db.Service) { r.NoError(dbSvc.CreateOrganizationMember(ctx, models.NewOrganizationMember(org.UID, existing.UID, models.MemberRoleUser))) - const legacyToken = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" - - legacyKey := writeLegacyInvite(t, dbSvc, org, inviter.UID, existing.Email, legacyToken) - - _, err := svc.AcceptInvite(ctx, AcceptInviteRequest{Token: legacyToken}, existing.UID) - r.NoError(err) - - entry, err := dbSvc.GetStateEntry(ctx, &org.UID, legacyKey) - r.NoError(err) - r.Nil(entry, "the legacy entry must be deleted for an existing member") - resp := createHashTestInvite(t, svc, org.Slug, inviter.UID, existing.Email) - _, err = svc.AcceptInvite(ctx, AcceptInviteRequest{Token: resp.Token}, existing.UID) + _, err := svc.AcceptInvite(ctx, AcceptInviteRequest{Token: resp.Token}, existing.UID) r.NoError(err) entries, err := dbSvc.ListStateEntries(ctx, &org.UID, inviteKeyPrefix) r.NoError(err) r.Empty(entries, "the hashed entry must be deleted for an existing member") + + index, err := dbSvc.GetStateEntry(ctx, nil, inviteKeyPrefix+hashPendingToken(resp.Token)) + r.NoError(err) + r.Nil(index, "the index entry must be deleted for an existing member") }) t.Run("list and revoke work on a hashed-key invite", func(t *testing.T) { @@ -336,5 +384,33 @@ func runInviteHashContract(t *testing.T, svc *Service, dbSvc db.Service) { _, err = svc.GetInviteInfo(ctx, resp.Token) r.ErrorIs(err, ErrInvitationNotFound) + + index, err := dbSvc.GetStateEntry(ctx, nil, inviteKeyPrefix+hashPendingToken(resp.Token)) + r.NoError(err) + r.Nil(index, "revocation deletes the index entry") + }) + + t.Run("a federated join through the invite deletes its index", func(t *testing.T) { + r := require.New(t) + ctx := t.Context() + org, inviter := inviteHashFixture(t, dbSvc, "ih-fedjoin") + + resp := createHashTestInvite(t, svc, org.Slug, inviter.UID, "fedjoin@acme.com") + + user := models.NewUser("fedjoin@acme.com") + r.NoError(dbSvc.CreateUser(ctx, user)) + + member, pending, err := svc.JoinOrgViaLogin(ctx, org, user) + r.NoError(err) + r.False(pending) + r.NotNil(member) + + index, err := dbSvc.GetStateEntry(ctx, nil, inviteKeyPrefix+hashPendingToken(resp.Token)) + r.NoError(err) + r.Nil(index, "a consumed invite leaves no index entry") + }) + + t.Run("lookup cost does not depend on the organization count", func(t *testing.T) { + runInviteLookupQueryCount(t, svc, dbSvc) }) } diff --git a/server/internal/handlers/auth/invite_lookup_query_count_test.go b/server/internal/handlers/auth/invite_lookup_query_count_test.go new file mode 100644 index 000000000..c43a57a15 --- /dev/null +++ b/server/internal/handlers/auth/invite_lookup_query_count_test.go @@ -0,0 +1,88 @@ +package auth + +import ( + "context" + "fmt" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/require" + "github.com/uptrace/bun" + + "github.com/fclairamb/solidping/server/internal/db" + "github.com/fclairamb/solidping/server/internal/db/models" +) + +// inviteQueryCounter counts the queries bun runs while armed. +type inviteQueryCounter struct { + armed atomic.Bool + total atomic.Int64 +} + +func (c *inviteQueryCounter) BeforeQuery(ctx context.Context, _ *bun.QueryEvent) context.Context { + return ctx +} + +func (c *inviteQueryCounter) AfterQuery(_ context.Context, _ *bun.QueryEvent) { + if c.armed.Load() { + c.total.Add(1) + } +} + +// count returns how many queries fn runs. +func (c *inviteQueryCounter) count(fn func()) int64 { + c.total.Store(0) + c.armed.Store(true) + fn() + c.armed.Store(false) + + return c.total.Load() +} + +// runInviteLookupQueryCount is the regression test of spec 2026-10-28-03: +// the public invite lookup used to probe every organization (about 2 x N +// queries for an unknown token). It must cost the same with 50 more orgs. +func runInviteLookupQueryCount(t *testing.T, svc *Service, dbSvc db.Service) { + t.Helper() + + r := require.New(t) + ctx := t.Context() + + counter := &inviteQueryCounter{} + dbSvc.DB().AddQueryHook(counter) + + org, inviter := inviteHashFixture(t, dbSvc, "ih-qcount") + resp := createHashTestInvite(t, svc, org.Slug, inviter.UID, "qcount@acme.com") + + const unknown = "1111111111111111111111111111111111111111111111111111111111111111" + + lookUp := func(token string) (int64, error) { + var err error + + n := counter.count(func() { _, err = svc.GetInviteInfo(ctx, token) }) + + return n, err + } + + unknownBefore, err := lookUp(unknown) + r.ErrorIs(err, ErrInvitationNotFound) + r.GreaterOrEqual(unknownBefore, int64(1), "positive control: the hook must fire") + + validBefore, err := lookUp(resp.Token) + r.NoError(err) + + for i := range 50 { + r.NoError(dbSvc.CreateOrganization(ctx, + models.NewOrganization(fmt.Sprintf("ih-qcount-%02d", i), "Query count org"))) + } + + unknownAfter, err := lookUp(unknown) + r.ErrorIs(err, ErrInvitationNotFound) + r.Equal(unknownBefore, unknownAfter, "an unknown token must not cost more with more orgs") + r.LessOrEqual(unknownAfter, int64(2), "an unknown token is one index lookup") + + validAfter, err := lookUp(resp.Token) + r.NoError(err) + r.Equal(validBefore, validAfter, "a valid token must not cost more with more orgs") + r.LessOrEqual(validAfter, int64(4)) +} diff --git a/server/internal/handlers/auth/join_policy.go b/server/internal/handlers/auth/join_policy.go index 31d22e957..e4ffcf4e5 100644 --- a/server/internal/handlers/auth/join_policy.go +++ b/server/internal/handlers/auth/join_policy.go @@ -235,7 +235,7 @@ func (s *Service) JoinOrgViaLogin( } // Consume the invitation — it is single-use, like AcceptInvite's. - _, _ = s.db.DeleteStateEntry(ctx, &org.UID, invite.key) + _ = s.deleteInvite(ctx, org.UID, invite.key) return created, false, nil } diff --git a/server/internal/handlers/auth/passkey_notice.go b/server/internal/handlers/auth/passkey_notice.go new file mode 100644 index 000000000..e18650024 --- /dev/null +++ b/server/internal/handlers/auth/passkey_notice.go @@ -0,0 +1,36 @@ +package auth + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/fclairamb/solidping/server/internal/config" + "github.com/fclairamb/solidping/server/internal/db/models" + "github.com/fclairamb/solidping/server/internal/email" + "github.com/fclairamb/solidping/server/internal/handlers/base" + "github.com/fclairamb/solidping/server/internal/i18n/recipient" +) + +// notifyPasskeyAdded emails the account owner a record of the passkey just +// registered (spec 2026-10-28-01). A passkey signs in to every org of the +// account, counts as MFA and survives a password reset, so one added behind +// the owner's back must not go unnoticed. Best effort, like the device token +// notice: the passkey is already stored. +func (s *Service) notifyPasskeyAdded(ctx context.Context, user *models.User, passkey *models.UserPasskey) { + dashboardURL := strings.TrimRight(s.fullCfg.Server.BaseURL, "/") + config.DashboardBasePath + securityURL := dashboardURL + "/" + + if claims, ok := ctx.Value(base.ContextKeyClaims).(*Claims); ok && claims.OrgSlug != "" { + securityURL = fmt.Sprintf("%s/orgs/%s/account/security", dashboardURL, claims.OrgSlug) + } + + s.enqueueEmail(ctx, "", user.Email, email.TemplatePasskeyAdded, recipient.ForUser(user), + map[string]any{ + "PasskeyName": passkey.Name, + "CreatedAt": time.Now().UTC(), + "SecurityURL": securityURL, + }, + ) +} diff --git a/server/internal/handlers/auth/passkey_notice_test.go b/server/internal/handlers/auth/passkey_notice_test.go new file mode 100644 index 000000000..9a810c8f0 --- /dev/null +++ b/server/internal/handlers/auth/passkey_notice_test.go @@ -0,0 +1,77 @@ +package auth_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/fclairamb/solidping/server/internal/config" + "github.com/fclairamb/solidping/server/internal/db/models" + "github.com/fclairamb/solidping/server/internal/db/sqlite" + "github.com/fclairamb/solidping/server/internal/email" + "github.com/fclairamb/solidping/server/internal/handlers/auth" + "github.com/fclairamb/solidping/server/internal/handlers/base" + "github.com/fclairamb/solidping/server/internal/jobs/jobdef" + "github.com/fclairamb/solidping/server/internal/jobs/jobsvc" + "github.com/fclairamb/solidping/server/internal/notifier" +) + +// TestPasskeyRegistrationNotifiesOwner: registering a passkey from a session +// still works, and enqueues a security notice to the account owner pointing at +// the account security page (spec 2026-10-28-01). +func TestPasskeyRegistrationNotifiesOwner(t *testing.T) { + t.Parallel() + r := require.New(t) + ctx := t.Context() + + dbSvc, err := sqlite.New(ctx, sqlite.Config{InMemory: true}) + r.NoError(err) + r.NoError(dbSvc.Initialize(ctx)) + t.Cleanup(func() { _ = dbSvc.Close() }) + + user := models.NewUser("alice@acme.com") + r.NoError(dbSvc.CreateUser(ctx, user)) + + cfg := &config.Config{ + Auth: config.AuthConfig{ + JWTSecret: "test-secret-1234567890", + WebAuthn: config.WebAuthnConfig{Enabled: true}, + }, + Server: config.ServerConfig{BaseURL: passkeyTestOrigin}, + } + jobs := jobsvc.NewService(dbSvc.DB(), dbSvc, notifier.NewLocalEventNotifier(), nil) + authSvc := auth.NewService(dbSvc, cfg.Auth, cfg, jobs, nil) + pkSvc := auth.NewPasskeyService(authSvc, dbSvc) + + // The request context RequireAuth builds for a session in org acme. + reqCtx := context.WithValue(ctx, base.ContextKeyClaims, &auth.Claims{UserUID: user.UID, OrgSlug: "acme"}) + + device := newSoftAuthenticator(t) + begin, err := pkSvc.BeginRegistration(reqCtx, user.UID) + r.NoError(err) + + info, err := pkSvc.FinishRegistration(reqCtx, begin.Session, + device.register(t, begin.Options.Response.Challenge.String()), "Laptop key") + r.NoError(err) + r.Equal("Laptop key", info.Name) + + listed, err := dbSvc.ListJobs(ctx, nil, 0) + r.NoError(err) + + var notices []*models.Job + + for _, job := range listed { + if job.Type == string(jobdef.JobTypeEmail) && job.Config["template"] == email.TemplatePasskeyAdded { + notices = append(notices, job) + } + } + + r.Len(notices, 1, "exactly one passkey notice") + r.Equal([]any{user.Email}, notices[0].Config["to"]) + + data, ok := notices[0].Config["templateData"].(map[string]any) + r.True(ok, "template data present: %v", notices[0].Config) + r.Equal("Laptop key", data["PasskeyName"]) + r.Equal(passkeyTestOrigin+config.DashboardBasePath+"/orgs/acme/account/security", data["SecurityURL"]) +} diff --git a/server/internal/handlers/auth/passkey_service.go b/server/internal/handlers/auth/passkey_service.go index 6df6e9f2f..b3b86fc33 100644 --- a/server/internal/handlers/auth/passkey_service.go +++ b/server/internal/handlers/auth/passkey_service.go @@ -278,6 +278,8 @@ func (s *PasskeyService) FinishRegistration( return nil, fmt.Errorf("store passkey: %w", err) } + s.authSvc.notifyPasskeyAdded(ctx, user, row) + info := passkeyInfoFromModel(row) return &info, nil diff --git a/server/internal/handlers/auth/pat_binding_test.go b/server/internal/handlers/auth/pat_binding_test.go new file mode 100644 index 000000000..9d9813f20 --- /dev/null +++ b/server/internal/handlers/auth/pat_binding_test.go @@ -0,0 +1,231 @@ +package auth + +import ( + "context" + "strconv" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/fclairamb/solidping/server/internal/db" + "github.com/fclairamb/solidping/server/internal/db/models" + "github.com/fclairamb/solidping/server/internal/db/postgres" + "github.com/fclairamb/solidping/server/internal/db/sqlite" + "github.com/fclairamb/solidping/server/internal/testsupport" +) + +// portPATBindingPG is distinct from every other embedded-Postgres port +// claimed in the repo (see the port-numbering note in +// internal/db/incident_number_test.go). +const portPATBindingPG = 15673 + +// patBindingFixture: one user, admin of org A and org B, holding a PAT created +// in org A (spec 2026-10-28-01). +type patBindingFixture struct { + svc *Service + db db.Service + orgA *models.Organization + orgB *models.Organization + user *models.User + claims *Claims +} + +// patCtx is the request context RequireAuth builds for a PAT. +func patCtx(ctx context.Context) context.Context { + return WithPATCredential(ctx) +} + +func newPATBindingFixture( + ctx context.Context, t *testing.T, svc *Service, dbSvc db.Service, suffix string, +) *patBindingFixture { + t.Helper() + r := require.New(t) + + orgA := models.NewOrganization("pata"+suffix, "Acme A "+suffix) + r.NoError(dbSvc.CreateOrganization(ctx, orgA)) + orgB := models.NewOrganization("patb"+suffix, "Acme B "+suffix) + r.NoError(dbSvc.CreateOrganization(ctx, orgB)) + + user := models.NewUser("alice-" + suffix + "@acme.com") + r.NoError(dbSvc.CreateUser(ctx, user)) + + now := time.Now() + for _, org := range []*models.Organization{orgA, orgB} { + member := models.NewOrganizationMember(org.UID, user.UID, models.MemberRoleAdmin) + member.JoinedAt = &now + r.NoError(dbSvc.CreateOrganizationMember(ctx, member)) + } + + pat, err := svc.CreatePAT(ctx, orgA.Slug, user.UID, CreateTokenRequest{Name: "ci"}) + r.NoError(err) + + claims, err := svc.ValidateToken(ctx, pat.Token) + r.NoError(err) + r.True(claims.IsPAT()) + r.Equal(orgA.Slug, claims.OrgSlug) + + return &patBindingFixture{ + svc: svc, db: dbSvc, orgA: orgA, orgB: orgB, user: user, claims: claims, + } +} + +func (f *patBindingFixture) tokenCount(ctx context.Context, t *testing.T, tokenType models.TokenType) int { + t.Helper() + + tokens, err := f.db.ListUserTokensByType(ctx, f.user.UID, tokenType) + require.NoError(t, err) + + return len(tokens) +} + +// runPATBindingContract is the service half of the PAT binding, shared by the +// SQLite test and its Postgres twin. The route half (RequireAuth's denylist) +// is covered against the real route table in internal/app. +func runPATBindingContract(t *testing.T, newFixture func(t *testing.T) (*patBindingFixture, context.Context)) { + t.Helper() + + t.Run("switch-org from a PAT mints no session", func(t *testing.T) { + t.Parallel() + r := require.New(t) + f, ctx := newFixture(t) + + before := f.tokenCount(ctx, t, models.TokenTypeRefresh) + + _, err := f.svc.SwitchOrg(patCtx(ctx), f.user.UID, f.orgB.Slug, Context{}) + r.ErrorIs(err, ErrPATForbidden) + r.Equal(before, f.tokenCount(ctx, t, models.TokenTypeRefresh), "no refresh token was minted") + + // Positive control: the same call from an interactive session works. + resp, err := f.svc.SwitchOrg(ctx, f.user.UID, f.orgB.Slug, Context{}) + r.NoError(err) + r.NotEmpty(resp.RefreshToken) + r.Equal(before+1, f.tokenCount(ctx, t, models.TokenTypeRefresh)) + }) + + t.Run("device consent from a PAT mints no PAT", func(t *testing.T) { + t.Parallel() + r := require.New(t) + f, ctx := newFixture(t) + + before := f.tokenCount(ctx, t, models.TokenTypePAT) + + start, err := f.svc.StartDeviceAuthorization(ctx, "sp CLI", DeviceRequester{IP: "203.0.113.7"}) + r.NoError(err) + + err = f.svc.RespondToDeviceConsent(patCtx(ctx), f.claims, start.UserCode, f.orgB.Slug, true) + r.ErrorIs(err, ErrPATForbidden) + r.Equal(before, f.tokenCount(ctx, t, models.TokenTypePAT), "no PAT was minted") + + // Positive control: a session approves the same request. + session := &Claims{UserUID: f.user.UID, OrgSlug: f.orgA.Slug} + r.NoError(f.svc.RespondToDeviceConsent(ctx, session, start.UserCode, f.orgB.Slug, true)) + r.Equal(before+1, f.tokenCount(ctx, t, models.TokenTypePAT)) + }) + + t.Run("a PAT cannot change the sign-in email", func(t *testing.T) { + t.Parallel() + r := require.New(t) + f, ctx := newFixture(t) + + newEmail := "mallory-" + f.orgA.Slug + "@acme.com" + _, err := f.svc.UpdateProfile(patCtx(ctx), f.claims, UpdateProfileRequest{Email: &newEmail}) + r.ErrorIs(err, ErrPATForbidden) + + user, err := f.db.GetUser(ctx, f.user.UID) + r.NoError(err) + r.Equal(f.user.Email, user.Email) + + // The rest of the profile stays editable with a PAT. + name := "Alice" + _, err = f.svc.UpdateProfile(patCtx(ctx), f.claims, UpdateProfileRequest{Name: &name}) + r.NoError(err) + }) + + t.Run("a PAT still creates a PAT in its own org", func(t *testing.T) { + t.Parallel() + r := require.New(t) + f, ctx := newFixture(t) + + created, err := f.svc.CreatePAT(patCtx(ctx), f.orgA.Slug, f.user.UID, CreateTokenRequest{Name: "another"}) + r.NoError(err) + + claims, err := f.svc.ValidateToken(patCtx(ctx), created.Token) + r.NoError(err) + r.Equal(f.orgA.Slug, claims.OrgSlug) + }) + + t.Run("startSession refuses a PAT request", func(t *testing.T) { + t.Parallel() + r := require.New(t) + f, ctx := newFixture(t) + + before := f.tokenCount(ctx, t, models.TokenTypeRefresh) + row := &models.UserToken{UID: "pat-session-" + f.orgA.Slug, UserUID: f.user.UID, Type: models.TokenTypeRefresh} + + err := f.svc.startSession(patCtx(ctx), sessionActor{UID: f.user.UID, Email: f.user.Email}, + f.orgA, row, string(models.MemberRoleAdmin), "password", Context{}) + r.ErrorIs(err, ErrPATForbidden) + r.Equal(before, f.tokenCount(ctx, t, models.TokenTypeRefresh)) + }) +} + +func TestPATBinding(t *testing.T) { + t.Parallel() + + var seq atomic.Int64 + + runPATBindingContract(t, func(t *testing.T) (*patBindingFixture, context.Context) { + t.Helper() + + ctx := t.Context() + dbSvc, err := sqlite.New(ctx, sqlite.Config{InMemory: true}) + require.NoError(t, err) + require.NoError(t, dbSvc.Initialize(ctx)) + t.Cleanup(func() { _ = dbSvc.Close() }) + + cfg := impersonationTestConfig() + svc := NewService(dbSvc, cfg.Auth, cfg, nil, nil) + + return newPATBindingFixture(ctx, t, svc, dbSvc, "s"+strconv.FormatInt(seq.Add(1), 10)), ctx + }) +} + +// TestPATBinding_Postgres is the real-engine twin of TestPATBinding. +func TestPATBinding_Postgres(t *testing.T) { + t.Parallel() + + if testing.Short() { + t.Skip("skipping embedded-postgres test in -short mode") + } + + ctx := t.Context() + + dbService, err := postgres.New(ctx, &postgres.Config{ + Embedded: true, + Port: portPATBindingPG, + RunMode: "test", + }) + if err != nil { + testsupport.PostgresUnavailable(t, err) + } + + t.Cleanup(func() { _ = dbService.Close() }) + + if initErr := dbService.Initialize(ctx); initErr != nil { + testsupport.PostgresInitFailed(t, initErr) + } + + var seq atomic.Int64 + + runPATBindingContract(t, func(t *testing.T) (*patBindingFixture, context.Context) { + t.Helper() + + cfg := impersonationTestConfig() + svc := NewService(dbService, cfg.Auth, cfg, nil, nil) + subCtx := t.Context() + + return newPATBindingFixture(subCtx, t, svc, dbService, "p"+strconv.FormatInt(seq.Add(1), 10)), subCtx + }) +} diff --git a/server/internal/handlers/auth/pat_guard.go b/server/internal/handlers/auth/pat_guard.go new file mode 100644 index 000000000..a33b234f0 --- /dev/null +++ b/server/internal/handlers/auth/pat_guard.go @@ -0,0 +1,116 @@ +package auth + +import ( + "context" + "errors" + "net/http" +) + +// ErrPATForbidden is returned when a personal access token asks for something +// it must never do (spec 2026-10-28-01): mint a session, a token for another +// org, or change how the account signs in. Mapped to 403 PAT_FORBIDDEN. +var ErrPATForbidden = errors.New("not allowed with a personal access token") + +// PATForbiddenMessage is the human half of every PAT_FORBIDDEN response. +const PATForbiddenMessage = "A personal access token is bound to one organization. " + + "It cannot open a session, switch organization, or change how the account signs in: " + + "sign in to the dashboard, or with `sp auth login`, to do that." + +// IsPAT reports whether these claims were produced by ValidatePATToken, i.e. +// the request authenticated with a personal access token. The flag is an +// unexported field, so no signed JWT can claim it, nor shed it. +func (c *Claims) IsPAT() bool { + return c != nil && c.pat +} + +// patForbiddenRoutes is what a personal access token may never reach (spec +// 2026-10-28-01). A PAT is bound to the org it was created in, and the device +// consent page has the user choose that org on purpose; everything on this +// list would let its holder step out of that binding: a session (with a +// refresh token that outlives the PAT) in another org of the owner, a PAT for +// another org, or a sign-in factor that works in every org and survives the +// PAT's revocation. +// +// It starts from impersonationForbiddenRoutes, minus what a PAT may keep: +// minting a PAT or an enrollment key in its own org (same org, same role), +// revoking tokens, and discovery scans. The rows match on chi's resolved +// route pattern, like the demo and impersonation guards. +// +// The denylist is not the only wall: Service.startSession refuses to mint any +// session from a PAT request, whatever the route, and the device consent and +// self-service email change refuse a PAT in the service too. +// +//nolint:gochecknoglobals // Effectively a constant table; Go has no const slices. +var patForbiddenRoutes = []demoAllowedRoute{ + // Session minting: a full session, with a refresh token, in any org of + // the owner. + {http.MethodPost, ImpersonationPathSwitchOrg}, + {http.MethodPost, ImpersonationPathOrgs}, + {http.MethodPost, ImpersonationPathAcceptInvite}, + // Sign-in factors. Each one signs in to every org of the account. + {http.MethodPost, ImpersonationPathChangePassword}, + {http.MethodPost, ImpersonationPath2FASetup}, + {http.MethodPost, ImpersonationPath2FAConfirm}, + // Also spends the shared login 2FA budget: a PAT holder could keep the + // owner's TOTP sign-in at 429, and so keep them from revoking the token. + {http.MethodDelete, ImpersonationPath2FA}, + {http.MethodPost, ImpersonationPathPasskeyBegin}, + {http.MethodPost, ImpersonationPathPasskeyFinish}, + {http.MethodDelete, ImpersonationPatternPasskey}, + // A super-admin route that changes another user's sign-in email. + {http.MethodPatch, ImpersonationPatternSystemUser}, + // A device grant for any org of the owner. + {http.MethodPost, ImpersonationPathDeviceConsent}, + // External identity binding: a bound Discord identity signs in. + {http.MethodPost, ImpersonationPatternDiscordLink}, + // Paging redirection to a chat the holder controls. + {http.MethodPost, ImpersonationPatternDiscordConnect}, + {http.MethodPost, ImpersonationPatternTelegramLink}, + // Impersonation mints a token acting as someone else. + {http.MethodPost, ImpersonationPatternImpersonate}, +} + +// IsPATAllowed reports whether a personal access token may perform this +// request. Safe methods always pass. +func IsPATAllowed(method, routePattern string) bool { + switch method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + return true + } + + for i := range patForbiddenRoutes { + if patForbiddenRoutes[i].method == method && patForbiddenRoutes[i].pattern == routePattern { + return false + } + } + + return true +} + +// PATForbiddenRoutes returns the denylist as (method, pattern) pairs, for the +// route-table test in internal/app that proves every entry names a real route. +func PATForbiddenRoutes() [][2]string { + out := make([][2]string, 0, len(patForbiddenRoutes)) + for i := range patForbiddenRoutes { + out = append(out, [2]string{patForbiddenRoutes[i].method, patForbiddenRoutes[i].pattern}) + } + + return out +} + +type patCredentialContextKey struct{} + +// WithPATCredential marks the request context as authenticated by a personal +// access token. Called by RequireAuth. Its own key, like the impersonator, so +// no later rewrite of the audit actor can drop it. +func WithPATCredential(ctx context.Context) context.Context { + return context.WithValue(ctx, patCredentialContextKey{}, true) +} + +// IsPATCredential reports whether the request authenticated with a personal +// access token. +func IsPATCredential(ctx context.Context) bool { + is, _ := ctx.Value(patCredentialContextKey{}).(bool) + + return is +} diff --git a/server/internal/handlers/auth/service.go b/server/internal/handlers/auth/service.go index 47d78f892..1717cc92f 100644 --- a/server/internal/handlers/auth/service.go +++ b/server/internal/handlers/auth/service.go @@ -5,7 +5,6 @@ import ( "context" "crypto/rand" "crypto/sha256" - "crypto/subtle" "database/sql" "encoding/hex" "encoding/json" @@ -265,6 +264,10 @@ type Claims struct { // 2026-10-16-12). Empty on every other token. It lets RequireAuth tell the // first-party CLI client from a third-party one; see IsMCPCredential. ClientID string `json:"clientId,omitempty"` + // pat marks claims produced by ValidatePATToken (spec 2026-10-28-01). + // Unexported, so it never appears in a signed JWT: no token can claim to + // be, or not to be, a PAT. See IsPAT and patForbiddenRoutes. + pat bool jwt.RegisteredClaims } @@ -933,6 +936,13 @@ func (s *Service) startSession( return ErrImpersonationForbidden } + // Same rule for a personal access token (spec 2026-10-28-01): it is bound + // to one org, and a session would carry its holder into every org of the + // owner, with a refresh token that outlives the PAT's revocation. + if IsPATCredential(ctx) { + return ErrPATForbidden + } + if err := s.db.CreateUserToken(ctx, refreshToken); err != nil { return err } @@ -1665,6 +1675,7 @@ func (s *Service) ValidatePATToken(ctx context.Context, patToken string) (*Claim // published demo API key safe (spec 2026-09-06-02). Demo: user.Demo, Scopes: scopesFromProperties(token.Properties), + pat: true, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: expiresAt, IssuedAt: jwt.NewNumericDate(token.CreatedAt), @@ -2484,6 +2495,7 @@ const ( registrationKeyPrefix = "email_registration:" registrationTTL = 3 * 24 * time.Hour inviteKeyPrefix = "invite:" + keyInviteOrgUID = "orgUID" passwordResetKeyPrefix = "password_reset:" passwordResetCountKeyPrefix = "password_reset_count:" passwordResetIPKeyPrefix = "pwd_reset_rl:" @@ -3794,6 +3806,14 @@ func (s *Service) CreateInvitation( return nil, fmt.Errorf("failed to store invitation: %w", storeErr) } + // Global index entry, same key, no organization: the public invite + // routes resolve the token to its org through it in one lookup instead + // of probing every organization (spec 2026-10-28-03). Same TTL. + indexValue := &models.JSONMap{keyInviteOrgUID: org.UID} + if storeErr := s.db.SetStateEntry(ctx, nil, stateKey, indexValue, &ttl); storeErr != nil { + return nil, fmt.Errorf("failed to index invitation: %w", storeErr) + } + // Get the state entry to return its UID entry, err := s.db.GetStateEntry(ctx, &org.UID, stateKey) if err != nil || entry == nil { @@ -3878,18 +3898,25 @@ func (s *Service) RevokeInvitation(ctx context.Context, orgSlug, invitationUID s for _, entry := range entries { if entry.UID == invitationUID { - _, err := s.db.DeleteStateEntry(ctx, &org.UID, entry.Key) - - return err + return s.deleteInvite(ctx, org.UID, entry.Key) } } return ErrInvitationNotFound } -// findInvite locates the live invitation a plaintext token refers to, -// across every organization. It returns the matching entry (whose Key is -// the one to delete on acceptance) and its org, or ErrInvitationNotFound. +// findInvite locates the live invitation a plaintext token refers to. It +// returns the matching org-scoped entry (whose Key is the one to delete on +// acceptance) and its org, or ErrInvitationNotFound. +// +// The lookup goes through the global index entry CreateInvitation writes +// (invite:, no organization, value {orgUID}), so it costs a fixed +// number of queries whatever the organization count: the route is public, +// and scanning every organization per request was a DoS (spec 2026-10-28-03). +// Invites created before that spec have no index entry and are not found: +// admins re-send them. +// +// A stored hash presented as the token hashes to another key and misses. func (s *Service) findInvite( ctx context.Context, token string, ) (*models.StateEntry, *models.Organization, error) { @@ -3897,51 +3924,53 @@ func (s *Service) findInvite( return nil, nil, ErrInvitationNotFound } - orgs, err := s.db.ListOrganizations(ctx) + key := inviteKeyPrefix + hashPendingToken(token) + + index, err := s.db.GetStateEntry(ctx, nil, key) if err != nil { - return nil, nil, fmt.Errorf("failed to list organizations: %w", err) + return nil, nil, fmt.Errorf("failed to look up invitation: %w", err) } - hashedKey := inviteKeyPrefix + hashPendingToken(token) - legacyKey := inviteKeyPrefix + token + if index == nil || index.Value == nil { + return nil, nil, ErrInvitationNotFound + } - for _, org := range orgs { - entry, getErr := s.db.GetStateEntry(ctx, &org.UID, hashedKey) - if getErr == nil && entry != nil && entry.Value != nil { - return entry, org, nil - } + orgUID := stringFromMap(*index.Value, keyInviteOrgUID) + if orgUID == "" { + return nil, nil, ErrInvitationNotFound + } - // Legacy fallback: invites created before spec 2026-09-26-02 are - // keyed by the plaintext token and carry it in their value. They are - // never rewritten, they just expire (max TTL 1 week). - // - // The value check is what keeps a hashed row from answering here: - // presenting a stored hash as the token would otherwise hit - // invite: — the new-style key — through this branch. Hashed - // rows hold no token field, so they never match. - // - // TODO(remove after 2026-10-10): drop this branch once every - // pre-hash invite has expired. - entry, getErr = s.db.GetStateEntry(ctx, &org.UID, legacyKey) - if getErr == nil && entry != nil && entry.Value != nil && legacyInviteTokenMatches(entry, token) { - return entry, org, nil + entry, err := s.db.GetStateEntry(ctx, &orgUID, key) + if err != nil { + return nil, nil, fmt.Errorf("failed to get invitation: %w", err) + } + + if entry == nil || entry.Value == nil { + return nil, nil, ErrInvitationNotFound + } + + org, err := s.db.GetOrganization(ctx, orgUID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil, ErrInvitationNotFound } + + return nil, nil, fmt.Errorf("failed to get organization: %w", err) } - return nil, nil, ErrInvitationNotFound + return entry, org, nil } -// legacyInviteTokenMatches reports whether a legacy (plaintext-keyed) invite -// row stores exactly the presented token in its value. -// -// TODO(remove after 2026-10-10): goes with the legacy branch of findInvite. -func legacyInviteTokenMatches(entry *models.StateEntry, token string) bool { - stored, ok := (*entry.Value)[keyToken].(string) - if !ok || stored == "" { - return false +// deleteInvite consumes or revokes an invitation: its org-scoped entry and +// the global index entry findInvite resolves it through share the key. +func (s *Service) deleteInvite(ctx context.Context, orgUID, key string) error { + if _, err := s.db.DeleteStateEntry(ctx, &orgUID, key); err != nil { + return err } - return subtle.ConstantTimeCompare([]byte(stored), []byte(token)) == 1 + _, err := s.db.DeleteStateEntry(ctx, nil, key) + + return err } // GetInviteInfo returns public information about an invitation. @@ -3971,9 +4000,8 @@ func (s *Service) GetInviteInfo(ctx context.Context, token string) (*InviteInfoR // //nolint:cyclop,funlen,gocritic // multiple steps; req is the decoded wire shape, passed by value func (s *Service) AcceptInvite(ctx context.Context, req AcceptInviteRequest, callerUID string) (*LoginResponse, error) { - // Find the invitation across orgs. matchedEntry.Key is the key that - // actually matched (hashed, or legacy plaintext), so the deletes below - // must use it rather than recompute one. + // Find the invitation through its global index entry. matchedEntry.Key + // is the key the deletes below must use. matchedEntry, matchedOrg, err := s.findInvite(ctx, req.Token) if err != nil { return nil, err @@ -4022,7 +4050,7 @@ func (s *Service) AcceptInvite(ctx context.Context, req AcceptInviteRequest, cal _, err = s.db.GetMemberByUserAndOrg(ctx, user.UID, matchedOrg.UID) if err == nil { // Already a member, just clean up and login - _, _ = s.db.DeleteStateEntry(ctx, &matchedOrg.UID, matchedEntry.Key) + _ = s.deleteInvite(ctx, matchedOrg.UID, matchedEntry.Key) } else { // Enforce the MaxUsers cap before adding this member. Invitation // acceptance is a membership-creation path, so it must honor the @@ -4054,7 +4082,7 @@ func (s *Service) AcceptInvite(ctx context.Context, req AcceptInviteRequest, cal }) // Delete the invitation - _, _ = s.db.DeleteStateEntry(ctx, &matchedOrg.UID, matchedEntry.Key) + _ = s.deleteInvite(ctx, matchedOrg.UID, matchedEntry.Key) } // Auto-join matching orgs for new users diff --git a/server/internal/handlers/base/base.go b/server/internal/handlers/base/base.go index 963a951ed..8be50086e 100644 --- a/server/internal/handlers/base/base.go +++ b/server/internal/handlers/base/base.go @@ -134,6 +134,12 @@ const ( // impersonate again. Distinct from ErrorCodeForbidden so the dashboard can // explain it rather than render a dead "Permission denied". ErrorCodeImpersonationForbidden ErrorCode = "IMPERSONATION_FORBIDDEN" + // ErrorCodePATForbidden accompanies the 403 a personal access token gets + // for anything outside its org binding (spec 2026-10-28-01): opening a + // session, approving a device grant, or changing a sign-in factor. The + // CLI reads it to tell the user to create a token in the target org or + // to log in interactively. + ErrorCodePATForbidden ErrorCode = "PAT_FORBIDDEN" ) // ContextKey is the type used for context keys in middleware and handlers. diff --git a/server/internal/handlers/checks/snmp_community_secret_test.go b/server/internal/handlers/checks/snmp_community_secret_test.go new file mode 100644 index 000000000..e3e16efcf --- /dev/null +++ b/server/internal/handlers/checks/snmp_community_secret_test.go @@ -0,0 +1,57 @@ +package checks_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/fclairamb/solidping/server/internal/handlers/checks" +) + +// TestSNMPCommunityIsSecret: the community string never reaches the public +// column, the API response or the export, and survives an unrelated edit. +func TestSNMPCommunityIsSecret(t *testing.T) { + t.Parallel() + + r := require.New(t) + svc, dbSvc, creds, org := setupEncryptedChecksServiceCreds(t) + ctx := t.Context() + + created, err := svc.CreateCheck(ctx, org.Slug, checks.CreateCheckRequest{ + Name: "snmp", Slug: "snmp", Type: "snmp", + Config: map[string]any{ + "host": "10.0.0.1", "oid": "1.3.6.1.2.1.1.3.0", "community": "s3cr3t-acme", + }, + }) + r.NoError(err) + r.NotContains(created.Config, "community") + r.Contains(created.ConfigPrivateKeys, "community") + + got, err := svc.GetCheck(ctx, org.Slug, created.UID, checks.GetCheckOptions{}) + r.NoError(err) + r.NotContains(got.Config, "community") + r.Contains(got.ConfigPrivateKeys, "community") + + row, err := dbSvc.GetCheck(ctx, org.UID, created.UID) + r.NoError(err) + rawCfg, err := json.Marshal(row.Config) + r.NoError(err) + r.NotContains(string(rawCfg), "s3cr3t-acme") + r.Equal("s3cr3t-acme", decryptPrivate(t, creds, row)["community"]) + + doc, err := svc.ExportChecks(ctx, org.Slug, checks.ListChecksOptions{}) + r.NoError(err) + exported, err := json.Marshal(doc) + r.NoError(err) + r.NotContains(string(exported), "s3cr3t-acme") + + // Unrelated edit that does not resend the community keeps it. + cfg := map[string]any{"host": "10.0.0.2", "oid": "1.3.6.1.2.1.1.3.0"} + _, err = svc.UpdateCheck(ctx, org.Slug, created.UID, &checks.UpdateCheckRequest{Config: &cfg}) + r.NoError(err) + + after, err := dbSvc.GetCheck(ctx, org.UID, created.UID) + r.NoError(err) + r.Equal("s3cr3t-acme", decryptPrivate(t, creds, after)["community"]) +} diff --git a/server/internal/handlers/emailpreview/fixtures.go b/server/internal/handlers/emailpreview/fixtures.go index 5d7e617e2..c72988cfe 100644 --- a/server/internal/handlers/emailpreview/fixtures.go +++ b/server/internal/handlers/emailpreview/fixtures.go @@ -82,6 +82,7 @@ var fixtureBuilders = map[string]func(i18n.Locale) map[string]any{ "password-changed.html": passwordChangedFixture, "email-changed.html": emailChangedFixture, "device-token-created.html": deviceTokenCreatedFixture, + "passkey-added.html": passkeyAddedFixture, "membership-request-new.html": membershipRequestNewFixture, "membership-request-decision.html": membershipRequestDecisionFixture, "uptime-report.html": uptimeReportFixture, diff --git a/server/internal/handlers/emailpreview/fixtures_account.go b/server/internal/handlers/emailpreview/fixtures_account.go index 81699015b..66d4cf9ce 100644 --- a/server/internal/handlers/emailpreview/fixtures_account.go +++ b/server/internal/handlers/emailpreview/fixtures_account.go @@ -70,6 +70,14 @@ func deviceTokenCreatedFixture(_ i18n.Locale) map[string]any { } } +func passkeyAddedFixture(_ i18n.Locale) map[string]any { + return map[string]any{ + "PasskeyName": "iCloud Keychain", + "CreatedAt": fixtureChangedAt(), + "SecurityURL": fixtureDashboardURL + "/orgs/acme/account/security", + } +} + func invitationFixture(loc i18n.Locale) map[string]any { return map[string]any{ keyOrgName: fixtureOrgName, diff --git a/server/internal/handlers/emailpreview/locale_account_test.go b/server/internal/handlers/emailpreview/locale_account_test.go index 06a15baf2..a34829f7d 100644 --- a/server/internal/handlers/emailpreview/locale_account_test.go +++ b/server/internal/handlers/emailpreview/locale_account_test.go @@ -20,7 +20,7 @@ import ( //nolint:gochecknoglobals // read-only test table. var accountTemplates = []string{ "registration.html", "welcome.html", "password-reset.html", "password-reset-sso.html", - "password-changed.html", "email-changed.html", "device-token-created.html", "invitation.html", + "password-changed.html", "email-changed.html", "device-token-created.html", "passkey-added.html", "invitation.html", "membership-request-new.html", "membership-request-decision.html", } @@ -130,6 +130,24 @@ func TestAccountMail_EveryLocale(t *testing.T) { i18n.LangGerman: {"Ihr SolidPing-Passwort wurde geändert", "am 5. Juli, 10:00 UTC geändert wurde."}, i18n.LangSpanish: {"Se ha cambiado tu contraseña de SolidPing", "se cambió el 5 jul, 10:00 UTC."}, }, + "passkey-added.html": { + i18n.LangEnglish: { + "A passkey was added to your SolidPing account", + "It signs in to every organization of the account.", + }, + i18n.LangFrench: { + "Une clé d'accès a été ajoutée à votre compte SolidPing", + "Une clé d'accès nommée « iCloud Keychain » a été ajoutée", + }, + i18n.LangGerman: { + "Ihrem SolidPing-Konto wurde ein Passkey hinzugefügt", + "ein Passkey mit dem Namen „iCloud Keychain“ hinzugefügt", + }, + i18n.LangSpanish: { + "Se ha añadido una llave de acceso a tu cuenta de SolidPing", + "una llave de acceso llamada «iCloud Keychain»", + }, + }, "device-token-created.html": { i18n.LangEnglish: { "A device was signed in to your SolidPing account", diff --git a/server/internal/handlers/statussubscribers/delivery_test.go b/server/internal/handlers/statussubscribers/delivery_test.go index de52d2688..35835f4cd 100644 --- a/server/internal/handlers/statussubscribers/delivery_test.go +++ b/server/internal/handlers/statussubscribers/delivery_test.go @@ -350,14 +350,14 @@ func TestPublicSubscribeStaysEmailOnly(t *testing.T) { s := newSubSetup(t) channel := "webhook" - _, err := s.svc.Subscribe(t.Context(), s.org.Slug, s.page.UID, &statussubscribers.SubscribeRequest{ + _, err := s.svc.Subscribe(t.Context(), s.org.Slug, s.page.UID, "", &statussubscribers.SubscribeRequest{ Email: "someone@example.com", Channel: &channel, }) r.ErrorIs(err, statussubscribers.ErrPublicChannelNotAllowed) // Positive control: the email channel still works from the public path. email := "email" - _, err = s.svc.Subscribe(t.Context(), s.org.Slug, s.page.UID, &statussubscribers.SubscribeRequest{ + _, err = s.svc.Subscribe(t.Context(), s.org.Slug, s.page.UID, "", &statussubscribers.SubscribeRequest{ Email: "someone@example.com", Channel: &email, }) r.NoError(err) diff --git a/server/internal/handlers/statussubscribers/handler.go b/server/internal/handlers/statussubscribers/handler.go index 06df403aa..2b9fceb82 100644 --- a/server/internal/handlers/statussubscribers/handler.go +++ b/server/internal/handlers/statussubscribers/handler.go @@ -68,8 +68,21 @@ func (h *Handler) baseURL() string { return "http://localhost:4000" } +// clientIP resolves the caller's IP with the rate limiter's trusted-proxy +// rules, never the spoofable raw X-Forwarded-For. +func (h *Handler) clientIP(req *http.Request) string { + trusted := 0 + if h.cfg != nil { + trusted = h.cfg.Server.RateLimiting.TrustedProxies + } + + return base.ExtractTrustedClientIP(req, trusted) +} + // Subscribe handles POST /api/v1/orgs/:org/status-pages/:statusPageUid/subscribers. -// Public, no auth. Always returns 202 on success and sends a confirm mail. +// Public, no auth. Always returns 202 on success. The confirm mail goes out +// only when the service says one is due: never for an already-confirmed +// address, never once a budget is spent (spec 2026-10-28-02). func (h *Handler) Subscribe(writer http.ResponseWriter, req *http.Request) error { orgSlug := httpx.Param(req, "org") statusPageUID := httpx.Param(req, "statusPageUid") @@ -81,12 +94,14 @@ func (h *Handler) Subscribe(writer http.ResponseWriter, req *http.Request) error }) } - result, err := h.svc.Subscribe(req.Context(), orgSlug, statusPageUID, &body) + result, err := h.svc.Subscribe(req.Context(), orgSlug, statusPageUID, h.clientIP(req), &body) if err != nil { return h.handleError(writer, req, err) } - h.sendConfirmMail(req.Context(), result) + if result.NeedsConfirmMail() { + h.sendConfirmMail(req.Context(), result) + } return h.WriteJSON(writer, http.StatusAccepted, map[string]any{ respKeyData: map[string]any{ diff --git a/server/internal/handlers/statussubscribers/notifier_test.go b/server/internal/handlers/statussubscribers/notifier_test.go index 573357a86..1616742ca 100644 --- a/server/internal/handlers/statussubscribers/notifier_test.go +++ b/server/internal/handlers/statussubscribers/notifier_test.go @@ -242,14 +242,14 @@ func TestSubscribingToALockedPageRequiresTheUnlock(t *testing.T) { r.NoError(s.dbSvc.UpdateStatusPage(ctx, s.page.UID, &models.StatusPageUpdate{Visibility: &visibility, PasswordHash: &hash})) - _, err := s.svc.Subscribe(ctx, s.org.Slug, s.page.UID, + _, err := s.svc.Subscribe(ctx, s.org.Slug, s.page.UID, "", &statussubscribers.SubscribeRequest{Email: "stranger@example.com"}) r.ErrorIs(err, statuspagelock.ErrLocked) // Positive control: with a grant on the context (what the router installs // from a valid unlock cookie) the very same call succeeds. granted := statuspagelock.WithGrant(ctx, func(*models.StatusPage) bool { return true }) - _, err = s.svc.Subscribe(granted, s.org.Slug, s.page.UID, + _, err = s.svc.Subscribe(granted, s.org.Slug, s.page.UID, "", &statussubscribers.SubscribeRequest{Email: "stranger@example.com"}) r.NoError(err) } diff --git a/server/internal/handlers/statussubscribers/service.go b/server/internal/handlers/statussubscribers/service.go index e532afcab..34df66507 100644 --- a/server/internal/handlers/statussubscribers/service.go +++ b/server/internal/handlers/statussubscribers/service.go @@ -7,13 +7,16 @@ package statussubscribers import ( "context" "crypto/rand" + "crypto/sha256" "database/sql" "encoding/base64" + "encoding/hex" "errors" "net/mail" "strings" "time" + "github.com/fclairamb/solidping/server/internal/config" "github.com/fclairamb/solidping/server/internal/crypto/credentials" "github.com/fclairamb/solidping/server/internal/db" "github.com/fclairamb/solidping/server/internal/db/models" @@ -64,6 +67,9 @@ type Service struct { // than failing at every delivery. nil means "no policy": every https URL // is accepted (tests that do not exercise the policy). guard *egress.Guard + // budget caps the double opt-in mails of the public subscribe endpoint + // (spec 2026-10-28-02). Defaults to config.DefaultStatusSubscribersConfig. + budget config.StatusSubscribersConfig } // Option configures the subscribers service. @@ -78,9 +84,23 @@ func WithEgressGuard(guard *egress.Guard) Option { return func(s *Service) { s.guard = guard } } +// WithConfirmBudget sets the per-recipient, per-page and per-IP hourly budgets +// of the confirm mail. A budget of 0 is not enforced. +func WithConfirmBudget(budget config.StatusSubscribersConfig) Option { + return func(s *Service) { s.budget = budget } +} + +// confirmBudgetWindow is the sliding window every confirm-mail budget counts on. +const confirmBudgetWindow = time.Hour + +// confirmCounterPrefix namespaces the confirm-mail counters in the global +// state entries. The recipient key carries a hash of the address, never the +// address itself (PII). +const confirmCounterPrefix = "status_subscriber_confirm:" + // NewService creates a new status subscribers service. func NewService(dbService db.Service, creds credentials.Service, opts ...Option) *Service { - svc := &Service{db: dbService, creds: creds} + svc := &Service{db: dbService, creds: creds, budget: config.DefaultStatusSubscribersConfig()} for _, opt := range opts { opt(svc) } @@ -164,7 +184,19 @@ type SubscribeResult struct { // update fan-out: the recipient is subscribing to a page. PageLogoURL *string PageHideBranding bool + // AlreadyConfirmed: the address already holds a live, confirmed + // subscription. Nothing changed and no mail is due. AlreadyConfirmed bool + // Throttled: a confirm-mail budget is spent. Nothing changed and no mail + // is due. Subscriber is nil when no row existed. + Throttled bool +} + +// NeedsConfirmMail reports whether the caller must send the confirm mail. +// The public endpoint answers the same 202 either way, so it reveals neither +// whether an address is subscribed nor whether a budget is spent. +func (r *SubscribeResult) NeedsConfirmMail() bool { + return r != nil && r.Subscriber != nil && !r.AlreadyConfirmed && !r.Throttled } // generateToken returns a URL-safe, unguessable opaque token. @@ -191,32 +223,28 @@ func normalizeEmail(raw string) (string, error) { return email, nil } -// Subscribe starts a (re)subscription. The returned subscriber is always -// unconfirmed with a fresh confirm token — a confirm mail must be sent. If a -// live unconfirmed/confirmed row already exists it is reused/soft-undeleted -// rather than duplicated. -// -//nolint:cyclop // validation of optional scope/incident fields requires several branches -func (s *Service) Subscribe( - ctx context.Context, orgSlug, statusPageUID string, req *SubscribeRequest, -) (*SubscribeResult, error) { +// parseSubscribeRequest validates a public subscribe body: email channel only, +// a well-formed address, and a known scope with its incident when required. +func parseSubscribeRequest( + req *SubscribeRequest, +) (string, models.SubscriberScope, *string, error) { // Public self-serve is email-only, explicitly. The channel field is // accepted so a mistaken client gets a clear error rather than silently // creating an email subscription it did not ask for. if req.Channel != nil && *req.Channel != "" && *req.Channel != string(models.SubscriberChannelEmail) { - return nil, ErrPublicChannelNotAllowed + return "", "", nil, ErrPublicChannelNotAllowed } email, err := normalizeEmail(req.Email) if err != nil { - return nil, err + return "", "", nil, err } scope := models.SubscriberScopePage if req.Scope != nil && *req.Scope != "" { scope = models.SubscriberScope(*req.Scope) if !scope.IsValid() { - return nil, ErrInvalidScope + return "", "", nil, ErrInvalidScope } } @@ -224,24 +252,32 @@ func (s *Service) Subscribe( if scope == models.SubscriberScopeIncident { if req.IncidentUID == nil || *req.IncidentUID == "" { - return nil, ErrIncidentRequired + return "", "", nil, ErrIncidentRequired } incidentUID = req.IncidentUID } + return email, scope, incidentUID, nil +} + +// resolveSubscribePage finds the org and the public, unlocked status page a +// visitor subscribes to, and checks the incident of an incident-scoped one. +func (s *Service) resolveSubscribePage( + ctx context.Context, orgSlug, statusPageUID string, incidentUID *string, +) (*models.Organization, *models.StatusPage, error) { org, err := s.db.GetOrganizationBySlug(ctx, orgSlug) if err != nil { - return nil, ErrOrganizationNotFound + return nil, nil, ErrOrganizationNotFound } page, err := s.db.GetStatusPage(ctx, org.UID, statusPageUID) if err != nil || page == nil { - return nil, ErrStatusPageNotFound + return nil, nil, ErrStatusPageNotFound } if !statuspagelock.Visible(page) { - return nil, ErrStatusPageNotFound + return nil, nil, ErrStatusPageNotFound } // The subscribe FORM sits behind the unlock — a page shared with a @@ -250,15 +286,80 @@ func (s *Service) Subscribe( // notifier.go never consults visibility, so locking a page down does not // silently stop the notifications people already opted into. if !statuspagelock.Allows(ctx, page) { - return nil, statuspagelock.ErrLocked + return nil, nil, statuspagelock.ErrLocked } if incidentUID != nil { if _, incErr := s.db.GetIncident(ctx, org.UID, *incidentUID); incErr != nil { - return nil, ErrIncidentNotFound + return nil, nil, ErrIncidentNotFound } } + return org, page, nil +} + +// Subscribe starts a (re)subscription on behalf of clientIP (trusted-proxy +// resolution; "" skips the per-IP budget). A new, unconfirmed or soft-deleted +// row gets a fresh confirm token and NeedsConfirmMail reports true. A live +// confirmed subscription is left untouched (AlreadyConfirmed), and so is +// everything once a confirm-mail budget is spent (Throttled). +func (s *Service) Subscribe( + ctx context.Context, orgSlug, statusPageUID, clientIP string, req *SubscribeRequest, +) (*SubscribeResult, error) { + email, scope, incidentUID, err := parseSubscribeRequest(req) + if err != nil { + return nil, err + } + + org, page, err := s.resolveSubscribePage(ctx, orgSlug, statusPageUID, incidentUID) + if err != nil { + return nil, err + } + + result := &SubscribeResult{ + PageName: page.Name, + PageSlug: page.Slug, + OrgSlug: org.Slug, + PageLogoURL: statuspageassets.PublicURL(page.Settings.LogoFileUID()), + PageHideBranding: page.Settings.HideBranding(), + } + + // Match any prior row for this tuple (including soft-deleted) so a returning + // visitor revives their existing subscription rather than creating a + // duplicate that would collide with the live unique index once confirmed. + existing, err := s.db.FindAnySubscriber(ctx, page.UID, email, scope, incidentUID) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return nil, err + } + + existing = nil + } + + // A live, confirmed subscription stays exactly as it is: re-subscribing it + // would null confirmed_at and rotate the unsubscribe token, letting anyone + // who knows an address silently stop its notifications. + if existing != nil && existing.DeletedAt == nil && existing.ConfirmedAt != nil { + result.Subscriber = existing + result.AlreadyConfirmed = true + + return result, nil + } + + // Over budget the row is left untouched too: rotating its confirm token + // without mailing the new one would void the links already sent. + allowed, err := s.spendConfirmBudget(ctx, page.UID, email, clientIP) + if err != nil { + return nil, err + } + + if !allowed { + result.Subscriber = existing + result.Throttled = true + + return result, nil + } + confirmToken, err := generateToken() if err != nil { return nil, err @@ -270,52 +371,77 @@ func (s *Service) Subscribe( } sub, err := s.upsertSubscriber( - ctx, org.UID, page.UID, email, scope, incidentUID, confirmToken, unsubToken) + ctx, existing, org.UID, page.UID, email, scope, incidentUID, confirmToken, unsubToken) if err != nil { return nil, err } - return &SubscribeResult{ - Subscriber: sub, - PageName: page.Name, - PageSlug: page.Slug, - OrgSlug: org.Slug, - PageLogoURL: statuspageassets.PublicURL(page.Settings.LogoFileUID()), - PageHideBranding: page.Settings.HideBranding(), - }, nil + result.Subscriber = sub + + return result, nil +} + +// spendConfirmBudget counts one confirm mail against the per-client-IP, +// per-recipient and per-page budgets, in that order, and reports whether all +// of them still had room. The first spent budget stops the count, so a +// refused attempt does not eat into the later budgets. clientIP "" skips the +// IP budget. +func (s *Service) spendConfirmBudget(ctx context.Context, pageUID, email, clientIP string) (bool, error) { + recipientHash := sha256.Sum256([]byte(email)) + + budgets := []struct { + key string + limit int + }{ + {confirmCounterPrefix + "ip:" + clientIP, s.budget.ConfirmPerIPPerHour}, + {confirmCounterPrefix + "rcpt:" + hex.EncodeToString(recipientHash[:]), s.budget.ConfirmPerRecipientPerHour}, + {confirmCounterPrefix + "page:" + pageUID, s.budget.ConfirmPerPagePerHour}, + } + + for i, budget := range budgets { + if budget.limit <= 0 || (i == 0 && clientIP == "") { + continue + } + + limited, err := s.db.BumpGlobalCounter(ctx, budget.key, budget.limit, confirmBudgetWindow) + if err != nil { + return false, err + } + + if limited { + return false, nil + } + } + + return true, nil } -// upsertSubscriber reuses an existing live row (refreshing tokens + resetting -// confirmation) or inserts a new unconfirmed row. +// upsertSubscriber revives existing (soft-deleted or unconfirmed: refreshed +// tokens, confirmation reset) or, when it is nil, inserts a new unconfirmed row. func (s *Service) upsertSubscriber( ctx context.Context, + existing *models.StatusPageSubscriber, orgUID, pageUID, email string, scope models.SubscriberScope, incidentUID *string, confirmToken, unsubToken string, ) (*models.StatusPageSubscriber, error) { - // Match any prior row for this tuple (including soft-deleted) so a returning - // visitor revives their existing subscription rather than creating a - // duplicate that would collide with the live unique index once confirmed. - existing, err := s.db.FindAnySubscriber(ctx, pageUID, email, scope, incidentUID) - if err == nil && existing != nil { + if existing != nil { // Reuse the row: soft-undelete, refresh tokens, require re-confirmation. if reErr := s.db.ResubscribeSubscriber(ctx, existing.UID, confirmToken, unsubToken); reErr != nil { return nil, reErr } + sentAt := nowUTC() existing.ConfirmToken = confirmToken existing.UnsubscribeToken = unsubToken + existing.ConfirmSentAt = &sentAt existing.ConfirmedAt = nil existing.DeletedAt = nil return existing, nil } - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return nil, err - } - sub := models.NewStatusPageSubscriber(orgUID, pageUID, email, scope) sub.IncidentUID = incidentUID sub.ConfirmToken = confirmToken @@ -351,6 +477,12 @@ func (s *Service) Confirm(ctx context.Context, token string) (*ConfirmResult, er return nil, err } + // An unconfirmed row past its confirm TTL is refused like an unknown + // token; the state-cleanup job reaps it. + if sub.ConfirmExpired(nowUTC()) { + return nil, ErrInvalidToken + } + if err := s.db.ConfirmSubscriber(ctx, sub.UID, nowUTC()); err != nil { return nil, err } diff --git a/server/internal/handlers/statussubscribers/service_test.go b/server/internal/handlers/statussubscribers/service_test.go index 07f4c079d..682613a8b 100644 --- a/server/internal/handlers/statussubscribers/service_test.go +++ b/server/internal/handlers/statussubscribers/service_test.go @@ -49,7 +49,7 @@ func newSubSetup(t *testing.T) *subSetup { func (s *subSetup) subscribe(t *testing.T, email string) *statussubscribers.SubscribeResult { t.Helper() - res, err := s.svc.Subscribe(t.Context(), s.org.Slug, s.page.UID, + res, err := s.svc.Subscribe(t.Context(), s.org.Slug, s.page.UID, "", &statussubscribers.SubscribeRequest{Email: email}) require.NoError(t, err) @@ -87,7 +87,7 @@ func TestSubscribeInvalidEmail(t *testing.T) { r := require.New(t) s := newSubSetup(t) - _, err := s.svc.Subscribe(t.Context(), s.org.Slug, s.page.UID, + _, err := s.svc.Subscribe(t.Context(), s.org.Slug, s.page.UID, "", &statussubscribers.SubscribeRequest{Email: "not-an-email"}) r.ErrorIs(err, statussubscribers.ErrInvalidEmail) } @@ -247,7 +247,7 @@ func TestSubscribeUnknownPage(t *testing.T) { r := require.New(t) s := newSubSetup(t) - _, err := s.svc.Subscribe(t.Context(), s.org.Slug, "no-such-page", + _, err := s.svc.Subscribe(t.Context(), s.org.Slug, "no-such-page", "", &statussubscribers.SubscribeRequest{Email: "x@example.com"}) r.ErrorIs(err, statussubscribers.ErrStatusPageNotFound) } diff --git a/server/internal/handlers/statussubscribers/subscribe_abuse_test.go b/server/internal/handlers/statussubscribers/subscribe_abuse_test.go new file mode 100644 index 000000000..4332cafa1 --- /dev/null +++ b/server/internal/handlers/statussubscribers/subscribe_abuse_test.go @@ -0,0 +1,363 @@ +package statussubscribers_test + +import ( + "bytes" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "strconv" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/uptrace/bun" + + "github.com/fclairamb/solidping/server/internal/config" + "github.com/fclairamb/solidping/server/internal/db" + "github.com/fclairamb/solidping/server/internal/db/models" + "github.com/fclairamb/solidping/server/internal/db/postgres" + "github.com/fclairamb/solidping/server/internal/db/sqlite" + "github.com/fclairamb/solidping/server/internal/email" + "github.com/fclairamb/solidping/server/internal/handlers/statussubscribers" + "github.com/fclairamb/solidping/server/internal/httpx" + "github.com/fclairamb/solidping/server/internal/jobs/jobdef" + "github.com/fclairamb/solidping/server/internal/jobs/jobtypes" + "github.com/fclairamb/solidping/server/internal/testsupport" +) + +// portSubscribeAbusePG is distinct from every other embedded-Postgres port +// claimed in the repo (see the port-numbering note in +// internal/db/incident_number_test.go). +const portSubscribeAbusePG = 15674 + +// abuseFixture is one org + public page behind the real Subscribe handler, on +// either engine (spec 2026-10-28-02). +type abuseFixture struct { + svc *statussubscribers.Service + dbSvc db.Service + bunDB *bun.DB + router *httpx.Router + sender *fakeSender + org *models.Organization + page *models.StatusPage + // suffix keeps addresses unique across the subtests sharing one database: + // the per-recipient budget is instance-wide. + suffix string + // clientIP is the caller address of every request this fixture sends. + clientIP string +} + +type abuseEngine struct { + dbSvc db.Service + bunDB *bun.DB + seq atomic.Int64 +} + +func (e *abuseEngine) fixture(t *testing.T, budget config.StatusSubscribersConfig) *abuseFixture { + t.Helper() + + ctx := t.Context() + r := require.New(t) + n := e.seq.Add(1) + suffix := strconv.FormatInt(n, 10) + "-" + strconv.FormatInt(time.Now().UnixNano()%1_000_000, 10) + + org := models.NewOrganization("abuse-"+suffix, "Acme "+suffix) + r.NoError(e.dbSvc.CreateOrganization(ctx, org)) + + page := models.NewStatusPage(org.UID, "Acme Status", "acme-status-"+suffix) + r.NoError(e.dbSvc.CreateStatusPage(ctx, page)) + + svc := statussubscribers.NewService(e.dbSvc, nil, statussubscribers.WithConfirmBudget(budget)) + + formatter, err := email.NewFormatter() + r.NoError(err) + + cfg := &config.Config{} + cfg.Server.BaseURL = "https://status.example.com" + sender := &fakeSender{} + handler := statussubscribers.NewHandler(svc, e.dbSvc, sender, formatter, cfg) + + router := httpx.New() + router.POST("/api/v1/orgs/:org/status-pages/:statusPageUid/subscribers", handler.Subscribe) + + return &abuseFixture{ + svc: svc, dbSvc: e.dbSvc, bunDB: e.bunDB, router: router, sender: sender, + org: org, page: page, suffix: suffix, + clientIP: fmt.Sprintf("198.51.%d.%d", (n/250)%250, n%250+1), + } +} + +func (f *abuseFixture) addr(local string) string { + return local + "-" + f.suffix + "@acme.com" +} + +// subscribeHTTP posts to the public endpoint and asserts the uniform 202. +func (f *abuseFixture) subscribeHTTP(t *testing.T, address string) { + t.Helper() + + body, err := json.Marshal(map[string]any{"email": address}) + require.NoError(t, err) + + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, + "/api/v1/orgs/"+f.org.Slug+"/status-pages/"+f.page.UID+"/subscribers", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.RemoteAddr = f.clientIP + ":4321" + + rec := httptest.NewRecorder() + f.router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusAccepted, rec.Code, rec.Body.String()) + require.Contains(t, rec.Body.String(), "pending_confirmation") +} + +func (f *abuseFixture) row(t *testing.T, address string) *models.StatusPageSubscriber { + t.Helper() + + sub, err := f.dbSvc.FindAnySubscriber(t.Context(), f.page.UID, address, models.SubscriberScopePage, nil) + require.NoError(t, err) + + return sub +} + +func (f *abuseFixture) mailsTo(address string) int { + count := 0 + + for _, msg := range f.sender.sent() { + if len(msg.Recipients.To) == 1 && msg.Recipients.To[0] == address { + count++ + } + } + + return count +} + +// age backdates a row's confirm token (and creation) by `by`. +func (f *abuseFixture) age(t *testing.T, uid string, by time.Duration) { + t.Helper() + + old := time.Now().UTC().Add(-by) + _, err := f.bunDB.NewUpdate(). + Model((*models.StatusPageSubscriber)(nil)). + Set("confirm_sent_at = ?", old). + Set("created_at = ?", old). + Where("uid = ?", uid). + Exec(t.Context()) + require.NoError(t, err) +} + +func defaultBudget() config.StatusSubscribersConfig { + return config.DefaultStatusSubscribersConfig() +} + +func runSubscribeAbuseContract(t *testing.T, engine *abuseEngine) { + t.Helper() + + t.Run("re-subscribing a confirmed address leaves it confirmed and mails nothing", func(t *testing.T) { + t.Parallel() + r := require.New(t) + f := engine.fixture(t, defaultBudget()) + address := f.addr("ops") + + f.subscribeHTTP(t, address) + _, err := f.svc.Confirm(t.Context(), f.row(t, address).ConfirmToken) + r.NoError(err) + + before := f.row(t, address) + r.NotNil(before.ConfirmedAt) + + f.subscribeHTTP(t, address) + + after := f.row(t, address) + r.NotNil(after.ConfirmedAt, "a repeat subscribe must not un-confirm the subscriber") + r.Equal(before.UnsubscribeToken, after.UnsubscribeToken, "the unsubscribe links already mailed must keep working") + r.Equal(before.ConfirmToken, after.ConfirmToken) + r.Equal(1, f.mailsTo(address), "only the first subscribe mails a confirm link") + + confirmed, err := f.dbSvc.ListConfirmedSubscribers(t.Context(), f.page.UID, nil) + r.NoError(err) + r.Len(confirmed, 1, "the subscriber still receives the fan-out") + }) + + t.Run("ten subscribes of one new address mail at most the per-recipient budget", func(t *testing.T) { + t.Parallel() + r := require.New(t) + f := engine.fixture(t, defaultBudget()) + address := f.addr("victim") + + for range 10 { + f.subscribeHTTP(t, address) + } + + r.Equal(config.DefaultStatusSubscriberConfirmPerRecipientPerHour, f.mailsTo(address)) + + // A throttled call leaves the row alone, so the last link mailed + // still confirms. + lastMail := f.sender.sent()[len(f.sender.sent())-1] + token := f.row(t, address).ConfirmToken + r.Contains(lastMail.HTML, token) + _, err := f.svc.Confirm(t.Context(), token) + r.NoError(err) + }) + + t.Run("the per-recipient budget spans every page", func(t *testing.T) { + t.Parallel() + r := require.New(t) + first := engine.fixture(t, defaultBudget()) + second := engine.fixture(t, defaultBudget()) + address := first.addr("spread") + + for range 2 { + first.subscribeHTTP(t, address) + } + + for range 3 { + second.subscribeHTTP(t, address) + } + + r.Equal(2, first.mailsTo(address)) + r.Equal(1, second.mailsTo(address)) + }) + + t.Run("the per-IP budget caps distinct addresses from one client", func(t *testing.T) { + t.Parallel() + r := require.New(t) + f := engine.fixture(t, defaultBudget()) + + for i := range config.DefaultStatusSubscriberConfirmPerIPPerHour + 5 { + f.subscribeHTTP(t, f.addr("ip"+strconv.Itoa(i))) + } + + r.Len(f.sender.sent(), config.DefaultStatusSubscriberConfirmPerIPPerHour) + }) + + t.Run("the per-page budget caps a page across clients", func(t *testing.T) { + t.Parallel() + r := require.New(t) + budget := defaultBudget() + budget.ConfirmPerPagePerHour = 2 + f := engine.fixture(t, budget) + + for i := range 4 { + f.clientIP = "203.0.113." + strconv.Itoa(i+1) + "0" + f.subscribeHTTP(t, f.addr("page"+strconv.Itoa(i))) + } + + r.Len(f.sender.sent(), 2) + }) + + t.Run("a first subscribe sends exactly one confirm mail", func(t *testing.T) { + t.Parallel() + r := require.New(t) + f := engine.fixture(t, defaultBudget()) + address := f.addr("first") + + f.subscribeHTTP(t, address) + + r.Len(f.sender.sent(), 1) + r.Equal(1, f.mailsTo(address)) + r.Contains(f.sender.sent()[0].HTML, f.row(t, address).ConfirmToken) + }) + + t.Run("an unsubscribed address can subscribe again and gets a new confirm mail", func(t *testing.T) { + t.Parallel() + r := require.New(t) + f := engine.fixture(t, defaultBudget()) + address := f.addr("returning") + + f.subscribeHTTP(t, address) + _, err := f.svc.Confirm(t.Context(), f.row(t, address).ConfirmToken) + r.NoError(err) + _, err = f.svc.Unsubscribe(t.Context(), f.row(t, address).UnsubscribeToken) + r.NoError(err) + + f.subscribeHTTP(t, address) + + r.Equal(2, f.mailsTo(address)) + revived := f.row(t, address) + r.Nil(revived.DeletedAt) + r.Nil(revived.ConfirmedAt) + r.Contains(f.sender.sent()[1].HTML, revived.ConfirmToken) + }) + + t.Run("an expired unconfirmed row is refused then reaped; a confirmed one is kept", func(t *testing.T) { + t.Parallel() + r := require.New(t) + ctx := t.Context() + f := engine.fixture(t, defaultBudget()) + stale, kept, fresh := f.addr("stale"), f.addr("kept"), f.addr("fresh") + + f.subscribeHTTP(t, stale) + f.subscribeHTTP(t, kept) + f.subscribeHTTP(t, fresh) + _, err := f.svc.Confirm(ctx, f.row(t, kept).ConfirmToken) + r.NoError(err) + + age := models.SubscriberConfirmTTL + 24*time.Hour + staleRow := f.row(t, stale) + f.age(t, staleRow.UID, age) + f.age(t, f.row(t, kept).UID, age) + + // Expired but not yet reaped: the link is already refused. + _, err = f.svc.Confirm(ctx, staleRow.ConfirmToken) + r.ErrorIs(err, statussubscribers.ErrInvalidToken) + + runner, err := (&jobtypes.StateCleanupJobDefinition{}).CreateJobRun(nil) + r.NoError(err) + r.NoError(runner.Run(ctx, &jobdef.JobContext{DBService: f.dbSvc, Logger: slog.Default()})) + + _, err = f.dbSvc.FindAnySubscriber(ctx, f.page.UID, stale, models.SubscriberScopePage, nil) + r.Error(err, "the expired unconfirmed row is hard-deleted") + + _, err = f.svc.Confirm(ctx, staleRow.ConfirmToken) + r.ErrorIs(err, statussubscribers.ErrInvalidToken) + + r.NotNil(f.row(t, kept).ConfirmedAt, "a confirmed row of the same age is kept") + r.Nil(f.row(t, fresh).ConfirmedAt, "a fresh unconfirmed row is kept") + + _, err = f.svc.Confirm(ctx, f.row(t, fresh).ConfirmToken) + r.NoError(err) + }) +} + +func TestSubscribeAbuse_SQLite(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + dbSvc, err := sqlite.New(ctx, sqlite.Config{InMemory: true}) + require.NoError(t, err) + require.NoError(t, dbSvc.Initialize(ctx)) + t.Cleanup(func() { _ = dbSvc.Close() }) + + runSubscribeAbuseContract(t, &abuseEngine{dbSvc: dbSvc, bunDB: dbSvc.DB()}) +} + +func TestSubscribeAbuse_Postgres(t *testing.T) { + t.Parallel() + + if testing.Short() { + t.Skip("skipping embedded-postgres test in -short mode") + } + + ctx := t.Context() + + dbSvc, err := postgres.New(ctx, &postgres.Config{ + Embedded: true, + Port: portSubscribeAbusePG, + RunMode: "test", + }) + if err != nil { + testsupport.PostgresUnavailable(t, err) + } + + t.Cleanup(func() { _ = dbSvc.Close() }) + + if initErr := dbSvc.Initialize(ctx); initErr != nil { + testsupport.PostgresInitFailed(t, initErr) + } + + runSubscribeAbuseContract(t, &abuseEngine{dbSvc: dbSvc, bunDB: dbSvc.DB()}) +} diff --git a/server/internal/i18n/locales/de/account.json b/server/internal/i18n/locales/de/account.json index 6ffd02cd4..29de4fcf3 100644 --- a/server/internal/i18n/locales/de/account.json +++ b/server/internal/i18n/locales/de/account.json @@ -75,6 +75,14 @@ "revoke": "Wenn Sie diese Anmeldung nicht selbst gestartet haben, widerrufen Sie das Token jetzt auf der Token-Seite Ihres Kontos:", "cta": "Meine Tokens ansehen" }, + "passkeyAdded": { + "subject": "Ihrem SolidPing-Konto wurde ein Passkey hinzugefügt", + "preheader": "„{name}“ kann sich jetzt bei Ihrem Konto anmelden.", + "title": "Ein neuer Passkey wurde hinzugefügt", + "body": "Ihrem SolidPing-Konto wurde am {date} ein Passkey mit dem Namen „{name}“ hinzugefügt. Er meldet sich bei allen Organisationen des Kontos an.", + "revoke": "Wenn Sie ihn nicht selbst hinzugefügt haben, entfernen Sie ihn jetzt auf der Sicherheitsseite Ihres Kontos und ändern Sie Ihr Passwort:", + "cta": "Meine Passkeys ansehen" + }, "invitation": { "subject": "Einladung zu {org} bei SolidPing", "preheader": { diff --git a/server/internal/i18n/locales/en/account.json b/server/internal/i18n/locales/en/account.json index 40f10a613..5d3dc57bf 100644 --- a/server/internal/i18n/locales/en/account.json +++ b/server/internal/i18n/locales/en/account.json @@ -75,6 +75,14 @@ "revoke": "If you did not start this sign-in yourself, revoke the token now from your account tokens page:", "cta": "Review my tokens" }, + "passkeyAdded": { + "subject": "A passkey was added to your SolidPing account", + "preheader": "\"{name}\" can now sign in to your account.", + "title": "A new passkey was added", + "body": "A passkey named \"{name}\" was added to your SolidPing account on {date}. It signs in to every organization of the account.", + "revoke": "If you did not add it yourself, remove it now from your account security page and change your password:", + "cta": "Review my passkeys" + }, "invitation": { "subject": "You're invited to join {org} on SolidPing", "preheader": { diff --git a/server/internal/i18n/locales/es/account.json b/server/internal/i18n/locales/es/account.json index 939ffb507..77b3491c5 100644 --- a/server/internal/i18n/locales/es/account.json +++ b/server/internal/i18n/locales/es/account.json @@ -75,6 +75,14 @@ "revoke": "Si no iniciaste tú este inicio de sesión, revoca el token ahora desde la página de tokens de tu cuenta:", "cta": "Ver mis tokens" }, + "passkeyAdded": { + "subject": "Se ha añadido una llave de acceso a tu cuenta de SolidPing", + "preheader": "«{name}» ya puede iniciar sesión en tu cuenta.", + "title": "Se ha añadido una nueva llave de acceso", + "body": "El {date} se añadió a tu cuenta de SolidPing una llave de acceso llamada «{name}». Permite iniciar sesión en todas las organizaciones de la cuenta.", + "revoke": "Si no la añadiste tú, elimínala ahora desde la página de seguridad de tu cuenta y cambia tu contraseña:", + "cta": "Ver mis llaves de acceso" + }, "invitation": { "subject": "Te han invitado a unirte a {org} en SolidPing", "preheader": { diff --git a/server/internal/i18n/locales/fr/account.json b/server/internal/i18n/locales/fr/account.json index 44fee6c06..3144a3943 100644 --- a/server/internal/i18n/locales/fr/account.json +++ b/server/internal/i18n/locales/fr/account.json @@ -75,6 +75,14 @@ "revoke": "Si vous n'avez pas lancé cette connexion vous-même, révoquez ce jeton dès maintenant depuis la page des jetons de votre compte :", "cta": "Voir mes jetons" }, + "passkeyAdded": { + "subject": "Une clé d'accès a été ajoutée à votre compte SolidPing", + "preheader": "« {name} » peut désormais se connecter à votre compte.", + "title": "Une nouvelle clé d'accès a été ajoutée", + "body": "Une clé d'accès nommée « {name} » a été ajoutée à votre compte SolidPing le {date}. Elle permet de se connecter à toutes les organisations du compte.", + "revoke": "Si vous ne l'avez pas ajoutée vous-même, supprimez-la dès maintenant depuis la page de sécurité de votre compte et changez votre mot de passe :", + "cta": "Voir mes clés d'accès" + }, "invitation": { "subject": "Invitation à rejoindre {org} sur SolidPing", "preheader": { diff --git a/server/internal/jobs/jobtypes/job_state_cleanup.go b/server/internal/jobs/jobtypes/job_state_cleanup.go index d886c38fe..e335f29bc 100644 --- a/server/internal/jobs/jobtypes/job_state_cleanup.go +++ b/server/internal/jobs/jobtypes/job_state_cleanup.go @@ -5,6 +5,7 @@ import ( "encoding/json" "time" + "github.com/fclairamb/solidping/server/internal/db/models" "github.com/fclairamb/solidping/server/internal/handlers/attachments" "github.com/fclairamb/solidping/server/internal/jobs/jobdef" "github.com/fclairamb/solidping/server/internal/jobs/jobsvc" @@ -61,6 +62,7 @@ func (r *StateCleanupJobRun) Run(ctx context.Context, jctx *jobdef.JobContext) e } sweepExpiredAuthHandoffCodes(ctx, jctx) + sweepExpiredUnconfirmedSubscribers(ctx, jctx) sweepOrphanIncidentAttachments(ctx, jctx) sweepOrphanCheckAttachments(ctx, jctx) @@ -97,6 +99,26 @@ func sweepExpiredAuthHandoffCodes(ctx context.Context, jctx *jobdef.JobContext) } } +// sweepExpiredUnconfirmedSubscribers hard-deletes status-page subscriptions +// whose confirm link expired unclicked (models.SubscriberConfirmTTL). Anyone +// can create such a row through the public subscribe endpoint, so without this +// the table grows without bound from anonymous traffic (spec 2026-10-28-02). +// Confirmed rows are never touched. Best-effort, like the other sweeps. +func sweepExpiredUnconfirmedSubscribers(ctx context.Context, jctx *jobdef.JobContext) { + before := time.Now().UTC().Add(-models.SubscriberConfirmTTL) + + count, err := jctx.DBService.DeleteExpiredUnconfirmedSubscribers(ctx, before) + if err != nil { + jctx.Logger.WarnContext(ctx, "Failed to delete expired unconfirmed status subscribers", "error", err) + + return + } + + if count > 0 { + jctx.Logger.InfoContext(ctx, "Deleted expired unconfirmed status subscribers", "count", count) + } +} + // attachmentOrphanGrace is how long an attachment must have existed before the // sweep will consider it. It is a RACE GUARD, not a retention policy: an // attachment row is written moments after its incident, and on the agent path diff --git a/server/internal/middleware/auth.go b/server/internal/middleware/auth.go index fd834ecd0..f3dee1b08 100644 --- a/server/internal/middleware/auth.go +++ b/server/internal/middleware/auth.go @@ -122,8 +122,13 @@ func (m *AuthMiddleware) RequireAuth(next httpx.HandlerFunc) httpx.HandlerFunc { // reasoning as the demo guard, reusing its route-pattern mechanism: // the token acts as the target everywhere except the // credential-changing surface. - if denied := impersonationDenial(claims, user, httpx.RoutePattern(req), req.Method); denied != "" { - return m.WriteError(writer, http.StatusForbidden, base.ErrorCodeImpersonationForbidden, denied) + // + // A personal access token is bound to the org it was created in + // (spec 2026-10-28-01). Same choke point and same route-pattern + // mechanism: it may do anything in its org but open a session, + // approve a device grant or change how the account signs in. + if code, denied := credentialDenial(claims, user, httpx.RoutePattern(req), req.Method); denied != "" { + return m.WriteError(writer, http.StatusForbidden, code, denied) } // Add claims and user to context @@ -136,12 +141,7 @@ func (m *AuthMiddleware) RequireAuth(next httpx.HandlerFunc) httpx.HandlerFunc { // not read the same in a security trail — so the token form is // recorded as such while ActorUID still names the owning user. ctx = audit.WithUser(ctx, user.UID, actorTypeForToken(token)) - // Under an impersonation token the audit actor is the TARGET (every - // permission check runs as them), and every row written must still - // name the admin behind it. - if claims.IsImpersonation() { - ctx = audit.WithImpersonator(ctx, claims.ImpersonatedBy) - } + ctx = withCredentialMarkers(ctx, claims) setSentryIdentity(ctx, claims) @@ -149,6 +149,40 @@ func (m *AuthMiddleware) RequireAuth(next httpx.HandlerFunc) httpx.HandlerFunc { } } +// credentialDenial returns the error code and refusal message for a request the +// credential's kind forbids (an impersonation token, a personal access token), +// or an empty message when it may proceed. +func credentialDenial(claims *auth.Claims, user *models.User, routePattern, method string) (base.ErrorCode, string) { + if denied := impersonationDenial(claims, user, routePattern, method); denied != "" { + return base.ErrorCodeImpersonationForbidden, denied + } + + if claims.IsPAT() && !auth.IsPATAllowed(method, routePattern) { + return base.ErrorCodePATForbidden, auth.PATForbiddenMessage + } + + return "", "" +} + +// withCredentialMarkers records on the context what the services must know +// about the credential beyond the user. +func withCredentialMarkers(ctx context.Context, claims *auth.Claims) context.Context { + // Under an impersonation token the audit actor is the TARGET (every + // permission check runs as them), and every row written must still + // name the admin behind it. + if claims.IsImpersonation() { + ctx = audit.WithImpersonator(ctx, claims.ImpersonatedBy) + } + + // Lets Service.startSession refuse to mint a session from a PAT request, + // whatever the route (spec 2026-10-28-01). + if claims.IsPAT() { + ctx = auth.WithPATCredential(ctx) + } + + return ctx +} + // impersonationDenial returns the refusal message for a request an // impersonation token may not make, or "" when it may proceed (including every // request that is not an impersonation at all). diff --git a/server/internal/middleware/mcp_credential_rest_test.go b/server/internal/middleware/mcp_credential_rest_test.go index f6e15e054..39195f036 100644 --- a/server/internal/middleware/mcp_credential_rest_test.go +++ b/server/internal/middleware/mcp_credential_rest_test.go @@ -182,11 +182,19 @@ func TestMCPCredentialRefusedOnREST(t *testing.T) { mcpCredCase{"legacy loopback CLI resolves its org", "cli-full", http.MethodGet, me, http.StatusOK}, ) - // Positive controls: a dashboard session and an unscoped PAT pass everywhere. + // Positive controls: a dashboard session and an unscoped PAT pass + // everywhere, except switch-org for the PAT: it is bound to its org + // (spec 2026-10-28-01). for _, tok := range []string{"session", "pat"} { for _, call := range controls { name := tok + " " + call.method + " " + call.path - tests = append(tests, mcpCredCase{name, tok, call.method, call.path, http.StatusOK}) + want := http.StatusOK + + if tok == "pat" && call.path == switchOrg { + want = http.StatusForbidden + } + + tests = append(tests, mcpCredCase{name, tok, call.method, call.path, want}) } } @@ -200,6 +208,10 @@ func TestMCPCredentialRefusedOnREST(t *testing.T) { if tt.want == http.StatusUnauthorized { require.Equal(t, string(base.ErrorCodeInvalidToken), errCode) } + + if tt.want == http.StatusForbidden { + require.Equal(t, string(base.ErrorCodePATForbidden), errCode) + } }) } } diff --git a/server/internal/notifications/slack_test.go b/server/internal/notifications/slack_test.go index 153448078..befb989a7 100644 --- a/server/internal/notifications/slack_test.go +++ b/server/internal/notifications/slack_test.go @@ -2722,6 +2722,10 @@ func (m *mockDBService) ResubscribeSubscriber(_ context.Context, _, _, _ string) return nil } +func (m *mockDBService) DeleteExpiredUnconfirmedSubscribers(_ context.Context, _ time.Time) (int64, error) { + return 0, nil +} + func (m *mockDBService) UpdateSubscriberDelivery( _ context.Context, _ string, _ int, _ *time.Time, ) error { diff --git a/server/pkg/cli/auth.go b/server/pkg/cli/auth.go index afa609dbe..bdf1caeae 100644 --- a/server/pkg/cli/auth.go +++ b/server/pkg/cli/auth.go @@ -358,6 +358,20 @@ func authSwitchOrgAction(ctx context.Context, cmd *cli.Command) error { return cliCtx.HandleAuthError(err) } + // A personal access token is bound to its org: the server refuses + // switch-org with PAT_FORBIDDEN (spec 2026-10-28-01). Say why and what to + // do instead of printing a bare 403. + if cliCtx.APIHelper.AuthMethod(ctx) == authMethodPAT { + bound := cliCtx.GetOrg() + if me, meErr := apiClient.Me(ctx); meErr == nil { + if slug := orgSlugFromMe(me); slug != "" { + bound = slug + } + } + + return cliCtx.HandleError("Cannot switch organization", &patBoundError{boundOrg: bound, targetOrg: org}) + } + // Switch org resp, err := apiClient.SwitchOrgWithResponse(ctx, client.SwitchOrgJSONRequestBody{ Org: org, @@ -389,6 +403,17 @@ func authSwitchOrgAction(ctx context.Context, cmd *cli.Command) error { return nil } +// patBoundError is returned by `sp auth switch-org` on a PAT login: it +// explains why the switch is refused and what to do instead. +type patBoundError struct { + boundOrg, targetOrg string +} + +func (e *patBoundError) Error() string { + return fmt.Sprintf("this token is bound to org %s; create a token in org %s "+ + "or log in with `sp auth login`", e.boundOrg, e.targetOrg) +} + // promptForInput prompts the user for input. func promptForInput(prompt string) (string, error) { fmt.Print(prompt) //nolint:forbidigo // Interactive prompt requires direct stdin diff --git a/server/pkg/cli/auth_switch_org_pat_test.go b/server/pkg/cli/auth_switch_org_pat_test.go new file mode 100644 index 000000000..5b30da011 --- /dev/null +++ b/server/pkg/cli/auth_switch_org_pat_test.go @@ -0,0 +1,101 @@ +package cli + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/urfave/cli/v3" +) + +// TestAuthSwitchOrgWithPATPrintsGuidance: on a PAT login, `sp auth switch-org` +// does not call the server's switch-org (which answers 403 PAT_FORBIDDEN, spec +// 2026-10-28-01) and tells the user what to do instead. +func TestAuthSwitchOrgWithPATPrintsGuidance(t *testing.T) { + var switchCalls atomic.Int32 + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch r.URL.Path { + case "/api/v1/auth/me": + _ = json.NewEncoder(w).Encode(map[string]any{ + "user": map[string]any{"uid": smokeUID, "email": "alice@acme.com"}, + "organization": map[string]any{"uid": smokeUID, "slug": "acme", "name": "Acme"}, + }) + case "/api/v1/auth/switch-org": + switchCalls.Add(1) + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]any{"code": "PAT_FORBIDDEN"}) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(srv.Close) + + home := t.TempDir() + t.Chdir(t.TempDir()) + t.Setenv("HOME", home) + + cfgDir := filepath.Join(home, ".config", "solidping") + if err := os.MkdirAll(cfgDir, 0o750); err != nil { + t.Fatal(err) + } + + cfg := `{"url":"` + srv.URL + `","org":"acme","auth":{"pat":"pat_test"}}` + if err := os.WriteFile(filepath.Join(cfgDir, "settings.json"), []byte(cfg), 0o600); err != nil { + t.Fatal(err) + } + + outPath := filepath.Join(t.TempDir(), "stdout") + + out, err := os.Create(outPath) + if err != nil { + t.Fatal(err) + } + + origOut := os.Stdout + os.Stdout = out + + root := &cli.Command{ + Name: "sp", Flags: GetGlobalFlags(), Commands: GetCommands(), + ExitErrHandler: func(context.Context, *cli.Command, error) {}, + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + runErr := root.Run(ctx, []string{"sp", "auth", "switch-org", "globex"}) + + cancel() + + os.Stdout = origOut + _ = out.Close() + + if runErr == nil { + t.Fatal("switch-org with a PAT must fail") + } + + printed, err := os.ReadFile(outPath) + if err != nil { + t.Fatal(err) + } + + for _, want := range []string{ + "this token is bound to org acme", + "create a token in org globex", + "sp auth login", + } { + if !strings.Contains(string(printed), want) { + t.Errorf("output %q does not contain %q", printed, want) + } + } + + if n := switchCalls.Load(); n != 0 { + t.Errorf("switch-org was called %d times with a PAT", n) + } +} diff --git a/specs/done/2026/10/2026-10-28-01-pat-cannot-mint-sessions-or-sign-in-factors.md b/specs/done/2026/10/2026-10-28-01-pat-cannot-mint-sessions-or-sign-in-factors.md new file mode 100644 index 000000000..38034c940 --- /dev/null +++ b/specs/done/2026/10/2026-10-28-01-pat-cannot-mint-sessions-or-sign-in-factors.md @@ -0,0 +1,79 @@ +--- +model: opus +effort: high +--- + +# An org-bound PAT can mint a session in the owner's other orgs and add sign-in factors + +## Problem + +Found by the security scan of 2026-10-28 (finding X1). + +**Severity: high.** Anyone holding a single unscoped personal access token can trigger it. The leak might be a CI secret, a token pasted in a script, or a device-flow token phished for one org (the V1 scenario). The result is a session in every org the owner belongs to, plus sign-in factors that outlive the token. + +A PAT is bound to the org it was created in, and the server enforces that binding. `ValidatePATToken` puts that org in `claims.OrgSlug` (`server/internal/handlers/auth/service.go:1659-1674`). `AuthorizeOrgAccess` refuses any other org (`server/internal/handlers/auth/orgaccess.go:63-65`). The device-flow consent page has the user pick the org the token is for. The `/auth/*` routes behind plain `RequireAuth` (`server/internal/app/server.go:833-869`) do not respect that binding: + +1. **`POST /auth/switch-org` turns the PAT into a full session in any org of its owner.** + - The handler passes only `claims.UserUID` (`server/internal/handlers/auth/handler.go:509-533`). + - `SwitchOrg` checks membership in the target org and mints an access token plus a refresh token there (`service.go:2152-2200`). + - Reproduced on a local test-mode server: a PAT created in org `test3` (role `user`) got 403 on `test2` routes. `switch-org` to `test2` then returned an access and refresh token with role `admin` in `test2`, and that token read `test2` checks (200). + - The refresh token keeps the session alive after the PAT is revoked. +2. **`POST /auth/device/consent` mints a PAT for any org of the owner.** + - `RespondToDeviceConsent` takes the org from the request body (`server/internal/handlers/auth/device_service.go:272-288`). + - `CreatePAT` checks only membership (`service.go:2033-2043`), never the caller's own `claims.OrgSlug`. + - So the holder opens a device request, approves it with the PAT, and receives a PAT for another org. +3. **`POST /auth/passkeys/register/begin` and `/finish` add a passkey to the account.** + - Neither step needs anything beyond `RequireAuth` (`server/internal/handlers/auth/passkey_handler.go:64-103`, `passkey_service.go:192-233`). + - The attacker controls their client, so a script can complete the WebAuthn ceremony with a software key. + - Passkey sign-in counts as MFA and skips TOTP (`passkey_service.go:408`). It signs into every org of the account. + - It survives PAT revocation and password reset, and no email tells the owner that a passkey was added. +4. **Other account-security routes accept a PAT too:** + - `POST /auth/change-password` sets a password with no current password when the account has none, i.e. an SSO-only user (`service.go:3290-3299`). + - `POST /auth/2fa/setup` and `/confirm` enable 2FA on an account without it, with the holder's own secret. + - `DELETE /auth/2fa`: since #693 every call spends the shared login 2FA budget, wrong code or not (`service.go:4870-4878`, key shared with `Verify2FA` at `:4769`). A holder who sends 5 wrong codes every 15 minutes keeps the owner's TOTP and recovery-code sign-in at 429 indefinitely. That stops the owner from signing in to revoke the stolen token, which is the remediation the V1 approval email asks for. + +Precedent: impersonation tokens are already denied exactly this class of routes by `impersonationForbiddenRoutes` (`server/internal/handlers/auth/impersonation_guard.go:49-91`). `startSession` also refuses to mint any session from an impersonated request (`service.go:926-934`). A PAT gets neither guard. The MCP-credential guard (`middleware/auth.go:77-86`, spec 2026-10-16-12) states the same rule for MCP tokens: they "must not mint a PAT or a session". + +Not in scope: a PAT minting another PAT in **its own** org (`POST /orgs/:org/tokens`), with or without an expiry. The dashboard offers "never expires", and the new token is bound to the same org with the same role. + +## Proposal + +1. Mark PAT-derived claims so that guards can recognise them. One option is an unexported field or a `TokenKind` set in `ValidatePATToken` (`service.go:1659`). Reusing the `pat_` prefix check from `middleware/auth.go:215` at the guard is acceptable too. +2. Add a PAT denylist next to the impersonation one, and enforce it in `RequireAuth` (`server/internal/middleware/auth.go:55-130`) with a 403 and a dedicated code (e.g. `PAT_FORBIDDEN`). Start from `impersonationForbiddenRoutes` and keep at least: + - `POST /auth/switch-org`, `POST /orgs` (org creation mints an owner session), `POST /auth/accept-invite`; + - `POST /auth/change-password`, `PATCH /auth/me` when it changes the sign-in email; + - `POST /auth/2fa/setup`, `POST /auth/2fa/confirm`, `DELETE /auth/2fa`; + - `POST /auth/passkeys/register/begin`, `POST /auth/passkeys/register/finish`; + - `POST /auth/device/consent`; + - the Discord identity link and the Telegram/Discord paging links. A bound Discord identity signs in (same reasoning as the impersonation list). +3. Defence in depth at the choke point: make `startSession` (`service.go:918`) refuse when the request's credential is a PAT, the way it refuses impersonation. A session-minting route added later then cannot reopen this. +4. CLI (`server/pkg/cli/auth.go:342-390`): `sp auth switch-org` on a PAT login must print a clear message instead of a bare 403. The message should say "this token is bound to org X; create a token in the target org or log in with `sp auth login`". +5. Send an email to the account owner when a passkey is registered. Reuse the security-notice layout of `device-token-created.html`. +6. Docs: `web/docs/docs/cli.md` and the API tokens page say a PAT is bound to one org and cannot change the account's sign-in methods. + +## Tests + +- `server/internal/handlers/auth` (both engines), table-driven over the denylist. With a PAT created in org A, for a user who is admin of orgs A and B: + - **Regression (fails before the fix):** + - `switch-org` to B returns 403 and mints no refresh token (row count unchanged); + - device consent with `org: B` mints no PAT; + - passkey `register/begin` returns 403; + - `DELETE /auth/2fa` returns 403 and does not touch the 2FA attempt counter. + - **Positive controls:** + - the same calls made with a session JWT from a password login still succeed; + - the PAT still reads org A routes; + - the PAT can still create a PAT in org A. +- A route-table test, like the impersonation and MCP ones, proving that every denylist entry names a real route. +- `startSession` unit test: called in a PAT request context, it returns the new error. +- CLI test: `switch-org` with a PAT config prints the guidance message. +- Email: registering a passkey enqueues the notice for the owner; registering with a session still works. + +## Open questions + +- Should a PAT be allowed to call `switch-org` at all, for the CLI convenience? **Recommended: no.** A token is bound to one org on purpose: the device consent page asks the user which org it is for. The CLI asks for a new token instead. +- Should a session's `DELETE /auth/2fa` keep sharing the login budget (spec 2026-10-27-02)? **Recommended: yes.** Once PATs are refused there, only a session can spend it, and a session holder can already do worse. A separate counter key would reopen the parallel-budget question. + +## Resolved open questions + +- A PAT must not call `switch-org` at all; the CLI asks for a new token. (autopilot: recommended answer) +- A session's `DELETE /auth/2fa` keeps sharing the login budget. (autopilot: recommended answer) diff --git a/specs/done/2026/10/2026-10-28-02-status-subscribe-throttle-and-keep-confirmed.md b/specs/done/2026/10/2026-10-28-02-status-subscribe-throttle-and-keep-confirmed.md new file mode 100644 index 000000000..74bbafa27 --- /dev/null +++ b/specs/done/2026/10/2026-10-28-02-status-subscribe-throttle-and-keep-confirmed.md @@ -0,0 +1,61 @@ +--- +model: opus +effort: medium +--- + +# Public status-page subscribe sends unthrottled confirm emails and un-confirms existing subscribers + +## Problem + +Found by the security scan of 2026-10-28 (finding X2). + +**Severity: medium.** Anyone can trigger it, with no account. It works against any public status page, including one the attacker created in their own org. + +`POST /api/v1/orgs/:org/status-pages/:statusPageUid/subscribers` is public (`server/internal/app/server.go:2259`). The comment above it says the global per-IP limit applies, and that "double opt-in is the primary anti-abuse control". That limit is 1800 requests per minute by default (`server/internal/config/config.go:1899`). Double opt-in does not hold for three reasons: + +1. **The confirm email is sent on every request, with no per-recipient or per-page budget.** + - The handler sends it synchronously after each successful `Subscribe` (`server/internal/handlers/statussubscribers/handler.go:73-95`). + - Re-subscribing the same address takes the `FindAnySubscriber` → `ResubscribeSubscriber` path, which rotates the tokens and returns success, so the email goes out again (`service.go:288-313`). + - Nothing in `internal/email` throttles either. + - Result: any third-party address can be flooded with confirm emails sent from the instance's own mail domain, at up to the per-IP rate, from several IPs. On SaaS that burns the sender reputation every customer depends on. + - The password-reset flow guards against the same thing with per-user and per-IP counters (`server/internal/handlers/auth/service.go:3075-3088`). E2 (spec 2026-10-16-13) treated a race on that cap as a bug. +2. **Re-subscribing silently cancels a confirmed subscription.** + - `ResubscribeSubscriber` sets `confirmed_at = NULL` and replaces the unsubscribe token on any existing row, confirmed ones included (`server/internal/db/sqlite/status_page_subscriber.go:150-163`, same in `db/postgres`). + - The fan-out reads only `confirmed_at IS NOT NULL` rows (`sqlite/status_page_subscriber.go:198-207`). + - So an anonymous caller who knows or guesses a subscriber's address (`ops@acme.com`) can stop that subscriber's incident notifications. The subscriber only gets an unexpected "confirm your subscription" email, and the unsubscribe links in their earlier emails stop working. +3. **Unconfirmed rows never expire.** Each distinct address inserts a row that no job removes (`server/internal/jobs/jobtypes/job_state_cleanup.go` does not touch this table), and the confirm token has no expiry. The table grows without bound from anonymous traffic. + +## Proposal + +1. **Keep a confirmed subscription confirmed.** In `upsertSubscriber` (`server/internal/handlers/statussubscribers/service.go:288`), when the existing row is live (`deleted_at IS NULL`) and confirmed: + - return success without changing the row and without sending an email; + - the response stays the same `202 pending_confirmation`, so the endpoint does not reveal whether an address is subscribed. + + Revive and re-confirm only soft-deleted or unconfirmed rows. +2. **Budget the confirm email** with `BumpGlobalCounter` (the primitive the auth counters use, `service.go:3069-3088`): + - per recipient address, across all pages: e.g. 3 per hour; + - per page: e.g. 100 per hour; + - per client IP, resolved with `base.ExtractTrustedClientIP`, never the spoofable `ExtractRemoteAddr` (known M10). + + Over budget, keep answering 202 but send nothing, so the endpoint does not become an oracle either. +3. **Expire unconfirmed rows.** Add an expiry for the confirm token (e.g. 7 days, checked in the confirm lookup). Have `StateCleanupJob` hard-delete unconfirmed rows older than that. +4. Update the route comment at `server.go:2250-2258` to describe the real controls. + +## Tests + +- `server/internal/handlers/statussubscribers` (both engines): + - **Regression (fails before the fix):** + - subscribe and confirm an address, then subscribe it again through the public endpoint. The row stays confirmed, the unsubscribe token is unchanged, and no email is sent. + - 10 subscribe calls for the same new address send at most the per-recipient budget of emails. Every call still answers 202. + - **Positive controls:** + - a first subscribe sends exactly one confirm email; + - a soft-deleted (unsubscribed) address can subscribe again and receives a new confirm email. + - An unconfirmed row older than the expiry is removed by the cleanup job. Its confirm link is then refused. A confirmed row of the same age is kept. + +## Open questions + +- Budgets: **recommended** 3 per recipient per hour, 100 per page per hour, 20 per IP per hour. Make them config values with those defaults. + +## Resolved open questions + +- Budgets: 3 per recipient per hour, 100 per page per hour, 20 per IP per hour, as config values with those defaults. (autopilot: recommended answer) diff --git a/specs/done/2026/10/2026-10-28-03-invite-lookup-without-org-scan.md b/specs/done/2026/10/2026-10-28-03-invite-lookup-without-org-scan.md new file mode 100644 index 000000000..fbf969822 --- /dev/null +++ b/specs/done/2026/10/2026-10-28-03-invite-lookup-without-org-scan.md @@ -0,0 +1,44 @@ +--- +model: opus +effort: medium +--- + +# Each anonymous invite-token lookup scans every organization + +## Problem + +Found by the security scan of 2026-10-28 (finding X3). + +**Severity: medium on SaaS, low self-hosted.** Anyone can trigger it, with no account. The cost grows with the number of organizations, and on SaaS any user can add organizations. + +- `GET /api/v1/auth/invite/:token` is public (`server/internal/app/server.go:808`). `POST /auth/accept-invite` also runs without authentication when no credential is presented (`:812`). +- Both go through `findInvite` (`server/internal/handlers/auth/service.go:3893-3932`, called at `:3949` and `:3977`). For each request it: + - loads every organization, all columns, with no limit (`ListOrganizations`, `server/internal/db/postgres/postgres.go:525-535`); + - then issues two `GetStateEntry` queries per organization: the hashed key, then the legacy plaintext key. +- An unknown token always walks the whole list. One request costs about 2 × N + 1 queries, plus materializing N organization rows. The only bound is the global per-IP limiter (1800 per minute by default, `server/internal/config/config.go:1899`). +- With 1000 organizations, a single IP can drive about 3.6 million database queries per minute. Several IPs multiply that. Every tenant shares that database, so the whole instance degrades. +- The legacy branch (`service.go:3914-3928`) is marked `TODO(remove after 2026-10-10)`. Invites live at most a week, so every pre-hash invite has expired by now. + +## Proposal + +1. Resolve the invite with one lookup. Store a global index entry at invite creation (`CreateInvitation`, `service.go:3743`): `invite:` with a nil organization, its value holding the org UID. `findInvite` then reads that entry, then the org-scoped entry it points to. + - An org-scoped entry with no index entry, i.e. an invite created before this change, can be found through the old loop for one more week. Drop that fallback in a follow-up, or accept that such invites must be re-sent. See Open questions. +2. Remove the legacy plaintext-key branch, whose removal date has passed. +3. Revoking an invitation deletes its index entry too. + +## Tests + +- `server/internal/handlers/auth` (both engines): + - **Regression (fails before the fix):** with 50 organizations, `GetInviteInfo` on an unknown token runs a constant number of queries, independent of the organization count. Count them with a bun query hook. Before the fix it runs about 2 × 50. + - **Positive controls:** + - a valid invite created through `CreateInvitation` is found by `GetInviteInfo` and accepted by `AcceptInvite`; + - a revoked invite is not found. + - A token equal to a stored hash is still refused. This is the property the legacy-branch comment protected; keep the test after the branch is gone. + +## Open questions + +- Invites created before the upgrade (no index entry): keep the old loop as a fallback for 7 days, or ask admins to re-send? **Recommended: no fallback.** Invites are cheap to re-send, and the fallback is the DoS. Note it in the changelog. + +## Resolved open questions + +- No fallback for invites created before the upgrade; admins re-send them. Note it in the changelog. (autopilot: recommended answer) diff --git a/specs/done/2026/10/2026-10-28-04-snmp-community-secret-field.md b/specs/done/2026/10/2026-10-28-04-snmp-community-secret-field.md new file mode 100644 index 000000000..9d45ea872 --- /dev/null +++ b/specs/done/2026/10/2026-10-28-04-snmp-community-secret-field.md @@ -0,0 +1,43 @@ +--- +model: sonnet +effort: low +--- + +# SNMP community string is stored and returned as public check config + +## Problem + +Found by the security scan of 2026-10-28 (finding X4). + +**Severity: low.** Any member of the org can read it, viewers included. Anyone who receives an export of the org's checks can read it too. + +- In SNMP v1/v2c, the community string is the protocol's only credential. A read-write community also lets its holder change the device. +- The SNMP checker declares only the v3 passwords as secret (`server/internal/checkers/checksnmp/config/secret_fields.go:5-7`, `return []string{"authPassword", "privPassword"}`). `community` (`config/config.go:40`) is treated as ordinary config. +- The value therefore: + - is stored in the public `config` column, not the encrypted secret store (`applyEncryption` splits on `SecretFields()`); + - comes back in clear on `GET /orgs/:org/checks` and `/checks/:uid` for every member, and through the MCP check tools. `redactSecretConfig` strips only declared secret keys (`server/internal/handlers/checks/service.go:4380-4405`); + - is kept in exports and in check version snapshots. `stripSecretKeysForExport` (`service.go:4353-4366`) and `hiddenExportConfigKeys` / `checkversion.HiddenConfigKeys` (`diff.go:526-530`) use the same declared set. +- SNMP is the only outlier. Every other checker declares its credential fields, e.g. `password` for rdp, vnc, ftp and the databases, `secretHeaders` and `basicAuth` for http, `secretMetadata` for grpc, `saslPassword` for kafka. + +## Proposal + +1. Add `"community"` to `SNMPConfig.SecretFields()` (`server/internal/checkers/checksnmp/config/secret_fields.go`). +2. Make sure existing rows move the value out of the public column. The credentials migration (`server/internal/credmigrate`) should re-split rows whose type gained a secret field, and the read path already redacts declared keys. +3. Dash0 SNMP form (`web/dash0/src/components/checks/form/types/infra.tsx`): render `community` as a secret input, like the v3 passwords. An unchanged edit must keep the stored value. +4. The default `public` community applied when the field is empty (`checksnmp/checker.go:128-133`) stays as it is. It is not stored. + +## Tests + +- `checksnmp/config`: `SecretFields()` contains `community` (update the existing table test). +- `server/internal/handlers/checks` (both engines): + - **Regression (fails before the fix):** create an SNMP v2c check with community `s3cr3t-acme`. + - `GET` as a viewer returns no `community` value, and `configPrivateKeys` lists it. + - The export does not contain `s3cr3t-acme`. + - The public `config` column of the row does not contain it. + - **Positive control:** the check still runs with the stored community. An edit that leaves the field untouched keeps it. +- Migration/credmigrate: a row written before the change, with `community` in the public column, ends up with the value in the secret store and out of `config`. + +## To verify + +- Whether `credmigrate` re-splits existing rows when a checker's `SecretFields()` grows, or only runs once. If only once, add a step for SNMP rows. +- Whether past check version snapshots (`checkversion`) already stored the community. If so, scrub it there too. diff --git a/web/dash0/src/components/checks/form/types/infra.tsx b/web/dash0/src/components/checks/form/types/infra.tsx index 4620e0cd6..463ab7a89 100644 --- a/web/dash0/src/components/checks/form/types/infra.tsx +++ b/web/dash0/src/components/checks/form/types/infra.tsx @@ -67,6 +67,7 @@ function SnmpFields({ errors, }: CheckTypeFieldsProps) { const { t } = useTranslation("checks"); + const { configPrivateKeys } = useCheckFormFields(); return ( <>
@@ -129,8 +130,9 @@ function SnmpFields({ onChange({ ...state, community: e.target.value })} data-testid="check-community-input" diff --git a/web/docs/docs/cli.md b/web/docs/docs/cli.md index 77b863376..93375ae17 100644 --- a/web/docs/docs/cli.md +++ b/web/docs/docs/cli.md @@ -58,10 +58,19 @@ sp auth login # device flow: approve a one-time code in any bro sp auth login --with-password # classic email + password sp auth login --token pat_... # save a Personal Access Token created in the dashboard sp auth me # show the current user -sp auth switch-org # change active organization +sp auth switch-org # change active organization (session logins only) sp auth logout ``` +A Personal Access Token, including the one the device flow mints, is bound to +the organization it was created in. It cannot switch organization or change how +your account signs in (password, email, 2FA, passkeys): `sp auth switch-org` on +a token login says so. Run `sp auth login` again and pick the other +organization on the consent page, use a token created in that organization, or +log in with `sp auth login --with-password` to get a session that can switch. +See +[API tokens are bound to one organization](configuration/security.md#api-tokens-are-bound-to-one-organization). + `sp auth login` uses the OAuth 2.0 Device Authorization Grant (RFC 8628): it prints a short one-time code and a verification URL, tries to open your browser at that URL, and waits while you approve the login in any browser where you are diff --git a/web/docs/docs/configuration/security.md b/web/docs/docs/configuration/security.md index 49e8efac3..1df68623a 100644 --- a/web/docs/docs/configuration/security.md +++ b/web/docs/docs/configuration/security.md @@ -90,6 +90,37 @@ If no master key is configured, encryption is disabled and credentials are store Token hashing has no such fallback and no configuration: it is not keyed off the master key, and it never stores a token in plaintext. +## API Tokens Are Bound to One Organization + +An API token (`pat_…`) belongs to the organization it was created in, with your role there. It reads and writes that organization only: a request for another organization answers `403`, even when you are a member of it. + +A token is meant for a script, a CI job or the CLI, so it cannot change how your account signs in or open a session. These requests answer `403 PAT_FORBIDDEN`: + +- switching organization, creating an organization or accepting an invitation (each would open a session); +- approving a device sign-in (it would mint a token for another organization); +- changing your password or your sign-in email, setting up or removing 2FA, adding or removing a passkey; +- linking a Discord account, or a Discord or Telegram chat for paging. + +To do any of that, sign in to the dashboard, or with `sp auth login`. A token can still create another token in its own organization. + +Adding a passkey sends you an email, with a link to your account security page: a passkey signs in to every organization of the account and survives a password reset, so one you did not add must not go unnoticed. + +## Status-Page Subscriptions Are Budgeted + +Anyone can subscribe an email address to a public status page, with no account. The address then receives a confirm link (double opt-in). To keep that form from flooding a third party's mailbox from your mail domain, the confirm email has three hourly budgets: + +| Variable | Default | Counts | +|---|---|---| +| `SP_STATUS_SUBSCRIBERS_CONFIRM_PER_RECIPIENT_PER_HOUR` | `3` | emails to one address, across every status page | +| `SP_STATUS_SUBSCRIBERS_CONFIRM_PER_PAGE_PER_HOUR` | `100` | emails sent by one status page | +| `SP_STATUS_SUBSCRIBERS_CONFIRM_PER_IP_PER_HOUR` | `20` | emails one client IP can trigger | + +`0` turns a budget off. The client IP follows `SP_SERVER_RATE_LIMITING_TRUSTED_PROXIES`, like the rate limiter. + +Over budget, the form still answers `202` but sends nothing. Subscribing an address that is already confirmed changes nothing and sends nothing either, with the same `202`. So the form tells nobody whether an address is subscribed, and nobody can cancel someone else's subscription through it. + +A confirm link expires after 7 days. The cleanup job then deletes the unconfirmed subscription. + ## Super-Admin Impersonation A super admin can sign in as another user to see exactly what they see: **Server → Users**, then the eye icon on the user's row. It is meant for support and debugging, and it is built so it cannot become an account takeover: