Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Content Security Policy #497

Merged
merged 7 commits into from
Dec 11, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions lib/better_errors/editor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ def url(raw_path, line)
url_proc.call(file, line)
end

def scheme
url('/fake', 42).sub(/:.*/, ':')
end

private

attr_reader :url_proc
Expand Down
38 changes: 25 additions & 13 deletions lib/better_errors/error_page.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
module BetterErrors
# @private
class ErrorPage
VariableInfo = Struct.new(:frame, :editor_url, :rails_params, :rack_session, :start_time)

def self.template_path(template_name)
File.expand_path("../templates/#{template_name}.erb", __FILE__)
end
Expand All @@ -13,6 +15,15 @@ def self.template(template_name)
Erubi::Engine.new(File.read(template_path(template_name)), escape: true)
end

def self.render_template(template_name, locals)
locals.send(:eval, self.template(template_name).src)
rescue => e
# Fix the backtrace, which doesn't identify the template that failed (within Better Errors).
# We don't know the line number, so just injecting the template path has to be enough.
e.backtrace.unshift "#{self.template_path(template_name)}:0"
raise
end

attr_reader :exception, :env, :repls

def initialize(exception, env)
Expand All @@ -26,20 +37,21 @@ def id
@id ||= SecureRandom.hex(8)
end

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).
# We don't know the line number, so just injecting the template path has to be enough.
e.backtrace.unshift "#{self.class.template_path(template_name)}:0"
raise
def render_main(csrf_token, csp_nonce)
frame = backtrace_frames[0]
first_frame_variable_info = VariableInfo.new(frame, editor_url(frame), rails_params, rack_session, Time.now.to_f)
self.class.render_template('main', binding)
end

def render_text
self.class.render_template('text', binding)
end

def do_variables(opts)
index = opts["index"].to_i
@frame = backtrace_frames[index]
@var_start_time = Time.now.to_f
{ html: render("variable_info") }
frame = backtrace_frames[index]
variable_info = VariableInfo.new(frame, editor_url(frame), rails_params, rack_session, Time.now.to_f)
{ html: self.class.render_template("variable_info", variable_info) }
end

def do_eval(opts)
Expand Down Expand Up @@ -113,19 +125,19 @@ def request_path
env["PATH_INFO"]
end

def html_formatted_code_block(frame)
def self.html_formatted_code_block(frame)
CodeFormatter::HTML.new(frame.filename, frame.line).output
end

def text_formatted_code_block(frame)
def self.text_formatted_code_block(frame)
CodeFormatter::Text.new(frame.filename, frame.line).output
end

def text_heading(char, str)
str + "\n" + char*str.size
end

def inspect_value(obj)
def self.inspect_value(obj)
if BetterErrors.ignored_classes.include? obj.class.name
"<span class='unsupported'>(Instance of ignored class. "\
"#{obj.class.name ? "Remove #{CGI.escapeHTML(obj.class.name)} from" : "Modify"}"\
Expand Down
22 changes: 19 additions & 3 deletions lib/better_errors/middleware.rb
Original file line number Diff line number Diff line change
Expand Up @@ -94,12 +94,13 @@ def protected_app_call(env)
def show_error_page(env, exception=nil)
request = Rack::Request.new(env)
csrf_token = request.cookies[CSRF_TOKEN_COOKIE_NAME] || SecureRandom.uuid
csp_nonce = SecureRandom.base64(12)

type, content = if @error_page
if text?(env)
[ 'plain', @error_page.render('text') ]
[ 'plain', @error_page.render_text ]
else
[ 'html', @error_page.render('main', csrf_token) ]
[ 'html', @error_page.render_main(csrf_token, csp_nonce) ]
end
else
[ 'html', no_errors_page ]
Expand All @@ -110,7 +111,22 @@ def show_error_page(env, exception=nil)
status_code = ActionDispatch::ExceptionWrapper.new(env, exception).status_code
end

response = Rack::Response.new(content, status_code, { "Content-Type" => "text/#{type}; charset=utf-8" })
headers = {
"Content-Type" => "text/#{type}; charset=utf-8",
"Content-Security-Policy" => [
"default-src 'none'",
# Specifying nonce makes a modern browser ignore 'unsafe-inline' which could still be set
# for older browsers without nonce support.
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src
"script-src 'self' 'nonce-#{csp_nonce}' 'unsafe-inline'",
# Inline style is required by the syntax highlighter.
"style-src 'self' 'unsafe-inline'",
"connect-src 'self'",
"navigate-to 'self' #{BetterErrors.editor.scheme}",
].join('; '),
}

response = Rack::Response.new(content, status_code, headers)

unless request.cookies[CSRF_TOKEN_COOKIE_NAME]
response.set_cookie(CSRF_TOKEN_COOKIE_NAME, value: csrf_token, path: "/", httponly: true, same_site: :strict)
Expand Down
87 changes: 74 additions & 13 deletions lib/better_errors/templates/main.erb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<head>
<title><%= exception_type %> at <%= request_path %></title>
</head>
<body>
<body class="better-errors-javascript-not-loaded">
<%# Stylesheets are placed in the <body> for Turbolinks compatibility. %>
<style>
/* Basic reset */
Expand Down Expand Up @@ -107,13 +107,18 @@
}

