From acb4c1b3cc0390c5a4c182e49c930ba0c5e5bbd0 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 17 May 2026 11:33:07 -0400 Subject: [PATCH] Add visitor-topic-creation-limit-* options --- cmd/serve.go | 10 +++++ docs/config.md | 15 +++++++ docs/releases.md | 4 ++ server/config.go | 6 +++ server/errors.go | 1 + server/server.go | 30 ++++++++----- server/server.yml | 9 ++++ server/server_account.go | 4 +- server/server_middleware.go | 2 +- server/server_test.go | 63 +++++++++++++++++++++++++++- server/server_webpush.go | 2 +- server/visitor.go | 84 +++++++++++++++++++++++-------------- 12 files changed, 183 insertions(+), 47 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index 0c0b1139..0cfc1cbc 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -93,6 +93,8 @@ var flagsServe = append( altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-message-daily-limit", Aliases: []string{"visitor_message_daily_limit"}, EnvVars: []string{"NTFY_VISITOR_MESSAGE_DAILY_LIMIT"}, Value: server.DefaultVisitorMessageDailyLimit, Usage: "max messages per visitor per day, derived from request limit if unset"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: util.FormatDuration(server.DefaultVisitorEmailLimitReplenish), Usage: "interval at which burst limit is replenished (one per x)"}), + altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-topic-creation-limit-burst", Aliases: []string{"visitor_topic_creation_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_TOPIC_CREATION_LIMIT_BURST"}, Value: server.DefaultVisitorTopicCreationLimitBurst, Usage: "burst of new topic creations per visitor (0 = disabled)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-topic-creation-limit-replenish", Aliases: []string{"visitor_topic_creation_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_TOPIC_CREATION_LIMIT_REPLENISH"}, Value: util.FormatDuration(server.DefaultVisitorTopicCreationLimitReplenish), Usage: "interval at which topic-creation tokens are refilled (one per x)"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-prefix-bits-ipv4", Aliases: []string{"visitor_prefix_bits_ipv4"}, EnvVars: []string{"NTFY_VISITOR_PREFIX_BITS_IPV4"}, Value: server.DefaultVisitorPrefixBitsIPv4, Usage: "number of bits of the IPv4 address to use for rate limiting (default: 32, full address)"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-prefix-bits-ipv6", Aliases: []string{"visitor_prefix_bits_ipv6"}, EnvVars: []string{"NTFY_VISITOR_PREFIX_BITS_IPV6"}, Value: server.DefaultVisitorPrefixBitsIPv6, Usage: "number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet)"}), altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting)"}), @@ -207,6 +209,8 @@ func execServe(c *cli.Context) error { visitorMessageDailyLimit := c.Int("visitor-message-daily-limit") visitorEmailLimitBurst := c.Int("visitor-email-limit-burst") visitorEmailLimitReplenishStr := c.String("visitor-email-limit-replenish") + visitorTopicCreationLimitBurst := c.Int("visitor-topic-creation-limit-burst") + visitorTopicCreationLimitReplenishStr := c.String("visitor-topic-creation-limit-replenish") visitorPrefixBitsIPv4 := c.Int("visitor-prefix-bits-ipv4") visitorPrefixBitsIPv6 := c.Int("visitor-prefix-bits-ipv6") behindProxy := c.Bool("behind-proxy") @@ -252,6 +256,10 @@ func execServe(c *cli.Context) error { if err != nil { return fmt.Errorf("invalid visitor email limit replenish: %s", visitorEmailLimitReplenishStr) } + visitorTopicCreationLimitReplenish, err := util.ParseDuration(visitorTopicCreationLimitReplenishStr) + if err != nil { + return fmt.Errorf("invalid visitor topic creation limit replenish: %s", visitorTopicCreationLimitReplenishStr) + } webPushExpiryDuration, err := util.ParseDuration(webPushExpiryDurationStr) if err != nil { return fmt.Errorf("invalid web push expiry duration: %s", webPushExpiryDurationStr) @@ -497,6 +505,8 @@ func execServe(c *cli.Context) error { conf.VisitorMessageDailyLimit = visitorMessageDailyLimit conf.VisitorEmailLimitBurst = visitorEmailLimitBurst conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish + conf.VisitorTopicCreationLimitBurst = visitorTopicCreationLimitBurst + conf.VisitorTopicCreationLimitReplenish = visitorTopicCreationLimitReplenish conf.VisitorPrefixBitsIPv4 = visitorPrefixBitsIPv4 conf.VisitorPrefixBitsIPv6 = visitorPrefixBitsIPv6 conf.BehindProxy = behindProxy diff --git a/docs/config.md b/docs/config.md index 003e380c..c934143a 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1919,6 +1919,17 @@ are enabled): * `visitor-email-limit-burst` is the initial bucket of emails each visitor has. This defaults to 16. * `visitor-email-limit-replenish` is the rate at which the bucket is refilled (one email per x). Defaults to 1h. +### Topic creation limits +To mitigate topic-enumeration / squatting attacks (where a single source pokes thousands of guessable +topic names to inflate the server's in-memory topic map), there is a per-visitor limit on how many *new* +topics each visitor can cause to be created. Touching topics that already exist in memory does not consume +a token; only first-time insertions do. + +* `visitor-topic-creation-limit-burst` is the initial bucket of new-topic tokens. Set to 0 to disable + the limit entirely. Defaults to 100. +* `visitor-topic-creation-limit-replenish` is the rate at which the bucket is refilled (one new topic per x). + Defaults to 1m. + ### Firebase limits If [Firebase is configured](#firebase-fcm), all messages are also published to a Firebase topic (unless `Firebase: no` is set). Firebase enforces [its own limits](https://firebase.google.com/docs/cloud-messaging/concept-options#topics_throttling) @@ -2309,6 +2320,8 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`). | `visitor-request-limit-exempt-hosts` | `NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS` | *comma-separated host/IP/CIDR list* | - | Rate limiting: List of hostnames and IPs to be exempt from request rate limiting | | `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) | | `visitor-subscriber-rate-limiting` | `NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING` | *bool* | `false` | Rate limiting: Enables subscriber-based rate limiting | +| `visitor-topic-creation-limit-burst` | `NTFY_VISITOR_TOPIC_CREATION_LIMIT_BURST` | *number* | 100 | Rate limiting: Initial bucket of new topic creations per visitor. 0 disables the limit. | +| `visitor-topic-creation-limit-replenish` | `NTFY_VISITOR_TOPIC_CREATION_LIMIT_REPLENISH` | *duration* | 1m | Rate limiting: Rate at which the per-visitor topic-creation bucket is refilled (one new topic per x). | | `visitor-prefix-bits-ipv4` | `NTFY_VISITOR_PREFIX_BITS_IPV4` | *number* | 32 | Rate limiting: Number of bits to use for IPv4 visitor prefix, e.g. 24 for /24 | | `visitor-prefix-bits-ipv6` | `NTFY_VISITOR_PREFIX_BITS_IPV6` | *number* | 64 | Rate limiting: Number of bits to use for IPv6 visitor prefix, e.g. 48 for /48 | | `web-root` | `NTFY_WEB_ROOT` | *path*, e.g. `/` or `/app`, or `disable` | `/` | Sets root of the web app (e.g. /, or /app), or disables it entirely (disable) | @@ -2416,6 +2429,8 @@ OPTIONS: --visitor-message-daily-limit value, --visitor_message_daily_limit value max messages per visitor per day, derived from request limit if unset (default: 0) [$NTFY_VISITOR_MESSAGE_DAILY_LIMIT] --visitor-email-limit-burst value, --visitor_email_limit_burst value initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST] --visitor-email-limit-replenish value, --visitor_email_limit_replenish value interval at which burst limit is replenished (one per x) (default: "1h") [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH] + --visitor-topic-creation-limit-burst value, --visitor_topic_creation_limit_burst value burst of new topic creations per visitor (0 = disabled) (default: 100) [$NTFY_VISITOR_TOPIC_CREATION_LIMIT_BURST] + --visitor-topic-creation-limit-replenish value, --visitor_topic_creation_limit_replenish value interval at which topic-creation tokens are refilled (one per x) (default: "1m") [$NTFY_VISITOR_TOPIC_CREATION_LIMIT_REPLENISH] --visitor-prefix-bits-ipv4 value, --visitor_prefix_bits_ipv4 value number of bits of the IPv4 address to use for rate limiting (default: 32, full address) (default: 32) [$NTFY_VISITOR_PREFIX_BITS_IPV4] --visitor-prefix-bits-ipv6 value, --visitor_prefix_bits_ipv6 value number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet) (default: 64) [$NTFY_VISITOR_PREFIX_BITS_IPV6] --behind-proxy, --behind_proxy, -P if set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY] diff --git a/docs/releases.md b/docs/releases.md index 1d4d8590..ae1fc6f8 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1895,6 +1895,10 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## ntfy server v2.23.x (UNRELEASED) +**Features:** + +* Add per-visitor rate limit on new topic creations (`visitor-topic-creation-limit-burst` / `visitor-topic-creation-limit-replenish`, defaults 100 burst / 1m replenish) to mitigate topic-enumeration / squatting attacks that inflate the in-memory topic map + **Bug fixes + maintenance:** * Remove `stacktrace-js`, `stacktrace-gps`, `humanize-duration`, and `js-base64` from the web app to reduce dependency and security footprint diff --git a/server/config.go b/server/config.go index f472930a..1cfed3fc 100644 --- a/server/config.go +++ b/server/config.go @@ -69,6 +69,8 @@ const ( DefaultVisitorMessageDailyLimit = 0 DefaultVisitorEmailLimitBurst = 16 DefaultVisitorEmailLimitReplenish = time.Hour + DefaultVisitorTopicCreationLimitBurst = 100 + DefaultVisitorTopicCreationLimitReplenish = time.Minute DefaultVisitorAccountCreationLimitBurst = 3 DefaultVisitorAccountCreationLimitReplenish = 24 * time.Hour DefaultVisitorAuthFailureLimitBurst = 30 @@ -163,6 +165,8 @@ type Config struct { VisitorMessageDailyLimit int VisitorEmailLimitBurst int VisitorEmailLimitReplenish time.Duration + VisitorTopicCreationLimitBurst int // Burst of new topic creations per visitor + VisitorTopicCreationLimitReplenish time.Duration // Interval at which topic-creation tokens are refilled VisitorAccountCreationLimitBurst int VisitorAccountCreationLimitReplenish time.Duration VisitorAuthFailureLimitBurst int @@ -266,6 +270,8 @@ func NewConfig() *Config { VisitorMessageDailyLimit: DefaultVisitorMessageDailyLimit, VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst, VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish, + VisitorTopicCreationLimitBurst: DefaultVisitorTopicCreationLimitBurst, + VisitorTopicCreationLimitReplenish: DefaultVisitorTopicCreationLimitReplenish, VisitorAccountCreationLimitBurst: DefaultVisitorAccountCreationLimitBurst, VisitorAccountCreationLimitReplenish: DefaultVisitorAccountCreationLimitReplenish, VisitorAuthFailureLimitBurst: DefaultVisitorAuthFailureLimitBurst, diff --git a/server/errors.go b/server/errors.go index aab51df4..3197d6ca 100644 --- a/server/errors.go +++ b/server/errors.go @@ -170,6 +170,7 @@ var ( 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 errHTTPTooManyRequestsLimitCalls = &errHTTP{42910, http.StatusTooManyRequests, "limit reached: daily phone call quota reached", "https://ntfy.sh/docs/publish/#limitations", nil} + errHTTPTooManyRequestsLimitTopicCreation = &errHTTP{42911, http.StatusTooManyRequests, "limit reached: too many new topics, please wait", "https://ntfy.sh/docs/publish/#limitations", nil} errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", "", nil} errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", "", nil} errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/", nil} diff --git a/server/server.go b/server/server.go index 78a668bd..7ca0b4e7 100644 --- a/server/server.go +++ b/server/server.go @@ -1543,7 +1543,7 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v * return errHTTPTooManyRequestsLimitSubscriptions } defer v.RemoveSubscription() - topics, topicsStr, err := s.topicsFromPath(r.URL.Path) + topics, topicsStr, err := s.topicsFromPath(v, r.URL.Path) if err != nil { return err } @@ -1646,7 +1646,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi defer v.RemoveSubscription() logvr(v, r).Tag(tagWebsocket).Debug("WebSocket connection opened") defer logvr(v, r).Tag(tagWebsocket).Debug("WebSocket connection closed") - topics, topicsStr, err := s.topicsFromPath(r.URL.Path) + topics, topicsStr, err := s.topicsFromPath(v, r.URL.Path) if err != nil { return err } @@ -1916,24 +1916,26 @@ func (s *Server) handleOptions(w http.ResponseWriter, _ *http.Request, _ *visito } // topicFromPath returns the topic from a root path (e.g. /mytopic), creating it if it doesn't exist. -func (s *Server) topicFromPath(path string) (*topic, error) { +// The visitor is consulted for the per-visitor topic-creation rate limit; pass nil to bypass (internal use). +func (s *Server) topicFromPath(v *visitor, path string) (*topic, error) { parts := strings.Split(path, "/") if len(parts) < 2 { return nil, errHTTPBadRequestTopicInvalid } - return s.topicFromID(parts[1]) + return s.topicFromID(v, parts[1]) } // topicsFromPath returns the topic from a root path (e.g. /mytopic,mytopic2), creating it if it doesn't exist. -func (s *Server) topicsFromPath(path string) ([]*topic, string, error) { +// The visitor is consulted for the per-visitor topic-creation rate limit; pass nil to bypass (internal use). +func (s *Server) topicsFromPath(v *visitor, path string) ([]*topic, string, error) { parts := strings.Split(path, "/") if len(parts) < 2 { return nil, "", errHTTPBadRequestTopicInvalid } topicIDs := util.SplitNoEmpty(parts[1], ",") - topics, err := s.topicsFromIDs(topicIDs...) + topics, err := s.topicsFromIDs(v, topicIDs...) if err != nil { - return nil, "", errHTTPBadRequestTopicInvalid + return nil, "", err } return topics, parts[1], nil } @@ -1948,7 +1950,9 @@ func (s *Server) sequenceIDFromPath(path string) (string, *errHTTP) { } // topicsFromIDs returns the topics with the given IDs, creating them if they don't exist. -func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) { +// If v is non-nil, its per-visitor topic-creation rate limiter is consulted before each new +// insertion into the in-memory topic map. Pass nil to bypass the limit (internal use only). +func (s *Server) topicsFromIDs(v *visitor, ids ...string) ([]*topic, error) { s.mu.Lock() defer s.mu.Unlock() topics := make([]*topic, 0) @@ -1960,6 +1964,9 @@ func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) { if len(s.topics) >= s.config.TotalTopicLimit { return nil, errHTTPTooManyRequestsLimitTotalTopics } + if v != nil && !v.TopicCreationAllowed() { + return nil, errHTTPTooManyRequestsLimitTopicCreation + } s.topics[id] = newTopic(id) } topics = append(topics, s.topics[id]) @@ -1968,8 +1975,9 @@ func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) { } // topicFromID returns the topic with the given ID, creating it if it doesn't exist. -func (s *Server) topicFromID(id string) (*topic, error) { - topics, err := s.topicsFromIDs(id) +// The visitor is consulted for the per-visitor topic-creation rate limit; pass nil to bypass (internal use). +func (s *Server) topicFromID(v *visitor, id string) (*topic, error) { + topics, err := s.topicsFromIDs(v, id) if err != nil { return nil, err } @@ -2239,7 +2247,7 @@ func (s *Server) authorizeTopic(next handleFunc, perm user.Permission) handleFun if s.userManager == nil { return next(w, r, v) } - topics, _, err := s.topicsFromPath(r.URL.Path) + topics, _, err := s.topicsFromPath(v, r.URL.Path) if err != nil { return err } diff --git a/server/server.yml b/server/server.yml index 833f1bea..08161dc2 100644 --- a/server/server.yml +++ b/server/server.yml @@ -358,6 +358,15 @@ # visitor-email-limit-burst: 16 # visitor-email-limit-replenish: "1h" +# Rate limiting: Allowed new topic creations per visitor. A "creation" is when a request +# causes a previously-unknown topic ID to be added to the in-memory topic map. Touches of +# existing topics do not consume tokens. Mitigates topic-enumeration / squatting attacks. +# - visitor-topic-creation-limit-burst is the initial bucket of new-topic tokens (0 = disabled) +# - visitor-topic-creation-limit-replenish is the rate at which the bucket is refilled +# +# visitor-topic-creation-limit-burst: 100 +# visitor-topic-creation-limit-replenish: "1m" + # Rate limiting: IPv4/IPv6 address prefix bits used for rate limiting # - visitor-prefix-bits-ipv4: number of bits of the IPv4 address to use for rate limiting (default: 32, full address) # - visitor-prefix-bits-ipv6: number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet) diff --git a/server/server_account.go b/server/server_account.go index 7def814d..7c5c03c3 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -485,7 +485,7 @@ func (s *Server) handleAccountReservationAdd(w http.ResponseWriter, r *http.Requ return err } // Kill existing subscribers - t, err := s.topicFromID(req.Topic) + t, err := s.topicFromID(v, req.Topic) if err != nil { return err } @@ -728,7 +728,7 @@ func (s *Server) publishSyncEvent(v *visitor) error { return nil } logv(v).Field("sync_topic", u.SyncTopic).Trace("Publishing sync event to user's sync topic") - syncTopic, err := s.topicFromID(u.SyncTopic) + syncTopic, err := s.topicFromID(nil, u.SyncTopic) // internal: no rate limit if err != nil { return err } diff --git a/server/server_middleware.go b/server/server_middleware.go index 07457f2f..b8e650c5 100644 --- a/server/server_middleware.go +++ b/server/server_middleware.go @@ -28,7 +28,7 @@ func (s *Server) limitRequests(next handleFunc) handleFunc { // limitRequestsWithTopic limits requests with a topic and stores the rate-limiting-subscriber and topic into request.Context func (s *Server) limitRequestsWithTopic(next handleFunc) handleFunc { return func(w http.ResponseWriter, r *http.Request, v *visitor) error { - t, err := s.topicFromPath(r.URL.Path) + t, err := s.topicFromPath(v, r.URL.Path) if err != nil { return err } diff --git a/server/server_test.go b/server/server_test.go index 133517d9..bb4ddfba 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -2849,7 +2849,7 @@ func TestServer_PublishWhileUpdatingStatsWithLotsOfMessages(t *testing.T) { messages := make([]*model.Message, 0) for i := 0; i < count; i++ { topicID := fmt.Sprintf("topic%d", i) - _, err := s.topicsFromIDs(topicID) // Add topic to internal s.topics array + _, err := s.topicsFromIDs(nil, topicID) // Add topic to internal s.topics array require.Nil(t, err) messages = append(messages, model.NewDefaultMessage(topicID, "some message")) } @@ -3148,6 +3148,67 @@ func TestServer_SubscriberRateLimiting_ProtectedTopics_WithDefaultReadWrite(t *t }) } +func TestServer_VisitorTopicCreationLimit(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfig(t, databaseURL) + c.VisitorTopicCreationLimitBurst = 5 + c.VisitorTopicCreationLimitReplenish = time.Hour // Effectively no refill during the test + s := newTestServer(t, c) + + // First 5 brand-new topics succeed + for i := 0; i < 5; i++ { + rr := request(t, s, "PUT", fmt.Sprintf("/fresh-topic-%d", i), "hi", nil) + require.Equal(t, 200, rr.Code) + } + // 6th brand-new topic is throttled (42911) + rr := request(t, s, "PUT", "/fresh-topic-6", "hi", nil) + require.Equal(t, 429, rr.Code) + require.Contains(t, rr.Body.String(), `"code":42911`) + + // Republishing to an existing topic doesn't consume a token + for i := 0; i < 3; i++ { + rr := request(t, s, "PUT", "/fresh-topic-0", "again", nil) + require.Equal(t, 200, rr.Code) + } + }) +} + +func TestServer_VisitorTopicCreationLimit_Refill(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + t.Parallel() + c := newTestConfig(t, databaseURL) + c.VisitorTopicCreationLimitBurst = 2 + c.VisitorTopicCreationLimitReplenish = 300 * time.Millisecond + s := newTestServer(t, c) + + // Burn the burst + for i := 0; i < 2; i++ { + rr := request(t, s, "PUT", fmt.Sprintf("/refill-topic-%d", i), "hi", nil) + require.Equal(t, 200, rr.Code) + } + rr := request(t, s, "PUT", "/refill-topic-blocked", "hi", nil) + require.Equal(t, 429, rr.Code) + + // Wait for a token to be replenished + time.Sleep(400 * time.Millisecond) + + rr = request(t, s, "PUT", "/refill-topic-after", "hi", nil) + require.Equal(t, 200, rr.Code) + }) +} + +func TestServer_VisitorTopicCreationLimit_Disabled(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfig(t, databaseURL) + c.VisitorTopicCreationLimitBurst = 0 // 0 disables the limit + s := newTestServer(t, c) + for i := 0; i < 25; i++ { + rr := request(t, s, "PUT", fmt.Sprintf("/nolimit-topic-%d", i), "hi", nil) + require.Equal(t, 200, rr.Code) + } + }) +} + func TestServer_MessageHistoryAndStatsEndpoint(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { c := newTestConfig(t, databaseURL) diff --git a/server/server_webpush.go b/server/server_webpush.go index 70f34669..3b7f0f55 100644 --- a/server/server_webpush.go +++ b/server/server_webpush.go @@ -56,7 +56,7 @@ func (s *Server) handleWebPushUpdate(w http.ResponseWriter, r *http.Request, v * } else if len(req.Topics) > webPushTopicSubscribeLimit { return errHTTPBadRequestWebPushTopicCountTooHigh } - topics, err := s.topicsFromIDs(req.Topics...) + topics, err := s.topicsFromIDs(v, req.Topics...) if err != nil { return err } diff --git a/server/visitor.go b/server/visitor.go index d5e774d7..49a70bb1 100644 --- a/server/visitor.go +++ b/server/visitor.go @@ -53,22 +53,23 @@ const ( // visitor represents an API user, and its associated rate.Limiter used for rate limiting type visitor struct { - config *Config - messageCache *message.Cache - userManager *user.Manager // May be nil - ip netip.Addr // Visitor IP address - user *user.User // Only set if authenticated user, otherwise nil - requestLimiter *rate.Limiter // Rate limiter for (almost) all requests (including messages) - messagesLimiter *util.FixedLimiter // Rate limiter for messages - emailsLimiter *util.RateLimiter // Rate limiter for emails - callsLimiter *util.FixedLimiter // Rate limiter for calls - subscriptionLimiter *util.FixedLimiter // Fixed limiter for active subscriptions (ongoing connections) - bandwidthLimiter *util.RateLimiter // Limiter for attachment bandwidth downloads - accountLimiter *rate.Limiter // Rate limiter for account creation, 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) - mu sync.RWMutex + config *Config + messageCache *message.Cache + userManager *user.Manager // May be nil + ip netip.Addr // Visitor IP address + user *user.User // Only set if authenticated user, otherwise nil + requestLimiter *rate.Limiter // Rate limiter for (almost) all requests (including messages) + messagesLimiter *util.FixedLimiter // Rate limiter for messages + emailsLimiter *util.RateLimiter // Rate limiter for emails + callsLimiter *util.FixedLimiter // Rate limiter for calls + 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 + 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) + mu sync.RWMutex } type visitorInfo struct { @@ -123,21 +124,22 @@ func newVisitor(conf *Config, messageCache *message.Cache, userManager *user.Man calls = user.Stats.Calls } v := &visitor{ - config: conf, - messageCache: messageCache, - userManager: userManager, // May be nil - ip: ip, - user: user, - firebase: time.Unix(0, 0), - seen: time.Now(), - subscriptionLimiter: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)), - requestLimiter: nil, // Set in resetLimiters - messagesLimiter: nil, // Set in resetLimiters, may be nil - emailsLimiter: nil, // Set in resetLimiters - callsLimiter: nil, // Set in resetLimiters, may be nil - bandwidthLimiter: nil, // Set in resetLimiters - accountLimiter: nil, // Set in resetLimiters, may be nil - authLimiter: nil, // Set in resetLimiters, may be nil + config: conf, + messageCache: messageCache, + userManager: userManager, // May be nil + ip: ip, + user: user, + firebase: time.Unix(0, 0), + seen: time.Now(), + subscriptionLimiter: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)), + requestLimiter: nil, // Set in resetLimiters + messagesLimiter: nil, // Set in resetLimiters, may be nil + emailsLimiter: nil, // Set in resetLimiters + callsLimiter: nil, // Set in resetLimiters, may be nil + topicCreationLimiter: nil, // Set in resetLimiters + bandwidthLimiter: nil, // Set in resetLimiters + accountLimiter: nil, // Set in resetLimiters, may be nil + authLimiter: nil, // Set in resetLimiters, may be nil } v.resetLimitersNoLock(messages, emails, calls, false) return v @@ -175,6 +177,10 @@ func (v *visitor) contextNoLock() log.Context { fields["visitor_auth_limiter_limit"] = v.authLimiter.Limit() fields["visitor_auth_limiter_tokens"] = v.authLimiter.Tokens() } + if v.topicCreationLimiter != nil { + fields["visitor_topic_creation_limiter_limit"] = v.topicCreationLimiter.Limit() + fields["visitor_topic_creation_limiter_tokens"] = v.topicCreationLimiter.Tokens() + } if v.user != nil { fields["user_id"] = v.user.ID fields["user_name"] = v.user.Name @@ -246,6 +252,17 @@ func (v *visitor) SubscriptionAllowed() bool { return v.subscriptionLimiter.Allow() } +// TopicCreationAllowed returns true if the visitor is allowed to cause a new topic to be +// inserted into the server's in-memory topic map. Returns true if no limiter is configured. +func (v *visitor) TopicCreationAllowed() bool { + v.mu.RLock() // limiters could be replaced! + defer v.mu.RUnlock() + if v.topicCreationLimiter == nil { + return true + } + return v.topicCreationLimiter.Allow() +} + // AuthAllowed returns true if an auth request can be attempted (> 1 token available) func (v *visitor) AuthAllowed() bool { v.mu.RLock() // limiters could be replaced! @@ -385,6 +402,11 @@ func (v *visitor) resetLimitersNoLock(messages, emails, calls int64, enqueueUpda v.messagesLimiter = util.NewFixedLimiterWithValue(limits.MessageLimit, messages) v.emailsLimiter = util.NewRateLimiterWithValue(limits.EmailLimitReplenish, limits.EmailLimitBurst, emails) v.callsLimiter = util.NewFixedLimiterWithValue(limits.CallLimit, calls) + if v.config.VisitorTopicCreationLimitBurst > 0 && v.config.VisitorTopicCreationLimitReplenish > 0 { + v.topicCreationLimiter = rate.NewLimiter(rate.Every(v.config.VisitorTopicCreationLimitReplenish), v.config.VisitorTopicCreationLimitBurst) + } else { + v.topicCreationLimiter = nil // Disabled + } v.bandwidthLimiter = util.NewBytesLimiter(int(limits.AttachmentBandwidthLimit), oneDay) if v.user == nil { v.accountLimiter = rate.NewLimiter(rate.Every(v.config.VisitorAccountCreationLimitReplenish), v.config.VisitorAccountCreationLimitBurst)