Skip to content
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
42 changes: 39 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -406,10 +406,46 @@ exception is raised up the middleware chain. However, it's likely you would
prefer to show an error page than have an unhandled exception.

You can write your own middleware that catches `Rails::Auth::NotAuthorizedError`
if you'd like. However, a default one is provided which renders a 403 response
with a static page body if you find that helpful.
if you'd like. However, this library includes two middleware for rescuing this
exception for you and displaying an error page.

To use it, add `Rails::Auth::ErrorPage::Middleware` to your app:
#### Rails::Auth::ErrorPage::DebugMiddleware

This middleware displays a detailed error page intended to help debug authorization errors:

![Debug Error Page](https://raw.github.com/square/rails-auth/master/images/debug_error_page.png)

Please be aware this middleware leaks information about your ACL to a potential attacker.
Make sure you're ok with that information being public before using it. If you would like
to avoid leaking that information, see `Rails::Auth::ErrorPage::Middleware` below.

```ruby
app = MyRackApp.new

acl = Rails::Auth::ACL.from_yaml(
File.read("/path/to/my/acl.yaml")
matchers: { allow_x509_subject: Rails::Auth::X509::Matcher }
)

acl_auth = Rails::Auth::ACL::Middleware.new(app, acl: acl)

x509_auth = Rails::Auth::X509::Middleware.new(
acl_auth,
ca_file: "/path/to/my/cabundle.pem"
cert_filters: { 'X-SSL-Client-Cert' => :pem },
require_cert: true
)

error_page = Rails::Auth::ErrorPage::Middleware.new(x509_auth, acl: acl)

run error_page
```

#### Rails::Auth::ErrorPage::Middleware

This middleware catches `Rails::Auth::NotAuthorizedError` and renders a given static HTML file,
e.g. the 403.html file which ships with Rails. It will not give detailed errors to your users,
but it also won't leak information to an attacker.

```ruby
app = MyRackApp.new
Expand Down
Binary file added images/debug_error_page.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
58 changes: 58 additions & 0 deletions lib/rails/auth/error_page/debug_middleware.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# frozen_string_literal: true

require "erb"
require "cgi"

module Rails
module Auth
module ErrorPage
# Render a descriptive access denied page with debugging information about why the given
# request was not authorized. Useful for debugging, but leaks information about your ACL
# to a potential attacker. Make sure you're ok with that information being public.
class DebugMiddleware
# Configure CSP to disable JavaScript, but allow inline CSS
# This is just in case someone pulls off reflective XSS, but hopefully all values are
# properly escaped on the page so that won't happen.
RESPONSE_HEADERS = {
"Content-Security-Policy" =>
"default-src 'self'; " \
"script-src 'none'; " \
"style-src 'unsafe-inline'"
}.freeze

def initialize(app, acl: nil)
raise ArgumentError, "ACL must be a Rails::Auth::ACL" unless acl.is_a?(Rails::Auth::ACL)

@app = app
@acl = acl
@erb = ERB.new(File.read(File.expand_path("../debug_page.html.erb", __FILE__))).freeze
end

def call(env)
@app.call(env)
rescue Rails::Auth::NotAuthorizedError
[403, RESPONSE_HEADERS.dup, [error_page(env)]]
end

def error_page(env)
credentials = Rails::Auth.credentials(env)
resources = @acl.matching_resources(env)

@erb.result(binding)
end

def h(text)
CGI.escapeHTML(text || "")
end

def format_attributes(value)
value.respond_to?(:attributes) ? value.attributes.inspect : value.inspect
end

def format_path(path)
path.source.sub(/\A\\A/, "").sub(/\\z\z/, "")
end
end
end
end
end
128 changes: 128 additions & 0 deletions lib/rails/auth/error_page/debug_page.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Rails::Auth: Access Denied</title>
<style>
body {
font-family: Helvetica, Arial, sans-serif;
margin: 0;
}

#header {
background-color: #ddd;
padding: 8px 32px;
border-bottom: 2px solid #888;
}

#header h1, #header h2 {
margin: 0;
font-size: 2em;
display: inline-block;
}

#header h2 {
color: #880000;
}

#content {
padding: 8px 32px;
}

table {
border: 1px solid #ddd;
border-collapse: collapse;
}

tr:nth-child(odd) {
background-color: #eee;
}

td {
padding: 6px;
border-bottom: 1px solid #ddd;
}

td.label {
font-weight: bold;
text-align: right;
}
</style>
</head>

<body>
<div id="header">
<h1>Rails::Auth:</h1>
<h2>Access Denied</h2>
</div>

<div id="content">
<div id="row">
<p><b>Error:</b> Access to the requested resource is not allowed with your current credentials.</p>
<p>Below is information about the request you made and the credentials you sent:</p>
</div>

<div id="row">
<h3>Request:</h3>

<table>
<tr>
<td class="label">Method</td>
<td><%= h(env["REQUEST_METHOD"]) %></td>
</tr>
<tr>
<td class="label">Path</td>
<td><%= h(env["REQUEST_PATH"]) %></td>
</tr>
<tr>
<td class="label">Host</td>
<td><%= h(env["HTTP_HOST"]) %></td>
</tr>
</table>
</div>

<div id="row">
<h3>Credentials:</h3>

<% if credentials.empty? %>
<p><b>No credentials provided!</b> This is a likely cause of this error.</p>
<p>Please retry the request with proper credentials.</p>
<% else %>
<table>
<% credentials.each do |name, credential| %>
<tr>
<td class="label"><%= h(name) %></td>
<td><%= h(format_attributes(credential)) %></td>
</tr>
<% end %>
</table>
<% end %>
</div>

<div id="row">
<h3>Authorized ACL Entries:</h2>
<% if resources.empty? %>
<p><b>Error: No matching resources!</b> This is a likely cause of this error.</p>
<p>Please check your ACL and make sure there's an entry for this route.</p>
<% else %>
<p>The following entries in your ACL are authorized to view this paritcular route:</p>

<table>
<% resources.each do |resource| %>
<tr>
<td class="label"><%= h((resource.http_methods || "ALL").join(" ")) %> <%= h(format_path(resource.path)) %></td>
<td>
<ul>
<% resource.predicates.each do |name, predicate| %>
<li><%= h(name) %>: <%= h(format_attributes(predicate)) %></li>
<% end %>
</ul>
</td>
</tr>
<% end %>
</table>
<% end %>
</div>
</div>
</body>
</html>
1 change: 1 addition & 0 deletions lib/rails/auth/rack.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
require "rails/auth/acl/resource"

require "rails/auth/error_page/middleware"
require "rails/auth/error_page/debug_middleware"

require "rails/auth/x509/certificate"
require "rails/auth/x509/filter/pem"
Expand Down
37 changes: 37 additions & 0 deletions spec/rails/auth/error_page/debug_middleware_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
RSpec.describe Rails::Auth::ErrorPage::DebugMiddleware do
let(:request) { Rack::MockRequest.env_for("https://www.example.com") }

let(:example_config) { fixture_path("example_acl.yml").read }

let(:example_acl) do
Rails::Auth::ACL.from_yaml(
example_config,
matchers: {
allow_x509_subject: Rails::Auth::X509::Matcher,
allow_claims: ClaimsMatcher
}
)
end

subject(:middleware) { described_class.new(app, acl: example_acl) }

context "access granted" do
let(:code) { 200 }
let(:app) { ->(env) { [code, env, "Hello, world!"] } }

it "renders the expected response" do
response = middleware.call(request)
expect(response.first).to eq code
end
end

context "access denied" do
let(:app) { ->(_env) { raise(Rails::Auth::NotAuthorizedError, "not authorized!") } }

it "renders the error page" do
code, _env, body = middleware.call(request)
expect(code).to eq 403
expect(body.join).to include("Access Denied")
end
end
end