diff --git a/server/config.go b/server/config.go index 26a0a161..206e8ead 100644 --- a/server/config.go +++ b/server/config.go @@ -44,6 +44,8 @@ const ( DefaultVisitorRequestLimitReplenish = 5 * time.Second DefaultVisitorEmailLimitBurst = 16 DefaultVisitorEmailLimitReplenish = time.Hour + DefaultVisitorAccountCreateLimitBurst = 2 + DefaultVisitorAccountCreateLimitReplenish = 24 * time.Hour DefaultVisitorAttachmentTotalSizeLimit = 100 * 1024 * 1024 // 100 MB DefaultVisitorAttachmentDailyBandwidthLimit = 500 * 1024 * 1024 // 500 MB ) @@ -98,6 +100,8 @@ type Config struct { VisitorRequestExemptIPAddrs []netip.Prefix VisitorEmailLimitBurst int VisitorEmailLimitReplenish time.Duration + VisitorAccountCreateLimitBurst int + VisitorAccountCreateLimitReplenish time.Duration BehindProxy bool EnableWeb bool EnableSignup bool @@ -147,6 +151,8 @@ func NewConfig() *Config { VisitorRequestExemptIPAddrs: make([]netip.Prefix, 0), VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst, VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish, + VisitorAccountCreateLimitBurst: DefaultVisitorAccountCreateLimitBurst, + VisitorAccountCreateLimitReplenish: DefaultVisitorAccountCreateLimitReplenish, BehindProxy: false, EnableWeb: true, Version: "", diff --git a/server/errors.go b/server/errors.go index fc97f0b3..5d5accd8 100644 --- a/server/errors.go +++ b/server/errors.go @@ -65,6 +65,7 @@ var ( errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions, please be nice", "https://ntfy.sh/docs/publish/#limitations"} 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"} errHTTPTooManyRequestsAttachmentBandwidthLimit = &errHTTP{42905, http.StatusTooManyRequests, "too many requests: daily bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations"} + errHTTPTooManyRequestsAccountCreateLimit = &errHTTP{42906, http.StatusTooManyRequests, "too many requests: daily account creation limit reached", "https://ntfy.sh/docs/publish/#limitations"} // FIXME document limit errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""} errHTTPInternalErrorInvalidFilePath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid file path", ""} errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/"} diff --git a/server/server.go b/server/server.go index 1c4ee596..a797f023 100644 --- a/server/server.go +++ b/server/server.go @@ -42,10 +42,10 @@ import ( expire tokens auto-refresh tokens from UI reserve topics - rate limit for signup (2 per 24h) handle invalid session token purge accounts that were not logged into in X sync subscription display name + reset daily limits for users store users Pages: - Home diff --git a/server/server_account.go b/server/server_account.go index 990efdbb..7069ca95 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -9,10 +9,13 @@ import ( ) func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *visitor) error { - signupAllowed := s.config.EnableSignup admin := v.user != nil && v.user.Role == auth.RoleAdmin - if !signupAllowed && !admin { - return errHTTPBadRequestSignupNotEnabled + if !admin { + if !s.config.EnableSignup { + return errHTTPBadRequestSignupNotEnabled + } else if v.user != nil { + return errHTTPUnauthorized // Cannot create account from user context + } } body, err := util.Peek(r.Body, 4096) // FIXME if err != nil { @@ -26,6 +29,9 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v * if existingUser, _ := s.auth.User(newAccount.Username); existingUser != nil { return errHTTPConflictUserExists } + if v.accountLimiter != nil && !v.accountLimiter.Allow() { + return errHTTPTooManyRequestsAccountCreateLimit + } if err := s.auth.AddUser(newAccount.Username, newAccount.Password, auth.RoleUser); err != nil { // TODO this should return a User return err } diff --git a/server/visitor.go b/server/visitor.go index 6593d5e7..add32731 100644 --- a/server/visitor.go +++ b/server/visitor.go @@ -34,7 +34,8 @@ type visitor struct { emailsLimiter *rate.Limiter // Rate limiter for emails subscriptionLimiter util.Limiter // Fixed limiter for active subscriptions (ongoing connections) bandwidthLimiter util.Limiter - firebase time.Time // Next allowed Firebase message + accountLimiter *rate.Limiter // Rate limiter for account creation + firebase time.Time // Next allowed Firebase message seen time.Time mu sync.Mutex } @@ -54,11 +55,13 @@ type visitorStats struct { } func newVisitor(conf *Config, messageCache *messageCache, ip netip.Addr, user *auth.User) *visitor { - var requestLimiter, emailsLimiter *rate.Limiter + var requestLimiter, emailsLimiter, accountLimiter *rate.Limiter var messages, emails int64 if user != nil { messages = user.Stats.Messages emails = user.Stats.Emails + } else { + accountLimiter = rate.NewLimiter(rate.Every(conf.VisitorAccountCreateLimitReplenish), conf.VisitorAccountCreateLimitBurst) } if user != nil && user.Plan != nil { requestLimiter = rate.NewLimiter(dailyLimitToRate(user.Plan.MessagesLimit), conf.VisitorRequestLimitBurst) @@ -78,6 +81,7 @@ func newVisitor(conf *Config, messageCache *messageCache, ip netip.Addr, user *a emailsLimiter: emailsLimiter, subscriptionLimiter: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)), bandwidthLimiter: util.NewBytesLimiter(conf.VisitorAttachmentDailyBandwidthLimit, 24*time.Hour), + accountLimiter: accountLimiter, // May be nil firebase: time.Unix(0, 0), seen: time.Now(), } diff --git a/web/src/app/Api.js b/web/src/app/Api.js index b3b383cf..4b0ca88b 100644 --- a/web/src/app/Api.js +++ b/web/src/app/Api.js @@ -161,9 +161,10 @@ class Api { body: body }); if (response.status === 409) { - throw new UsernameTakenError(username) - } - if (response.status !== 200) { + throw new UsernameTakenError(username); + } else if (response.status === 429) { + throw new AccountCreateLimitReachedError(); + } else if (response.status !== 200) { throw new Error(`Unexpected server response ${response.status}`); } } @@ -260,5 +261,9 @@ export class UsernameTakenError extends Error { } } +export class AccountCreateLimitReachedError extends Error { + // Nothing +} + const api = new Api(); export default api; diff --git a/web/src/components/Signup.js b/web/src/components/Signup.js index 5956ed34..9ae6bb4c 100644 --- a/web/src/components/Signup.js +++ b/web/src/components/Signup.js @@ -2,7 +2,7 @@ import * as React from 'react'; import TextField from "@mui/material/TextField"; import Button from "@mui/material/Button"; import Box from "@mui/material/Box"; -import api, {UsernameTakenError} from "../app/Api"; +import api, {AccountCreateLimitReachedError, UsernameTakenError} from "../app/Api"; import routes from "./routes"; import session from "../app/Session"; import Typography from "@mui/material/Typography"; @@ -36,6 +36,8 @@ const Signup = () => { console.log(`[Signup] Signup for user ${user.username} failed`, e); if ((e instanceof UsernameTakenError)) { setError(t("Username {{username}} is already taken", { username: e.username })); + } else if ((e instanceof AccountCreateLimitReachedError)) { + setError(t("Account creation limit reached")); } else if (e.message) { setError(e.message); } else {