Cross-Site Request Forgery in better_errors
Description
better_errors is an open source replacement for the standard Rails error page with more information rich error pages. It is also usable outside of Rails in any Rack app as Rack middleware. better_errors prior to 2.8.0 did not implement CSRF protection for its internal requests. It also did not enforce the correct "Content-Type" header for these requests, which allowed a cross-origin "simple request" to be made without CORS protection. These together left an application with better_errors enabled open to cross-origin attacks. As a developer tool, better_errors documentation strongly recommends addition only to the development bundle group, so this vulnerability should only affect development environments. Please ensure that your project limits better_errors to the development group (or the non-Rails equivalent). Starting with release 2.8.x, CSRF protection is enforced. It is recommended that you upgrade to the latest release, or minimally to "~> 2.8.3". There are no known workarounds to mitigate the risk of using older releases of better_errors.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
better_errors prior to 2.8.0 lacked CSRF protection and proper Content-Type enforcement, enabling cross-origin attacks in development environments.
Vulnerability
better_errors, an open-source replacement for the standard Rails error page (also usable as Rack middleware), prior to version 2.8.0 did not implement CSRF protection for its internal requests. Additionally, it failed to enforce the correct Content-Type header for these requests, allowing cross-origin "simple requests" to bypass CORS protections [1]. This vulnerability affects all versions before 2.8.0. The developer tool is intended only for the development bundle group, as strongly recommended in its documentation [3].
Exploitation
An attacker can exploit this vulnerability by luring a developer who is running a Rails or Rack application with better_errors enabled in the development environment to visit a malicious website. Because the vulnerable endpoint accepts simple cross-origin requests without CSRF tokens or proper Content-Type validation, the attacker can craft a request that triggers actions within the better_errors middleware, such as executing arbitrary code via the live shell (REPL) or accessing sensitive error information [1][2]. No authentication or special network position is required beyond the ability to serve a malicious page to the developer.
Impact
Successful exploitation allows an attacker to execute arbitrary Ruby code in the context of the developer's application, potentially leading to full compromise of the development environment. This includes reading sensitive data (e.g., credentials, source code), modifying files, or pivoting to other systems accessible from the development machine. The impact is limited to development environments, but given that developers often have broad access, the consequences can be severe [1][3].
Mitigation
The vulnerability is fixed in better_errors version 2.8.0 and later, with the CSRF protection enforced starting from that release. It is recommended to upgrade to at least version 2.8.3 [1][2][4]. Users must ensure that better_errors is only included in the development group of their Gemfile (or equivalent for non-Rails Rack apps) to prevent accidental exposure in production [3]. There are no known workarounds for older releases; upgrading is the only mitigation.
- NVD - CVE-2021-39197
- Add CSRF protection to internal requests by RobinDaugherty · Pull Request #474 · BetterErrors/better_errors
- GitHub - BetterErrors/better_errors: Better error page for Rack apps
- Merge pull request #474 from BetterErrors/feature/add-csrf-to-requests · BetterErrors/better_errors@8e8e796
AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
better_errorsRubyGems | < 2.8.0 | 2.8.0 |
Affected products
3- Range: <2.8.0
- BetterErrors/better_errorsv5Range: < 2.8.0
Patches
18e8e796bfbdeMerge pull request #474 from BetterErrors/feature/add-csrf-to-requests
4 files changed · +192 −74
lib/better_errors/error_page.rb+1 −1 modified@@ -26,7 +26,7 @@ def id @id ||= SecureRandom.hex(8) end - def render(template_name = "main") + def render(template_name = "main", csrf_token = nil) binding.eval(self.class.template(template_name).src) rescue => e # Fix the backtrace, which doesn't identify the template that failed (within Better Errors).
lib/better_errors/middleware.rb+38 −8 modified@@ -1,5 +1,6 @@ require "json" require "ipaddr" +require "securerandom" require "set" require "rack" @@ -39,6 +40,8 @@ def self.allow_ip!(addr) allow_ip! "127.0.0.0/8" allow_ip! "::1/128" rescue nil # windows ruby doesn't have ipv6 support + CSRF_TOKEN_COOKIE_NAME = 'BetterErrors-CSRF-Token' + # A new instance of BetterErrors::Middleware # # @param app The Rack app/middleware to wrap with Better Errors @@ -89,11 +92,14 @@ def protected_app_call(env) end def show_error_page(env, exception=nil) + request = Rack::Request.new(env) + csrf_token = request.cookies[CSRF_TOKEN_COOKIE_NAME] || SecureRandom.uuid + type, content = if @error_page if text?(env) [ 'plain', @error_page.render('text') ] else - [ 'html', @error_page.render ] + [ 'html', @error_page.render('main', csrf_token) ] end else [ 'html', no_errors_page ] @@ -104,12 +110,22 @@ def show_error_page(env, exception=nil) status_code = ActionDispatch::ExceptionWrapper.new(env, exception).status_code end - [status_code, { "Content-Type" => "text/#{type}; charset=utf-8" }, [content]] + response = Rack::Response.new(content, status_code, { "Content-Type" => "text/#{type}; charset=utf-8" }) + + unless request.cookies[CSRF_TOKEN_COOKIE_NAME] + response.set_cookie(CSRF_TOKEN_COOKIE_NAME, value: csrf_token, httponly: true, same_site: :strict) + end + + # In older versions of Rack, the body returned here is actually a Rack::BodyProxy which seems to be a bug. + # (It contains status, headers and body and does not act like an array of strings.) + # Since we already have status code and body here, there's no need to use the ones in the Rack::Response. + (_status_code, headers, _body) = response.finish + [status_code, headers, [content]] end def text?(env) env["HTTP_X_REQUESTED_WITH"] == "XMLHttpRequest" || - !env["HTTP_ACCEPT"].to_s.include?('html') + !env["HTTP_ACCEPT"].to_s.include?('html') end def log_exception @@ -133,9 +149,15 @@ def internal_call(env, opts) return no_errors_json_response unless @error_page return invalid_error_json_response if opts[:id] != @error_page.id - env["rack.input"].rewind - response = @error_page.send("do_#{opts[:method]}", JSON.parse(env["rack.input"].read)) - [200, { "Content-Type" => "text/plain; charset=utf-8" }, [JSON.dump(response)]] + request = Rack::Request.new(env) + return invalid_csrf_token_json_response unless request.cookies[CSRF_TOKEN_COOKIE_NAME] + + request.body.rewind + body = JSON.parse(request.body.read) + return invalid_csrf_token_json_response unless request.cookies[CSRF_TOKEN_COOKIE_NAME] == body['csrfToken'] + + response = @error_page.send("do_#{opts[:method]}", body) + [200, { "Content-Type" => "application/json; charset=utf-8" }, [JSON.dump(response)]] end def no_errors_page @@ -157,18 +179,26 @@ def no_errors_json_response "The application has been restarted since this page loaded, " + "or the framework is reloading all gems before each request " end - [200, { "Content-Type" => "text/plain; charset=utf-8" }, [JSON.dump( + [200, { "Content-Type" => "application/json; charset=utf-8" }, [JSON.dump( error: 'No exception information available', explanation: explanation, )]] end def invalid_error_json_response - [200, { "Content-Type" => "text/plain; charset=utf-8" }, [JSON.dump( + [200, { "Content-Type" => "application/json; charset=utf-8" }, [JSON.dump( error: "Session expired", explanation: "This page was likely opened from a previous exception, " + "and the exception is no longer available in memory.", )]] end + + def invalid_csrf_token_json_response + [200, { "Content-Type" => "application/json; charset=utf-8" }, [JSON.dump( + error: "Invalid CSRF Token", + explanation: "The browser session might have been cleared, " + + "or something went wrong.", + )]] + end end end
lib/better_errors/templates/main.erb+2 −0 modified@@ -800,6 +800,7 @@ (function() { var OID = "<%= id %>"; + var csrfToken = "<%= csrf_token %>"; var previousFrame = null; var previousFrameInfo = null; @@ -810,6 +811,7 @@ var req = new XMLHttpRequest(); req.open("POST", "//" + window.location.host + <%== uri_prefix.gsub("<", "<").inspect %> + "/__better_errors/" + OID + "/" + method, true); req.setRequestHeader("Content-Type", "application/json"); + opts.csrfToken = csrfToken; req.send(JSON.stringify(opts)); req.onreadystatechange = function() { if(req.readyState == 4) {
spec/better_errors/middleware_spec.rb+151 −65 modified@@ -4,9 +4,14 @@ module BetterErrors describe Middleware do let(:app) { Middleware.new(->env { ":)" }) } let(:exception) { RuntimeError.new("oh no :(") } + let(:status) { response_env[0] } + let(:headers) { response_env[1] } + let(:body) { response_env[2].join } - it "passes non-error responses through" do - expect(app.call({})).to eq(":)") + context 'when the application raises no exception' do + it "passes non-error responses through" do + expect(app.call({})).to eq(":)") + end end it "calls the internal methods" do @@ -24,11 +29,6 @@ module BetterErrors app.call("PATH_INFO" => "/__better_errors/") end - it "shows the error page on any subfolder path" do - expect(app).to receive :show_error_page - app.call("PATH_INFO" => "/any_sub/folder/path/__better_errors/") - end - it "doesn't show the error page to a non-local address" do expect(app).not_to receive :better_errors_call app.call("REMOTE_ADDR" => "1.2.3.4") @@ -62,34 +62,71 @@ module BetterErrors expect { app.call("REMOTE_ADDR" => "0:0:0:0:0:0:0:1%0" ) }.to_not raise_error end - context "when requesting the /__better_errors manually" do - let(:app) { Middleware.new(->env { ":)" }) } + context "when /__better_errors is requested directly" do + let(:response_env) { app.call("PATH_INFO" => "/__better_errors") } - it "shows that no errors have been recorded" do - status, headers, body = app.call("PATH_INFO" => "/__better_errors") - expect(body.join).to match /No errors have been recorded yet./ - end + context "when no error has been recorded since startup" do + it "shows that no errors have been recorded" do + expect(body).to match /No errors have been recorded yet./ + end - it 'does not attempt to use ActionDispatch::ExceptionWrapper with a nil exception' do - ad_ew = double("ActionDispatch::ExceptionWrapper") - stub_const('ActionDispatch::ExceptionWrapper', ad_ew) - expect(ad_ew).to_not receive :new + it 'does not attempt to use ActionDispatch::ExceptionWrapper on the nil exception' do + ad_ew = double("ActionDispatch::ExceptionWrapper") + stub_const('ActionDispatch::ExceptionWrapper', ad_ew) + expect(ad_ew).to_not receive :new + + response_env + end + + context 'when requested inside a subfolder path' do + let(:response_env) { app.call("PATH_INFO" => "/any_sub/folder/__better_errors") } - status, headers, body = app.call("PATH_INFO" => "/__better_errors") + it "shows that no errors have been recorded" do + expect(body).to match /No errors have been recorded yet./ + end + end end - it "shows that no errors have been recorded on any subfolder path" do - status, headers, body = app.call("PATH_INFO" => "/any_sub/folder/path/__better_errors") - expect(body.join).to match /No errors have been recorded yet./ + context 'when an error has been recorded' do + let(:app) { + Middleware.new(->env do + # Only raise on the first request + raise exception unless @already_raised + @already_raised = true + end) + } + before do + app.call({}) + end + + it 'returns the information of the most recent error' do + expect(body).to include("oh no :(") + end + + it 'does not attempt to use ActionDispatch::ExceptionWrapper' do + ad_ew = double("ActionDispatch::ExceptionWrapper") + stub_const('ActionDispatch::ExceptionWrapper', ad_ew) + expect(ad_ew).to_not receive :new + + response_env + end + + context 'when inside a subfolder path' do + let(:response_env) { app.call("PATH_INFO" => "/any_sub/folder/__better_errors") } + + it "shows the error page on any subfolder path" do + expect(app).to receive :show_error_page + app.call("PATH_INFO" => "/any_sub/folder/path/__better_errors/") + end + end end end context "when handling an error" do let(:app) { Middleware.new(->env { raise exception }) } + let(:response_env) { app.call({}) } it "returns status 500" do - status, headers, body = app.call({}) - expect(status).to eq(500) end @@ -109,11 +146,9 @@ module BetterErrors } it "shows the exception as-is" do - status, _, body = app.call({}) - expect(status).to eq(500) - expect(body.join).to match(/\n> Second Exception\n/) - expect(body.join).not_to match(/\n> First Exception\n/) + expect(body).to match(/\n> Second Exception\n/) + expect(body).not_to match(/\n> First Exception\n/) end end @@ -135,11 +170,9 @@ def initialize(message, original_exception = nil) } it "shows the original exception instead of the last-raised one" do - status, _, body = app.call({}) - expect(status).to eq(500) - expect(body.join).not_to match(/Second Exception/) - expect(body.join).to match(/First Exception/) + expect(body).not_to match(/Second Exception/) + expect(body).to match(/First Exception/) end end @@ -151,41 +184,68 @@ def initialize(message, original_exception = nil) } it "shows the exception as-is" do - status, _, body = app.call({}) - expect(status).to eq(500) - expect(body.join).to match(/The Exception/) + expect(body).to match(/The Exception/) end end end it "returns ExceptionWrapper's status_code" do ad_ew = double("ActionDispatch::ExceptionWrapper") - allow(ad_ew).to receive('new').with({}, exception) { double("ExceptionWrapper", status_code: 404) } + allow(ad_ew).to receive('new').with(anything, exception) { double("ExceptionWrapper", status_code: 404) } stub_const('ActionDispatch::ExceptionWrapper', ad_ew) - status, headers, body = app.call({}) - expect(status).to eq(404) end it "returns UTF-8 error pages" do - status, headers, body = app.call({}) - expect(headers["Content-Type"]).to match /charset=utf-8/ end - it "returns text pages by default" do - status, headers, body = app.call({}) - + it "returns text content by default" do expect(headers["Content-Type"]).to match /text\/plain/ end - it "returns HTML pages by default" do - # Chrome's 'Accept' header looks similar this. - status, headers, body = app.call("HTTP_ACCEPT" => "text/html,application/xhtml+xml;q=0.9,*/*") + context 'when a CSRF token cookie is not specified' do + it 'includes a newly-generated CSRF token cookie' do + expect(headers).to include( + 'Set-Cookie' => /BetterErrors-CSRF-Token=[-a-z0-9]+; HttpOnly; SameSite=Strict/ + ) + end + end + + context 'when a CSRF token cookie is specified' do + let(:response_env) { app.call({ 'HTTP_COOKIE' => 'BetterErrors-CSRF-Token=abc123' }) } + + it 'does not set a new CSRF token cookie' do + expect(headers).not_to include('Set-Cookie') + end + end + + context 'when the Accept header specifies HTML first' do + let(:response_env) { app.call("HTTP_ACCEPT" => "text/html,application/xhtml+xml;q=0.9,*/*") } + + it "returns HTML content" do + expect(headers["Content-Type"]).to match /text\/html/ + end + + it 'includes the newly-generated CSRF token in the body of the page' do + matches = headers['Set-Cookie'].match(/BetterErrors-CSRF-Token=(?<tok>[-a-z0-9]+); HttpOnly; SameSite=Strict/) + expect(body).to include(matches[:tok]) + end - expect(headers["Content-Type"]).to match /text\/html/ + context 'when a CSRF token cookie is specified' do + let(:response_env) { + app.call({ + 'HTTP_COOKIE' => 'BetterErrors-CSRF-Token=csrfTokenGHI', + "HTTP_ACCEPT" => "text/html,application/xhtml+xml;q=0.9,*/*", + }) + } + + it 'includes that CSRF token in the body of the page' do + expect(body).to include('csrfTokenGHI') + end + end end context 'the logger' do @@ -196,7 +256,7 @@ def initialize(message, original_exception = nil) it "receives the exception as a fatal message" do expect(logger).to receive(:fatal).with(/RuntimeError/) - app.call({}) + response_env end context 'when Rails is being used' do @@ -208,7 +268,7 @@ def initialize(message, original_exception = nil) expect(logger).to receive(:fatal) do |message| expect(message).to_not match(/rspec-core/) end - app.call({}) + response_env end end context 'when Rails is not being used' do @@ -220,27 +280,23 @@ def initialize(message, original_exception = nil) expect(logger).to receive(:fatal) do |message| expect(message).to match(/rspec-core/) end - app.call({}) + response_env end end end end context "requesting the variables for a specific frame" do let(:env) { {} } - let(:result) { - app.call( - "PATH_INFO" => "/__better_errors/#{id}/#{method}", - # This is a POST request, and this is the body of the request. - "rack.input" => StringIO.new('{"index": 0}'), - ) + let(:response_env) { + app.call(request_env) } - let(:status) { result[0] } - let(:headers) { result[1] } - let(:body) { result[2].join } + let(:request_env) { + Rack::MockRequest.env_for("/__better_errors/#{id}/variables", input: StringIO.new(JSON.dump(request_body_data))) + } + let(:request_body_data) { {"index": 0} } let(:json_body) { JSON.parse(body) } let(:id) { 'abcdefg' } - let(:method) { 'variables' } context 'when no errors have been recorded' do it 'returns a JSON error' do @@ -291,14 +347,44 @@ def initialize(message, original_exception = nil) end end - context 'and it matches the request', :focus do + context 'and its ID matches the requested ID' do let(:id) { error_page.id } - it 'returns a JSON error' do - expect(error_page).to receive(:do_variables).and_return(html: "<content>") - expect(json_body).to match( - 'html' => '<content>', - ) + context 'when the body csrfToken matches the CSRF token cookie' do + let(:request_body_data) { { "index" => 0, "csrfToken" => "csrfToken123" } } + before do + request_env["HTTP_COOKIE"] = "BetterErrors-CSRF-Token=csrfToken123" + end + + it 'returns the HTML content' do + expect(error_page).to receive(:do_variables).and_return(html: "<content>") + expect(json_body).to match( + 'html' => '<content>', + ) + end + end + + context 'when the body csrfToken does not match the CSRF token cookie' do + let(:request_body_data) { {"index": 0, "csrfToken": "csrfToken123"} } + before do + request_env["HTTP_COOKIE"] = "BetterErrors-CSRF-Token=csrfToken456" + end + + it 'returns a JSON error' do + expect(json_body).to match( + 'error' => 'Invalid CSRF Token', + 'explanation' => /session might have been cleared/, + ) + end + end + + context 'when there is no CSRF token in the request' do + it 'returns a JSON error' do + expect(json_body).to match( + 'error' => 'Invalid CSRF Token', + 'explanation' => /session might have been cleared/, + ) + end end end end
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-w3j4-76qw-wwjmghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2021-39197ghsaADVISORY
- github.com/BetterErrors/better_errors/commit/8e8e796bfbde4aa088741823c8a3fc6df2089bb0ghsax_refsource_MISCWEB
- github.com/BetterErrors/better_errors/pull/474ghsax_refsource_MISCWEB
- github.com/BetterErrors/better_errors/security/advisories/GHSA-w3j4-76qw-wwjmghsax_refsource_CONFIRMWEB
- github.com/rubysec/ruby-advisory-db/blob/master/gems/better_errors/CVE-2021-39197.ymlghsaWEB
News mentions
0No linked articles in our index yet.