Skip to content

Commit 617e65e

Browse files
Add CSRF to error page and internal calls
1 parent 86c19fc commit 617e65e

File tree

4 files changed

+80
-20
lines changed

4 files changed

+80
-20
lines changed

lib/better_errors/error_page.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ def id
2626
@id ||= SecureRandom.hex(8)
2727
end
2828

29-
def render(template_name = "main")
29+
def render(template_name = "main", csrf_token = nil)
3030
binding.eval(self.class.template(template_name).src)
3131
rescue => e
3232
# Fix the backtrace, which doesn't identify the template that failed (within Better Errors).

lib/better_errors/middleware.rb

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
require "json"
22
require "ipaddr"
3+
require "securerandom"
34
require "set"
45
require "rack"
56

@@ -39,6 +40,8 @@ def self.allow_ip!(addr)
3940
allow_ip! "127.0.0.0/8"
4041
allow_ip! "::1/128" rescue nil # windows ruby doesn't have ipv6 support
4142

43+
CSRF_TOKEN_COOKIE_NAME = 'BetterErrors-CSRF-Token'
44+
4245
# A new instance of BetterErrors::Middleware
4346
#
4447
# @param app The Rack app/middleware to wrap with Better Errors
@@ -89,11 +92,14 @@ def protected_app_call(env)
8992
end
9093

9194
def show_error_page(env, exception=nil)
95+
request = Rack::Request.new(env)
96+
csrf_token = request.cookies[CSRF_TOKEN_COOKIE_NAME] || SecureRandom.uuid
97+
9298
type, content = if @error_page
9399
if text?(env)
94100
[ 'plain', @error_page.render('text') ]
95101
else
96-
[ 'html', @error_page.render ]
102+
[ 'html', @error_page.render('main', csrf_token) ]
97103
end
98104
else
99105
[ 'html', no_errors_page ]
@@ -104,12 +110,21 @@ def show_error_page(env, exception=nil)
104110
status_code = ActionDispatch::ExceptionWrapper.new(env, exception).status_code
105111
end
106112

107-
[status_code, { "Content-Type" => "text/#{type}; charset=utf-8" }, [content]]
113+
headers = {
114+
"Content-Type" => "text/#{type}; charset=utf-8",
115+
}
116+
response = Rack::Response.new(content, status_code, headers)
117+
118+
unless request.cookies[CSRF_TOKEN_COOKIE_NAME]
119+
response.set_cookie(CSRF_TOKEN_COOKIE_NAME, value: csrf_token, httponly: true, same_site: :strict)
120+
end
121+
122+
response.finish
108123
end
109124

110125
def text?(env)
111126
env["HTTP_X_REQUESTED_WITH"] == "XMLHttpRequest" ||
112-
!env["HTTP_ACCEPT"].to_s.include?('html')
127+
!env["HTTP_ACCEPT"].to_s.include?('html')
113128
end
114129

115130
def log_exception
@@ -133,9 +148,15 @@ def internal_call(env, opts)
133148
return no_errors_json_response unless @error_page
134149
return invalid_error_json_response if opts[:id] != @error_page.id
135150

136-
env["rack.input"].rewind
137-
response = @error_page.send("do_#{opts[:method]}", JSON.parse(env["rack.input"].read))
138-
[200, { "Content-Type" => "text/plain; charset=utf-8" }, [JSON.dump(response)]]
151+
request = Rack::Request.new(env)
152+
return invalid_csrf_token_json_response unless request.cookies[CSRF_TOKEN_COOKIE_NAME]
153+
154+
request.body.rewind
155+
body = JSON.parse(request.body.read)
156+
return invalid_csrf_token_json_response unless request.cookies[CSRF_TOKEN_COOKIE_NAME] == body['csrfToken']
157+
158+
response = @error_page.send("do_#{opts[:method]}", body)
159+
[200, { "Content-Type" => "application/json; charset=utf-8" }, [JSON.dump(response)]]
139160
end
140161

141162
def no_errors_page
@@ -170,5 +191,13 @@ def invalid_error_json_response
170191
"and the exception is no longer available in memory.",
171192
)]]
172193
end
194+
195+
def invalid_csrf_token_json_response
196+
[200, { "Content-Type" => "text/plain; charset=utf-8" }, [JSON.dump(
197+
error: "Invalid CSRF Token",
198+
explanation: "The browser session might have been cleared, " +
199+
"or something went wrong.",
200+
)]]
201+
end
173202
end
174203
end

