Update privacy policy, code review

This commit is contained in:
binwiederhier
2026-06-16 20:56:22 -04:00
parent b75d0e582c
commit d8c87d04e7
8 changed files with 33 additions and 29 deletions
+8 -6
View File
@@ -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)
+1 -1
View File
@@ -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
+6 -6
View File
@@ -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 {
+7 -5
View File
@@ -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 {
+3 -3
View File
@@ -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.
+1 -1
View File
@@ -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",
+5 -5
View File
@@ -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) {
+2 -2
View File
@@ -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);