Skip to content

Commit 960ce6d

Browse files
authored
Merge pull request #214 from alphagov/2446-rate-limit-api-endpoints
Rate limit API endpoints
2 parents ff8e722 + 1c75b78 commit 960ce6d

4 files changed

Lines changed: 203 additions & 20 deletions

File tree

config/initializers/rack_attack.rb

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
class Rack::Attack
2+
CONVERSATION_API_PATH_REGEX = /^\/api\/v\d+\/conversation/
3+
24
throttle("sign-in or sign-ups by IP", limit: 10, period: 5.minutes) do |request|
35
homepage_path = Rails.application.routes.url_helpers.homepage_path
46
next cdn_client_ip(request) if request.path == homepage_path && request.post?
@@ -27,6 +29,30 @@ class Rack::Attack
2729
end
2830
end
2931

32+
throttle("read requests to Conversations API with token", limit: 10_000, period: 1.minute) do |request|
33+
if request.path.match?(CONVERSATION_API_PATH_REGEX) && read_method?(request)
34+
normalise_auth_header(request.get_header("HTTP_AUTHORIZATION"))
35+
end
36+
end
37+
38+
throttle("write requests to Conversations API with token", limit: 500, period: 1.minute) do |request|
39+
if request.path.match?(CONVERSATION_API_PATH_REGEX) && !read_method?(request)
40+
normalise_auth_header(request.get_header("HTTP_AUTHORIZATION"))
41+
end
42+
end
43+
44+
throttle("read requests to Conversations API with device id", limit: 120, period: 1.minute) do |request|
45+
if request.path.match?(CONVERSATION_API_PATH_REGEX) && read_method?(request)
46+
request.get_header("HTTP_GOVUK_CHAT_CLIENT_DEVICE_ID").presence
47+
end
48+
end
49+
50+
throttle("write requests to Conversations API with device id", limit: 20, period: 1.minute) do |request|
51+
if request.path.match?(CONVERSATION_API_PATH_REGEX) && !read_method?(request)
52+
request.get_header("HTTP_GOVUK_CHAT_CLIENT_DEVICE_ID").presence
53+
end
54+
end
55+
3056
def self.rails_controller_action(url)
3157
route = Rails.application.routes.recognize_path(url)
3258

@@ -42,6 +68,16 @@ def self.cdn_client_ip(request)
4268
request.get_header("HTTP_TRUE_CLIENT_IP")
4369
end
4470

71+
def self.read_method?(request)
72+
request.get? || request.head? || request.options?
73+
end
74+
75+
def self.normalise_auth_header(auth_header)
76+
return if auth_header.blank?
77+
78+
auth_header.strip.gsub(/^bearer/i, "Bearer")
79+
end
80+
4581
self.throttled_responder = lambda do |request|
4682
Rails.logger.info(
4783
"Throttled request for #{request.env['rack.attack.match_discriminator']} " \

docs/api_openapi_specification.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ paths:
1818
post:
1919
summary: Start conversation
2020
description: Create a conversation by posting an initial question
21+
parameters:
22+
- $ref: "#/components/parameters/DeviceIdHeader"
2123
requestBody:
2224
required: true
2325
content:
@@ -52,6 +54,7 @@ paths:
5254
schema:
5355
type: string
5456
format: uuid
57+
- $ref: "#/components/parameters/DeviceIdHeader"
5558
responses:
5659
"200":
5760
description: |
@@ -82,6 +85,7 @@ paths:
8285
schema:
8386
type: string
8487
format: uuid
88+
- $ref: "#/components/parameters/DeviceIdHeader"
8589
requestBody:
8690
required: true
8791
content:
@@ -123,6 +127,7 @@ paths:
123127
schema:
124128
type: string
125129
format: uuid
130+
- $ref: "#/components/parameters/DeviceIdHeader"
126131
responses:
127132
"200":
128133
description: The answer is available and is returned
@@ -158,6 +163,7 @@ paths:
158163
schema:
159164
type: string
160165
format: uuid
166+
- $ref: "#/components/parameters/DeviceIdHeader"
161167
requestBody:
162168
required: true
163169
content:
@@ -195,6 +201,17 @@ components:
195201
GOV.UK Signon issued bearer token which is used to authenticate the
196202
client application (e.g. GOV.UK App) and not an individual end user
197203
scheme: bearer
204+
parameters:
205+
DeviceIdHeader:
206+
name: Govuk-Chat-Client-Device-Id
207+
in: header
208+
required: false
209+
description: |
210+
An identifier for an individual end-user client to be used to provide
211+
individual end-user rate limiting to ensure that no one client can
212+
consume all of an API users' limits.
213+
schema:
214+
type: string
198215
schemas:
199216
UserQuestion:
200217
type: object

spec/requests/api/v0/conversations_spec.rb

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,42 @@
7979
},
8080
]
8181