lib/better_errors/templates/main.erb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -800,6 +800,7 @@
800800
(function() {
801801

802802
var OID = "<%= id %>";
803+
var csrfToken = "<%= csrf_token %>";
803804

804805
var previousFrame = null;
805806
var previousFrameInfo = null;
@@ -810,6 +811,7 @@
810811
var req = new XMLHttpRequest();
811812
req.open("POST", "//" + window.location.host + <%== uri_prefix.gsub("<", "&lt;").inspect %> + "/__better_errors/" + OID + "/" + method, true);
812813
req.setRequestHeader("Content-Type", "application/json");
814+
opts.csrfToken = csrfToken;
813815
req.send(JSON.stringify(opts));
814816
req.onreadystatechange = function() {
815817
if(req.readyState == 4) {

spec/better_errors/middleware_spec.rb

Lines changed: 42 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ def initialize(message, original_exception = nil)
161161

162162
it "returns ExceptionWrapper's status_code" do
163163
ad_ew = double("ActionDispatch::ExceptionWrapper")
164-
allow(ad_ew).to receive('new').with({}, exception) { double("ExceptionWrapper", status_code: 404) }
164+
allow(ad_ew).to receive('new').with(anything, exception) { double("ExceptionWrapper", status_code: 404) }
165165
stub_const('ActionDispatch::ExceptionWrapper', ad_ew)
166166

167167
status, headers, body = app.call({})
@@ -229,18 +229,17 @@ def initialize(message, original_exception = nil)
229229
context "requesting the variables for a specific frame" do
230230
let(:env) { {} }
231231
let(:result) {
232-
app.call(
233-
"PATH_INFO" => "/__better_errors/#{id}/#{method}",
234-
# This is a POST request, and this is the body of the request.
235-
"rack.input" => StringIO.new('{"index": 0}'),
236-
)
232+
app.call(request_env)
237233
}
234+
let(:request_env) {
235+
Rack::MockRequest.env_for("/__better_errors/#{id}/variables", input: StringIO.new(JSON.dump(request_body_data)))
236+
}
237+
let(:request_body_data) { {"index": 0} }
238238
let(:status) { result[0] }
239239
let(:headers) { result[1] }
240240
let(:body) { result[2].join }
241241
let(:json_body) { JSON.parse(body) }
242242
let(:id) { 'abcdefg' }
243-
let(:method) { 'variables' }
244243

245244
context 'when no errors have been recorded' do
246245
it 'returns a JSON error' do
@@ -291,14 +290,44 @@ def initialize(message, original_exception = nil)
291290
end
292291
end
293292

294-
context 'and it matches the request', :focus do
293+
context 'and its ID matches the requested ID' do
295294
let(:id) { error_page.id }
296295

297-
it 'returns a JSON error' do
298-
expect(error_page).to receive(:do_variables).and_return(html: "<content>")
299-
expect(json_body).to match(
300-
'html' => '<content>',
301-
)
296+
context 'when the body csrfToken matches the CSRF token cookie' do
297+
let(:request_body_data) { { "index" => 0, "csrfToken" => "csrfToken123" } }
298+
before do
299+
request_env["HTTP_COOKIE"] = "BetterErrors-CSRF-Token=csrfToken123"
300+
end
301+
302+
it 'returns the HTML content' do
303+
expect(error_page).to receive(:do_variables).and_return(html: "<content>")
304+
expect(json_body).to match(
305+
'html' => '<content>',
306+
)
307+
end
308+
end
309+
310+
context 'when the body csrfToken does not match the CSRF token cookie' do
311+
let(:request_body_data) { {"index": 0, "csrfToken": "csrfToken123"} }
312+
before do
313+
request_env["HTTP_COOKIE"] = "BetterErrors-CSRF-Token=csrfToken456"
314+
end
315+
316+
it 'returns a JSON error' do
317+
expect(json_body).to match(
318+
'error' => 'Invalid CSRF Token',
319+
'explanation' => /session might have been cleared/,
320+
)
321+
end
322+
end
323+
324+
context 'when there is no CSRF token in the request' do
325+
it 'returns a JSON error' do
326+
expect(json_body).to match(
327+
'error' => 'Invalid CSRF Token',
328+
'explanation' => /session might have been cleared/,
329+
)
330+
end
302331
end
303332
end
304333
end

0 commit comments

Comments
 (0)