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:
Vishnu Narayanan
2026-07-29 16:32:06 +05:30
committed by GitHub
parent 59eac9a7c5
commit 039a311327
3 changed files with 62 additions and 13 deletions

View File

@@ -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

View File

@@ -18,6 +18,7 @@ Metrics/ClassLength:
Exclude:
- 'app/models/message.rb'
- 'app/models/conversation.rb'
- 'config/initializers/rack_attack.rb'
Metrics/MethodLength:
Max: 19

View File

@@ -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
##-----------------------------------------------##