mirror of
https://github.com/Growstuff/growstuff.git
synced 2026-09-28 08:54:58 -04:00
* Keep Rack::Attack counters in process memory Rack::Attack was storing its request counters in Rails.cache, which is memcached in production. On 2026-09-19, memcached calls were timing out in the production logs. When that happens, the counters stop working. In the same log window, one IP made 753 requests. 717 of them succeeded. The 15 per minute throttle and the 500 per day ban did not stop it. Rack::Attack now uses its own in-memory store, capped at 8 MB. Each Puma worker keeps its own counters, so the limits apply per worker. Refs #1640 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Add BLOCKED_IPS list to Rack::Attack When one crawler overloads the site, we need a fast way to block it. Rack::Attack now blocks every IP listed in the BLOCKED_IPS environment variable. The list is comma separated. Setting the variable does not need a code change. It also keeps IP addresses out of the repository. Refs #1640 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Lower the memcached socket timeout to 0.5 seconds The production cache store waited up to 1.5 seconds for each memcached call. Dalli's own default is 1 second. Dalli also retries after a failure, so one bad cache call can block a Puma thread for longer than the timeout. On 2026-09-19, the production logs showed memcached timeouts every few seconds. With only 5 threads per Puma worker, blocked threads make the site slow. Slow requests hold memory for longer. The timeout is now 0.5 seconds. A healthy memcached call takes a few milliseconds. A call that takes 0.5 seconds has failed in practice, so the request is better off skipping the cache. Refs #1640 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
101 lines
3.3 KiB
Ruby
101 lines
3.3 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require 'rails_helper'
|
|
|
|
RSpec.describe 'Rack::Attack', type: :request do
|
|
include ActiveSupport::Testing::TimeHelpers
|
|
|
|
before do
|
|
Rack::Attack.enabled = true
|
|
Rack::Attack.reset!
|
|
end
|
|
|
|
after do
|
|
Rack::Attack.enabled = false
|
|
Rack::Attack.reset!
|
|
end
|
|
|
|
describe 'cache store' do
|
|
it 'counts requests in process memory, not in Rails.cache' do
|
|
expect(Rack::Attack.cache.store).to be_a(ActiveSupport::Cache::MemoryStore)
|
|
expect(Rack::Attack.cache.store).not_to equal(Rails.cache)
|
|
end
|
|
|
|
it 'keeps banning an IP when Rails.cache is broken' do
|
|
allow(Rails.cache).to receive(:increment).and_raise(IOError, 'memcached is down')
|
|
allow(Rails.cache).to receive(:read).and_raise(IOError, 'memcached is down')
|
|
|
|
get '/dont-crawl-me', headers: { 'REMOTE_ADDR' => '1.2.3.4' }
|
|
get '/community-gardens', headers: { 'REMOTE_ADDR' => '1.2.3.4' }
|
|
|
|
expect(response).to have_http_status(:forbidden)
|
|
end
|
|
end
|
|
|
|
describe 'BLOCKED_IPS environment variable' do
|
|
before do
|
|
allow(ENV).to receive(:fetch).and_call_original
|
|
allow(ENV).to receive(:fetch).with('BLOCKED_IPS', '').and_return('9.9.9.9, 8.8.8.8')
|
|
end
|
|
|
|
it 'blocks every IP in the list' do
|
|
get '/community-gardens', headers: { 'REMOTE_ADDR' => '9.9.9.9' }
|
|
expect(response).to have_http_status(:forbidden)
|
|
|
|
get '/community-gardens', headers: { 'REMOTE_ADDR' => '8.8.8.8' }
|
|
expect(response).to have_http_status(:forbidden)
|
|
end
|
|
|
|
it 'allows IPs that are not in the list' do
|
|
get '/community-gardens', headers: { 'REMOTE_ADDR' => '5.6.7.8' }
|
|
expect(response).not_to have_http_status(:forbidden)
|
|
end
|
|
end
|
|
|
|
describe 'honeypot route /dont-crawl-me' do
|
|
it 'bans an IP for 7 days when hitting /dont-crawl-me' do
|
|
get '/dont-crawl-me', headers: { 'REMOTE_ADDR' => '1.2.3.4' }
|
|
expect(response).to have_http_status(:forbidden)
|
|
|
|
# Next request from same IP should be blocked
|
|
get '/community-gardens', headers: { 'REMOTE_ADDR' => '1.2.3.4' }
|
|
expect(response).to have_http_status(:forbidden)
|
|
|
|
# Requests from a different IP should be allowed
|
|
get '/community-gardens', headers: { 'REMOTE_ADDR' => '5.6.7.8' }
|
|
expect(response).not_to have_http_status(:forbidden)
|
|
|
|
# Fast forward 7 days plus 1 minute
|
|
travel 7.days + 1.minute do
|
|
get '/community-gardens', headers: { 'REMOTE_ADDR' => '1.2.3.4' }
|
|
expect(response).not_to have_http_status(:forbidden)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'excessive crawling (>500 page requests in a day)' do
|
|
it 'bans an IP for 1 week after 500 requests in a day' do
|
|
ip = '10.0.0.1'
|
|
|
|
500.times do
|
|
get '/community-gardens', headers: { 'REMOTE_ADDR' => ip }
|
|
expect(response).not_to have_http_status(:forbidden)
|
|
end
|
|
|
|
# 501st request should be banned
|
|
get '/community-gardens', headers: { 'REMOTE_ADDR' => ip }
|
|
expect(response).to have_http_status(:forbidden)
|
|
|
|
# Subsequent request should remain banned
|
|
get '/community-gardens', headers: { 'REMOTE_ADDR' => ip }
|
|
expect(response).to have_http_status(:forbidden)
|
|
|
|
# Fast forward 1 week plus 1 minute
|
|
travel 7.days + 1.minute do
|
|
get '/community-gardens', headers: { 'REMOTE_ADDR' => ip }
|
|
expect(response).not_to have_http_status(:forbidden)
|
|
end
|
|
end
|
|
end
|
|
end
|