diff --git a/docs/privacy.md b/docs/privacy.md index 055e3a35..322e4f34 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -1,6 +1,6 @@ # Privacy policy -**Last updated:** March 31, 2026 +**Last updated:** June 15, 2026 This privacy policy describes how ntfy ("we", "us", or "our") collects, uses, and handles your information when you use the ntfy.sh service, web app, and mobile applications (Android and iOS). @@ -19,8 +19,9 @@ If you create an account on ntfy.sh, we collect: - **Username** - A unique identifier you choose - **Password** - Stored as a secure bcrypt hash (we never store your plaintext password) -- **Email address** - If you subscribe to a paid plan (for billing purposes via Stripe), or if you add a verified - email address for use with the email notification feature +- **Email address** - If you add an email address to your account for account recovery and password resets, for use + with the email notification feature, or if you subscribe to a paid plan (for billing purposes via Stripe). Email + addresses you add to your account are verified by sending a confirmation link. - **Phone number** - Only if you enable the phone call notification feature (verified via SMS/call) You can use ntfy without creating an account. Anonymous usage is fully supported. @@ -77,9 +78,10 @@ Your phone number is shared with Twilio to deliver these services. Twilio's ### Amazon SES (email delivery) -If you use the email notification feature (`X-Email` header), we use Amazon Simple Email Service (SES) to -deliver emails. The recipient email address and message content are transmitted through Amazon's infrastructure. -Amazon's [privacy policy](https://aws.amazon.com/privacy/) applies. +If you use the email notification feature (`X-Email` header), or when ntfy sends account-related emails (email +address verification and password reset links), we use Amazon Simple Email Service (SES) to deliver emails. The +recipient email address and message content are transmitted through Amazon's infrastructure. Amazon's +[privacy policy](https://aws.amazon.com/privacy/) applies. ### Stripe (payments) diff --git a/server/errors.go b/server/errors.go index 6a7bd769..caf1abc0 100644 --- a/server/errors.go +++ b/server/errors.go @@ -167,7 +167,7 @@ var ( errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions", "https://ntfy.sh/docs/publish/#limitations", nil} errHTTPTooManyRequestsLimitTotalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations", nil} errHTTPTooManyRequestsLimitAttachmentBandwidth = &errHTTP{42905, http.StatusTooManyRequests, "limit reached: daily bandwidth reached", "https://ntfy.sh/docs/publish/#limitations", nil} - errHTTPTooManyRequestsLimitAccountCreation = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many accounts created", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit + errHTTPTooManyRequestsLimitAccountActions = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many account requests", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit errHTTPTooManyRequestsLimitReservations = &errHTTP{42907, http.StatusTooManyRequests, "limit reached: too many topic reservations for this user", "", nil} errHTTPTooManyRequestsLimitMessages = &errHTTP{42908, http.StatusTooManyRequests, "limit reached: daily message quota reached", "https://ntfy.sh/docs/publish/#limitations", nil} errHTTPTooManyRequestsLimitAuthFailure = &errHTTP{42909, http.StatusTooManyRequests, "limit reached: too many auth failures", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit diff --git a/server/server_account.go b/server/server_account.go index c7ba5335..517fb938 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -29,8 +29,8 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v * } else if u != nil { return errHTTPUnauthorized // Cannot create account from user context } - if !v.AccountCreationAllowed() { - return errHTTPTooManyRequestsLimitAccountCreation + if !v.AccountActionAllowed() { + return errHTTPTooManyRequestsLimitAccountActions } } newAccount, err := readJSONWithLimit[apiAccountCreateRequest](r.Body, jsonBodyBytesLimit, false) @@ -50,7 +50,7 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v * } return err } - v.AccountCreated() + v.AccountActionPerformed() // If an email was provided and email sending is configured, start verification (best-effort). // The address becomes the primary email on verify (the new account has no primary yet); a // failure to send must not fail signup, so we only log it. @@ -801,10 +801,10 @@ func (s *Server) handleAccountPasswordResetRequest(w http.ResponseWriter, r *htt return err } // Rate limit via the shared per-visitor account-creation bucket (no new limiter/config) - if !v.AccountCreationAllowed() { - return errHTTPTooManyRequestsLimitAccountCreation + if !v.AccountActionAllowed() { + return errHTTPTooManyRequestsLimitAccountActions } - v.AccountCreated() // Consume a token on every request (including no-match), to throttle probing + v.AccountActionPerformed() // Consume a token on every request (including no-match), to throttle probing identifier := strings.TrimSpace(req.Identifier) if identifier != "" && s.config.BaseURL != "" { if userID, email, ok := s.resolveResetTarget(identifier); ok { diff --git a/server/visitor.go b/server/visitor.go index 3d4622dd..2f07d273 100644 --- a/server/visitor.go +++ b/server/visitor.go @@ -66,7 +66,7 @@ type visitor struct { subscriptionLimiter *util.FixedLimiter // Fixed limiter for active subscriptions (ongoing connections) topicCreationLimiter *rate.Limiter // Rate limiter for inserting new topics into the in-memory topic map bandwidthLimiter *util.RateLimiter // Limiter for attachment bandwidth downloads - accountLimiter *rate.Limiter // Rate limiter for account creation, may be nil + accountLimiter *rate.Limiter // Rate limiter for account actions (signup, password-reset requests), may be nil authLimiter *rate.Limiter // Limiter for incorrect login attempts, may be nil firebase time.Time // Next allowed Firebase message seen time.Time // Last seen time of this visitor (needed for removal of stale visitors) @@ -280,8 +280,9 @@ func (v *visitor) AuthFailed() { } } -// AccountCreationAllowed returns true if a new account can be created -func (v *visitor) AccountCreationAllowed() bool { +// AccountActionAllowed returns true if a rate-limited account action (signup or password-reset +// request) is currently allowed for this visitor +func (v *visitor) AccountActionAllowed() bool { v.mu.RLock() // limiters could be replaced! defer v.mu.RUnlock() if v.accountLimiter == nil || (v.accountLimiter != nil && v.accountLimiter.Tokens() < 1) { @@ -290,8 +291,9 @@ func (v *visitor) AccountCreationAllowed() bool { return true } -// AccountCreated decreases the account limiter. This is to be called after an account was created. -func (v *visitor) AccountCreated() { +// AccountActionPerformed decreases the account limiter. This is to be called after a rate-limited +// account action (signup or password-reset request). +func (v *visitor) AccountActionPerformed() { v.mu.RLock() // limiters could be replaced! defer v.mu.RUnlock() if v.accountLimiter != nil { diff --git a/user/manager.go b/user/manager.go index 0f0064d5..40b61647 100644 --- a/user/manager.go +++ b/user/manager.go @@ -1561,10 +1561,10 @@ func (a *Manager) SetPrimaryEmail(userID, email string) error { // emailed link. Only the hash is persisted; the raw token is never stored. email is the // address being verified for email_verify, and "" for password_reset. func (a *Manager) CreateMagicLink(kind MagicLinkKind, userID, email string, ttl time.Duration) (string, error) { - raw := generateLinkToken() + token := generateLinkToken() now := time.Now() m := &MagicLink{ - TokenHash: hashToken(raw), + TokenHash: hashToken(token), Kind: kind, UserID: userID, Email: email, @@ -1574,7 +1574,7 @@ func (a *Manager) CreateMagicLink(kind MagicLinkKind, userID, email string, ttl if err := a.AddMagicLink(m); err != nil { return "", err } - return raw, nil + return token, nil } // MagicLinkByToken looks up a magic link by its raw token (hashing it first). See MagicLinkByHash. diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index e8926227..0e1ce85f 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -28,7 +28,7 @@ "login_title": "Sign in to your ntfy account", "login_form_button_submit": "Sign in", "login_link_signup": "Sign up", - "login_link_forgot_password": "Forgot password?", + "login_link_forgot_password": "Forgot password", "reset_password_request_title": "Reset password", "reset_password_request_description": "Enter your username or email address. If an account exists, a link to reset your password will be emailed. This only works if you already added a primary email address and verified it.", "reset_password_request_identifier_label": "Username or email", diff --git a/web/src/app/errors.js b/web/src/app/errors.js index 5b749d14..34c86f8d 100644 --- a/web/src/app/errors.js +++ b/web/src/app/errors.js @@ -31,11 +31,11 @@ export class TopicReservedError extends Error { } } -export class AccountCreateLimitReachedError extends Error { - static CODE = 42906; // errHTTPTooManyRequestsLimitAccountCreation +export class AccountActionLimitReachedError extends Error { + static CODE = 42906; // errHTTPTooManyRequestsLimitAccountActions constructor() { - super("Account creation limit reached"); + super("Account action limit reached"); } } @@ -75,8 +75,8 @@ export const throwAppError = async (response) => { throw new UserExistsError(); } else if (error.code === TopicReservedError.CODE) { throw new TopicReservedError(); - } else if (error.code === AccountCreateLimitReachedError.CODE) { - throw new AccountCreateLimitReachedError(); + } else if (error.code === AccountActionLimitReachedError.CODE) { + throw new AccountActionLimitReachedError(); } else if (error.code === IncorrectPasswordError.CODE) { throw new IncorrectPasswordError(); } else if (error.code === EmailVerificationCodeInvalidError.CODE) { diff --git a/web/src/components/Signup.jsx b/web/src/components/Signup.jsx index cd3a3d87..8dcd4ea2 100644 --- a/web/src/components/Signup.jsx +++ b/web/src/components/Signup.jsx @@ -9,7 +9,7 @@ import accountApi from "../app/AccountApi"; import AvatarBox from "./AvatarBox"; import session from "../app/Session"; import routes from "./routes"; -import { AccountCreateLimitReachedError, UserExistsError } from "../app/errors"; +import { AccountActionLimitReachedError, UserExistsError } from "../app/errors"; const Signup = () => { const { t } = useTranslation(); @@ -34,7 +34,7 @@ const Signup = () => { console.log(`[Signup] Signup for user ${user.username} failed`, e); if (e instanceof UserExistsError) { setError(t("signup_error_username_taken", { username: e.username })); - } else if (e instanceof AccountCreateLimitReachedError) { + } else if (e instanceof AccountActionLimitReachedError) { setError(t("signup_error_creation_limit_reached")); } else { setError(e.message);