82+
it_behaves_like "throttles traffic for an access token",
83+
routes: {
84+
api_v0_show_conversation_path: %i[get],
85+
api_v0_answer_question_path: %i[get],
86+
api_v0_create_conversation_path: %i[post],
87+
api_v0_update_conversation_path: %i[put],
88+
api_v0_answer_feedback_path: %i[post],
89+
},
90+
period: 1.minute do
91+
let(:route_params) do
92+
{
93+
conversation_id: SecureRandom.uuid,
94+
question_id: SecureRandom.uuid,
95+
answer_id: SecureRandom.uuid,
96+
}
97+
end
98+
end
99+
100+
it_behaves_like "throttles traffic for a single device",
101+
routes: {
102+
api_v0_show_conversation_path: %i[get],
103+
api_v0_answer_question_path: %i[get],
104+
api_v0_create_conversation_path: %i[post],
105+
api_v0_update_conversation_path: %i[put],
106+
api_v0_answer_feedback_path: %i[post],
107+
},
108+
period: 1.minute do
109+
let(:route_params) do
110+
{
111+
conversation_id: SecureRandom.uuid,
112+
question_id: SecureRandom.uuid,
113+
answer_id: SecureRandom.uuid,
114+
}
115+
end
116+
end
117+
82118
describe "middleware ensures adherance to the OpenAPI specification" do
83119
context "when the response returned does not conform to the OpenAPI specification" do
84120
it "raises an error and returns the invalid params in the error message" do

spec/support/rack_attack_examples.rb

Lines changed: 114 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,138 @@
11
module RackAttackExamples
2-
shared_examples "throttles traffic from a single IP address" do |routes:, limit:, period:|
2+
RSpec.shared_context "with rack attack helpers" do
3+
def process_request(method, path, headers)
4+
process(method.to_sym, public_send(path, **route_params), headers: headers)
5+
end
6+
7+
def expect_throttled_response(method, path, headers)
8+
process_request(method, path, headers)
9+
expect(response).to have_http_status(:too_many_requests)
10+
end
11+
12+
def expect_not_throttled_response(method, path, headers)
13+
process_request(method, path, headers)
14+
expect(response).not_to have_http_status(:too_many_requests)
15+
end
16+
end
17+
18+
RSpec.shared_examples "throttles traffic from a single IP address" do |routes:, limit:, period:|
19+
include_context "with rack attack helpers"
320
let(:route_params) { {} }
421

522
routes.each do |path, methods|
623
methods.each do |method|
7-
context "when a single IP address uses it's allowance of traffic to #{method} #{path}", :rack_attack do
8-
let(:ip_address) { "1.2.3.4" }
24+
context "when a single IP address uses its allowance of traffic to #{method} #{path}", :rack_attack do
25+
let(:headers) { { "HTTP_TRUE_CLIENT_IP" => "1.2.3.4" } }
926

1027
before do
1128
limit.times do |i|
12-
process(method.to_sym,
13-
public_send(path, **route_params),
14-
headers: { "HTTP_TRUE_CLIENT_IP": ip_address })
29+
process_request(method, path, headers)
1530
raise "Returning too_many_requests on request #{i + 1}" if response.status == 429
1631
end
1732
end
1833

1934
it "rejects the next request from that IP address" do
20-
process(method.to_sym,
21-
public_send(path, **route_params),
22-
headers: { "HTTP_TRUE_CLIENT_IP": ip_address })
23-
24-
expect(response).to have_http_status(:too_many_requests)
35+
expect_throttled_response(method, path, headers)
2536
end
2637

