fix: consolidate widget rack-attack throttles with per-endpoint kill switches + saner defaults (#14376)
## Description Consolidates all widget API throttles under the single `ENABLE_RACK_ATTACK_WIDGET_API` flag, each with its own independent kill switch and configurable limit, with defaults tuned from real prod traffic. Fixes https://linear.app/chatwoot/issue/INF-83 Related to https://linear.app/chatwoot/issue/INF-77 Parent flag stays default-true; installs that disabled it keep today's behavior (no widget throttling). Each endpoint adds `ENABLE_RACK_ATTACK_WIDGET_<X>` + `RATE_LIMIT_WIDGET_<X>`. - **conversations create**: keyed on (IP, website_token), 30/min (was per-IP 6/12h) - **messages create**: new throttle, (IP, website_token), 60/min (was unthrottled) - **contact update**: fixes a dormant bug. `resource :contact` gives the singular URL `/api/v1/widget/contact`, but the throttle checked plural `/api/v1/widget/contacts` so it never fired. Now active (60/1h per IP). - **widget load**: 5 to 200/1h, tuned from prod (real per-IP loads top out ~50/hr; the higher tail is crawlers/scrapers) - **transcript**: 5/1h retained Token precedence: the (IP, website_token) throttles read `website_token` via ActionDispatch (query wins), matching the controller, so a body-supplied token cannot fork the throttle bucket. ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? `ruby -c` and rubocop clean. No spec added, matching this file's existing no-spec convention for throttles. --------- Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
This commit is contained in:
16
.env.example
16
.env.example
@@ -230,7 +230,23 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
|
||||
## To prevent and throttle abusive requests
|
||||
# ENABLE_RACK_ATTACK=true
|
||||
# RACK_ATTACK_LIMIT=300
|
||||
## Parent switch for all widget throttles; per-endpoint switches below only apply when this is on
|
||||
# ENABLE_RACK_ATTACK_WIDGET_API=true
|
||||
## Widget conversation creates, per minute, keyed on (IP, website_token)
|
||||
# ENABLE_RACK_ATTACK_WIDGET_CONVERSATIONS=true
|
||||
# RATE_LIMIT_WIDGET_CONVERSATIONS=30
|
||||
## Widget message creates, per minute, keyed on (IP, website_token)
|
||||
# ENABLE_RACK_ATTACK_WIDGET_MESSAGES=true
|
||||
# RATE_LIMIT_WIDGET_MESSAGES=60
|
||||
## Widget contact updates, per hour, per IP
|
||||
# ENABLE_RACK_ATTACK_WIDGET_CONTACTS=true
|
||||
# RATE_LIMIT_WIDGET_CONTACTS=60
|
||||
## Widget page loads (new session), per hour, per IP
|
||||
# ENABLE_RACK_ATTACK_WIDGET_LOAD=true
|
||||
# RATE_LIMIT_WIDGET_LOAD=200
|
||||
## Widget transcript requests, per hour, per IP
|
||||
# ENABLE_RACK_ATTACK_WIDGET_TRANSCRIPT=true
|
||||
# RATE_LIMIT_WIDGET_TRANSCRIPT=5
|
||||
# Comma-separated list of trusted IPs that bypass Rack Attack throttling rules
|
||||
# RACK_ATTACK_ALLOWED_IPS=127.0.0.1,::1,192.168.0.10
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ Metrics/ClassLength:
|
||||
Exclude:
|
||||
- 'app/models/message.rb'
|
||||
- 'app/models/conversation.rb'
|
||||
- 'config/initializers/rack_attack.rb'
|
||||
|
||||
Metrics/MethodLength:
|
||||
Max: 19
|
||||
|
||||
@@ -171,30 +171,62 @@ class Rack::Attack
|
||||
###-----------Widget API Throttling---------------###
|
||||
###-----------------------------------------------###
|
||||
|
||||
# Rack attack on widget APIs can be disabled by setting ENABLE_RACK_ATTACK_WIDGET_API to false
|
||||
# For clients using the widgets in specific conditions like inside and iframe
|
||||
# TODO: Deprecate this feature in future after finding a better solution
|
||||
# Set ENABLE_RACK_ATTACK_WIDGET_API to false to disable all widget throttles (e.g. iframe embeds).
|
||||
# Each throttle also has its own ENABLE_*/RATE_LIMIT_* override.
|
||||
# TODO: Deprecate the blanket ENABLE_RACK_ATTACK_WIDGET_API switch after finding a better solution
|
||||
if ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_RACK_ATTACK_WIDGET_API', true))
|
||||
## Prevent Conversation Bombing on Widget APIs ###
|
||||
throttle('api/v1/widget/conversations', limit: 6, period: 12.hours) do |req|
|
||||
req.ip if req.path_without_extensions == '/api/v1/widget/conversations' && req.post?
|
||||
## Conversation creation, keyed on (IP, website_token) so widgets behind a shared NAT get separate buckets.
|
||||
if ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_RACK_ATTACK_WIDGET_CONVERSATIONS', true))
|
||||
throttle('api/v1/widget/conversations',
|
||||
limit: ENV.fetch('RATE_LIMIT_WIDGET_CONVERSATIONS', '30').to_i,
|
||||
period: 1.minute) do |req|
|
||||
next unless req.path_without_extensions == '/api/v1/widget/conversations' && req.post?
|
||||
|
||||
# ActionDispatch precedence (query wins) matches the controller, so a body token can't fork the bucket.
|
||||
token = ActionDispatch::Request.new(req.env).params['website_token'].presence
|
||||
"#{req.ip}:#{token}" if token
|
||||
end
|
||||
end
|
||||
|
||||
## Message creation, keyed the same way, to cap single-conversation floods.
|
||||
if ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_RACK_ATTACK_WIDGET_MESSAGES', true))
|
||||
throttle('api/v1/widget/messages',
|
||||
limit: ENV.fetch('RATE_LIMIT_WIDGET_MESSAGES', '60').to_i,
|
||||
period: 1.minute) do |req|
|
||||
next unless req.path_without_extensions == '/api/v1/widget/messages' && req.post?
|
||||
|
||||
token = ActionDispatch::Request.new(req.env).params['website_token'].presence
|
||||
"#{req.ip}:#{token}" if token
|
||||
end
|
||||
end
|
||||
|
||||
## Prevent Contact update Bombing in Widget API ###
|
||||
throttle('api/v1/widget/contacts', limit: 60, period: 1.hour) do |req|
|
||||
req.ip if req.path_without_extensions == '/api/v1/widget/contacts' && (req.patch? || req.put?)
|
||||
if ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_RACK_ATTACK_WIDGET_CONTACTS', true))
|
||||
throttle('api/v1/widget/contact',
|
||||
limit: ENV.fetch('RATE_LIMIT_WIDGET_CONTACTS', '60').to_i,
|
||||
period: 1.hour) do |req|
|
||||
req.ip if req.path_without_extensions == '/api/v1/widget/contact' && (req.patch? || req.put?)
|
||||
end
|
||||
end
|
||||
|
||||
## Prevent Conversation Bombing through multiple sessions
|
||||
throttle('widget?website_token={website_token}&cw_conversation={x-auth-token}', limit: 5, period: 1.hour) do |req|
|
||||
## Prevent Conversation Bombing through repeated widget loads
|
||||
if ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_RACK_ATTACK_WIDGET_LOAD', true))
|
||||
throttle('widget?website_token={website_token}&cw_conversation={x-auth-token}',
|
||||
limit: ENV.fetch('RATE_LIMIT_WIDGET_LOAD', '200').to_i,
|
||||
period: 1.hour) do |req|
|
||||
req.ip if req.path_without_extensions == '/widget' && ActionDispatch::Request.new(req.env).params['cw_conversation'].blank?
|
||||
end
|
||||
end
|
||||
|
||||
## Prevent Transcript Bombing on Widget API ###
|
||||
throttle('api/v1/widget/conversations/transcript', limit: 5, period: 1.hour) do |req|
|
||||
if ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_RACK_ATTACK_WIDGET_TRANSCRIPT', true))
|
||||
throttle('api/v1/widget/conversations/transcript',
|
||||
limit: ENV.fetch('RATE_LIMIT_WIDGET_TRANSCRIPT', '5').to_i,
|
||||
period: 1.hour) do |req|
|
||||
req.ip if req.path_without_extensions == '/api/v1/widget/conversations/transcript' && req.post?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
##-----------------------------------------------##
|
||||
|
||||
|
||||
Reference in New Issue
Block a user