.frame_info {
display: none;

right: 0;
left: 40%;

padding: 20px;
padding-left: 10px;
margin-left: 30px;
}
.frame_info.current {
display: block;
}
}

nav.sidebar {
Expand Down Expand Up @@ -227,6 +232,10 @@
* Navigation
* --------------------------------------------------------------------- */

.better-errors-javascript-not-loaded .backtrace .tabs {
display: none;
}

nav.tabs {
border-bottom: solid 1px #ddd;

Expand Down Expand Up @@ -411,6 +420,18 @@
* Display area
* --------------------------------------------------------------------- */

p.no-javascript-notice {
margin-bottom: 1em;
padding: 1em;
border: 2px solid #e00;
}
.better-errors-javascript-loaded .no-javascript-notice {
display: none;
}
.no-inline-style-notice {
display: none;
}

.trace_info {
background: #fff;
padding: 6px;
Expand Down Expand Up @@ -468,6 +489,10 @@
font-weight: 200;
}

.better-errors-javascript-not-loaded .be-repl {
display: none;
}

.code, .be-console, .unavailable {
background: #fff;
padding: 5px;
Expand Down Expand Up @@ -598,6 +623,9 @@
.console-has-been-used .live-console-hint {
display: none;
}
.better-errors-javascript-not-loaded .live-console-hint {
display: none;
}

.hint:before {
content: '\25b2';
Expand Down Expand Up @@ -701,7 +729,7 @@
</style>

<%# IE8 compatibility crap %>
<script>
<script nonce="<%= csp_nonce %>">
(function() {
var elements = ["section", "nav", "header", "footer", "audio"];
for (var i = 0; i < elements.length; i++) {
Expand All @@ -715,7 +743,7 @@
rendered in the host app's layout. Let's empty out the styles of the
host app.
%>
<script>
<script nonce="<%= csp_nonce %>">
if (window.Turbolinks) {
for(var i=0; i < document.styleSheets.length; i++) {
if(document.styleSheets[i].href)
Expand All @@ -740,6 +768,15 @@
}
</script>

<p class='no-inline-style-notice'>
<strong>
Better Errors can't apply inline style<span class='no-javascript-notice'> (or run Javascript)</span>,
possibly because you have a Content Security Policy along with Turbolinks.
But you can
<a href='/__better_errors' target="_blank">open the interactive console in a new tab/window</a>.
</strong>
</p>

<div class='top'>
<header class="exception">
<h2><strong><%= exception_type %></strong> <span>at <%= request_path %></span></h2>
Expand Down Expand Up @@ -786,21 +823,37 @@
</ul>
</nav>

<% backtrace_frames.each_with_index do |frame, index| %>
<div class="frame_info" id="frame_info_<%= index %>" style="display:none;"></div>
<% end %>
<div class="frameInfos">
<div class="frame_info current" data-frame-idx="0">
<p class='no-javascript-notice'>
Better Errors can't run Javascript here<span class='no-inline-style-notice'> (or apply inline style)</span>,
possibly because you have a Content Security Policy along with Turbolinks.
But you can
<a href='/__better_errors' target="_blank">open the interactive console in a new tab/window</a>.
</p>
<!-- this is enough information to show something in case JS doesn't get to load -->
<%== ErrorPage.render_template('variable_info', first_frame_variable_info) %>
</div>
</div>
</section>
</body>
<script>
<script nonce="<%= csp_nonce %>">
(function() {

var OID = "<%= id %>";
var csrfToken = "<%= csrf_token %>";

var previousFrame = null;
var previousFrameInfo = null;
var allFrames = document.querySelectorAll("ul.frames li");
var allFrameInfos = document.querySelectorAll(".frame_info");
var frameInfos = document.querySelector(".frameInfos");

document.querySelector('body').classList.remove("better-errors-javascript-not-loaded");
document.querySelector('body').classList.add("better-errors-javascript-loaded");

var noJSNotices = document.querySelectorAll('.no-javascript-notice');
for(var i = 0; i < noJSNotices.length; i++) {
noJSNotices[i].remove();
}

function apiCall(method, opts, cb) {
var req = new XMLHttpRequest();
Expand Down Expand Up @@ -974,17 +1027,25 @@
};

function switchTo(el) {
if(previousFrameInfo) previousFrameInfo.style.display = "none";
previousFrameInfo = el;
var currentFrameInfo = document.querySelectorAll('.frame_info.current');
for(var i = 0; i < currentFrameInfo.length; i++) {
currentFrameInfo[i].className = "frame_info";
}

el.style.display = "block";
el.className = "frame_info current";

var replInput = el.querySelector('.be-console input');
if (replInput) replInput.focus();
}

function selectFrameInfo(index) {
var el = allFrameInfos[index];
var el = document.querySelector(".frame_info[data-frame-idx='" + index + "']")
if (!el) {
el = document.createElement("div");
el.className = "frame_info";
el.setAttribute('data-frame-idx', index);
frameInfos.appendChild(el);
}
if(el) {
if (el.loaded) {
return switchTo(el);
Expand Down
2 changes: 1 addition & 1 deletion lib/better_errors/templates/text.erb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<%== text_heading("-", "%s, line %i" % [first_frame.pretty_path, first_frame.line]) %>

``` ruby
<%== text_formatted_code_block(first_frame) %>```
<%== ErrorPage.text_formatted_code_block(first_frame) %>```

App backtrace
-------------
Expand Down
Loading