2738
it "doesn't reject a request from a different IP address" do
28-
process(method.to_sym,
29-
public_send(path, **route_params),
30-
headers: { "HTTP_TRUE_CLIENT_IP": "4.5.6.7" })
31-
32-
expect(response).not_to have_http_status(:too_many_requests)
39+
expect_not_throttled_response(method, path, { "HTTP_TRUE_CLIENT_IP" => "4.5.6.7" })
3340
end
3441

3542
it "doesn't reject a request after the time period" do
3643
travel_to(Time.current + period + 1.second) do
37-
process(method.to_sym,
38-
public_send(path, **route_params),
39-
headers: { "HTTP_TRUE_CLIENT_IP": ip_address })
44+
expect_not_throttled_response(method, path, headers)
45+
end
46+
end
47+
end
48+
end
49+
end
50+
end
51+
52+
shared_examples "throttles traffic for an access token" do |routes:, period:|
53+
include_context "with rack attack helpers"
54+
let(:route_params) { {} }
55+
let(:headers) { { "HTTP_AUTHORIZATION" => "Bearer testtoken123" } }
56+
57+
before do
58+
read_throttle = Rack::Attack.throttles["read requests to Conversations API with token"]
59+
allow(read_throttle).to receive(:limit).and_return(1)
60+
write_throttle = Rack::Attack.throttles["write requests to Conversations API with token"]
61+
allow(write_throttle).to receive(:limit).and_return(1)
62+
end
63+
64+
routes.each do |path, methods|
65+
methods.each do |method|
66+
context "when an access token exhausts its allowance", :rack_attack do
67+
before { process_request(method, path, headers) }
68+
69+
it "rejects the next request to #{method} #{path} using the same token" do
70+
expect_throttled_response(method, path, headers)
71+
end
72+
73+
it "normalises Bearer tokens with different formats" do
74+
[
75+
"bearer testtoken123",
76+
"BEARER testtoken123",
77+
" Bearer testtoken123",
78+
"Bearer testtoken123 ",
79+
].each do |auth_value|
80+
process_request(method, path, { "HTTP_AUTHORIZATION" => auth_value })
81+
expect(response).to have_http_status(:too_many_requests)
82+
end
83+
end
84+
85+
it "doesn't reject a request to #{method} #{path} using a different token" do
86+
expect_not_throttled_response(
87+
method,
88+
path,
89+
{ "HTTP_AUTHORIZATION" => "Bearer testtoken456" },
90+
)
91+
end
92+
93+
it "doesn't reject a request to #{method} #{path} after the time period" do
94+
travel_to(Time.current + period + 1.second) do
95+
expect_not_throttled_response(method, path, headers)
96+
end
97+
end
98+
end
99+
end
100+
end
101+
end
40102

41-
expect(response).not_to have_http_status(:too_many_requests)
103+
RSpec.shared_examples "throttles traffic for a single device" do |routes:, period:|
104+
include_context "with rack attack helpers"
105+
let(:route_params) { {} }
106+
let(:headers) { { "HTTP_GOVUK_CHAT_CLIENT_DEVICE_ID" => "test-device-123" } }
107+
108+
before do
109+
read_throttle = Rack::Attack.throttles["read requests to Conversations API with device id"]
110+
allow(read_throttle).to receive(:limit).and_return(1)
111+
112+
write_throttle = Rack::Attack.throttles["write requests to Conversations API with device id"]
113+
allow(write_throttle).to receive(:limit).and_return(1)
114+
end
115+
116+
routes.each do |path, methods|
117+
methods.each do |method|
118+
context "when a user's device uses its allowance", :rack_attack do
119+
before { process_request(method, path, headers) }
120+
121+
it "rejects the next request to #{method} #{path} with the same device ID" do
122+
expect_throttled_response(method, path, headers)
123+
end
124+
125+
it "doesn't reject a request to #{method} #{path} with a different device ID" do
126+
expect_not_throttled_response(
127+
method,
128+
path,
129+
{ "HTTP_GOVUK_CHAT_CLIENT_DEVICE_ID" => "test-device-456" },
130+
)
131+
end
132+
133+
it "doesn't reject a request to #{method} #{path} after the time period" do
134+
travel_to(Time.current + period + 1.second) do
135+
expect_not_throttled_response(method, path, headers)
42136
end
43137
end
44138
end

0 commit comments

Comments
 (0)