Files
growstuff/spec/requests/rack_attack_spec.rb
T
Brenda WallaceandClaude Sonnet 5 571405a840 Stop Rack::Attack depending on memcached, and cut the memcached timeout (#4800)
* 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>
2026-09-20 13:36:25 +09:30

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