Skip to content

Commit

Permalink
[CORS] Adds W3C Cors Specification Support
Browse files Browse the repository at this point in the history
Provides facilities for dealing with CORS requests and responses

- A Plug (Handler) that handles CORS request and responses.

\## How It Works

This handler is compliant with the W3C CORS specification. As per
this specification, It doesn’t put any CORS response headers
in a connection that holds an invalid CORS request. To know what
“invalid” CORS request means, have a look at the
“Validity of CORS requests” section below.

When some options that are not mandatory and have no default value
(such :max_age) at intialization, the relative header will often not be
sent at all. This is compliant with the specification and at the
same time it reduces the size of the response, even if just by a handful
of bytes.

The following is a list of all the CORS response headers supported by
the handler:

- Access-Control-Allow-Origin
- Access-Control-Allow-Methods
- Access-Control-Allow-Headers
- Access-Control-Allow-Credentials
- Access-Control-Expose-Headers
- Access-Control-Max-Age

\## Validity of CORS requests

“Invalid CORS request” can mean that a request doesn’t have an Origin
header (so it’s not a CORS request at all) or that it’s a CORS request but:

  - The Origin request header doesn’t match any of the allowed origins
  - The request is a preflight request but it requests to use a method
    or some headers that are not allowed (via the Access-Control-Request-Method
    and Access-Control-Request-Headers headers)

\## Responding to preflight requests

When the request is a preflight request and is a valid one (valid origin, valid
request method, and valid request headers), The CORS handler directly sends a
response to that request instead of just adding headers to the connection.
To do this, the handler halts the connection and sends a response.
  • Loading branch information
eliasjpr committed Feb 25, 2018
1 parent 8ad096b commit a53194a
Show file tree
Hide file tree
Showing 2 changed files with 217 additions and 107 deletions.
169 changes: 119 additions & 50 deletions spec/amber/pipes/cors_spec.cr
Original file line number Diff line number Diff line change
@@ -1,56 +1,125 @@
require "../../spec_helper"

module Amber
module Pipe
describe CORS do
context "allowed headers" do
# Pipeline with default settings
pipeline = Pipeline.new
pipeline.build :cors do
plug CORS.new
end
pipeline.prepare_pipelines

# Pipeline with custom settings
pipeline_custom = Pipeline.new
pipeline_custom.build :cors do
plug CORS.new(allow_headers: "max-age")
end
pipeline_custom.prepare_pipelines

Amber::Server.router.draw :cors do
options "/test", HelloController, :world
end

it "should allow a case-insensitive header values" do
request = HTTP::Request.new("OPTIONS", "/test")
request.headers["Access-Control-Request-Method"] = "OPTIONS"
request.headers["Access-Control-Request-Headers"] = "cOnTeNt-TyPe"
response = create_request_and_return_io(pipeline, request)

response.status_code.should eq 200
end

it "allows headers 'accept, content-type' by default" do
request = HTTP::Request.new("OPTIONS", "/test")
request.headers["Access-Control-Request-Method"] = "OPTIONS"
request.headers["Access-Control-Request-Headers"] = "accept"
response = create_request_and_return_io(pipeline, request)

response.status_code.should eq 200
response.headers["Access-Control-Allow-Headers"].should eq "accept, content-type"
end

it "can override settings at initialization" do
request = HTTP::Request.new("OPTIONS", "/test")
request.headers["Access-Control-Request-Method"] = "OPTIONS"
request.headers["Access-Control-Request-Headers"] = "max-age"
response = create_request_and_return_io(pipeline_custom, request)

response.status_code.should eq 200
response.headers["Access-Control-Allow-Headers"].should eq "max-age"
end
module Amber::Pipe
describe CORS do
it "supports simple CORS requests" do
context = cors_context("GET", "Origin": "http://localhost:3000")
CORS.new.call(context)
assert_cors_success(context)
end

it "does not return CORS headers if Origin header not present" do
context = cors_context("GET", "Origin": "http://localhost:3000")
CORS.new(origins: CORS::OriginType.new).call(context)
assert_cors_not_success context
end

it "supports OPTIONS request" do
context = cors_context("OPTIONS", "Origin": "example.com")
CORS.new.call(context)
assert_cors_success context
end

it "matches regex :origin settings" do
context = cors_context("GET", "Origin": "http://192.168.0.1:3000")
origins = CORS::OriginType.new
origins << %r(192\.168\.0\.1)
CORS.new(origins: origins).call(context)
assert_cors_success(context)
end

it "does not return CORS headers if origins is empty" do
context = cors_context("GET", "Origin": "http://localhost:3000")
CORS.new(origins: CORS::OriginType.new).call(context)
assert_cors_not_success context
end

it "supports alternative X-Origin header" do
context = cors_context("GET", "X-Origin": "http://localhost:3000")
CORS.new.call(context)
assert_cors_success(context)
end

it "supports expose header configuration" do
expose_header = %w(X-Expose)
context = cors_context("GET", "X-Origin": "http://localhost:3000")
CORS.new(expose_headers: expose_header).call(context)
context.response.headers[Amber::Pipe::Headers::ALLOW_EXPOSE].should eq expose_header.join(",")
end

it "supports expose multiple header configuration" do
expose_header = %w(X-Example X-Another)
context = cors_context("GET", "X-Origin": "http://localhost:3000")
CORS.new(expose_headers: expose_header).call(context)
context.response.headers[Amber::Pipe::Headers::ALLOW_EXPOSE].should eq expose_header.join(",")
end

it "adds vary header when origin is other than (*)" do
origins = CORS::OriginType.new
origins << "example.com"
context = cors_context("GET", "Origin": "example.com")
CORS.new(origins: origins).call(context)
context.response.headers[Amber::Pipe::Headers::VARY].should eq "Origin"
end

it "does not add vary header when origin is (*)" do
origins = CORS::OriginType.new
origins << "*"
context = cors_context("GET", "Origin": "*")
CORS.new(origins: origins).call(context)
context.response.headers[Amber::Pipe::Headers::VARY]?.should be_nil
end

it "adds Vary header based on :vary option" do
origins = CORS::OriginType.new
origins << "example.com"
context = cors_context("GET", "Origin": "example.com")
CORS.new(origins: origins, vary: "Other").call(context)
context.response.headers[Amber::Pipe::Headers::VARY].should eq "Origin,Other"
end

it "sets allow credential headers if credential settings is true" do
origins = CORS::OriginType.new
origins << "example.com"
context = cors_context("GET", "Origin": "example.com")
CORS.new(credentials: true, origins: origins, vary: "Other").call(context)
context.response.headers[Amber::Pipe::Headers::ALLOW_CREDENTIALS].should eq "true"
end

context "when preflight request" do
it "process valid preflight request" do
origins = CORS::OriginType.new
origins << "example.com"
context = cors_context(
"OPTIONS",
"Origin": "example.com",
"Access-Control-Request-Method": "PUT",
"Access-Control-Request-Headers": "Accept"
)
CORS.new(origins: origins).call(context)

context.response.status_code = 200
context.response.headers["Content-Length"].should eq "0"
end
end
end
end

def cors_context(method = "GET", **args)
headers = HTTP::Headers.new
args.each do |k, v|
headers[k.to_s] = v
end
request = HTTP::Request.new(method, "/", headers)
create_context(request)
end

def assert_cors_success(context)
origin_header = context.response.headers["Access-Control-Allow-Origin"]?
origin_header.should_not be_nil
end

def assert_cors_not_success(context)
origin_header = context.response.headers["Access-Control-Allow-Origin"]?
origin_header.should be_nil
end
155 changes: 98 additions & 57 deletions src/amber/pipes/cors.cr
Original file line number Diff line number Diff line change
@@ -1,81 +1,122 @@
require "./base"

module Amber
module Pipe
# The CORS Handler adds support for Cross Origin Resource Sharing.
module Headers
VARY = "Vary"
ORIGIN = "Origin"
X_ORIGIN = "X-Origin"
REQUEST_METHOD = "Access-Control-Request-Method"
REQUEST_HEADERS = "Access-Control-Request-Headers"
ALLOW_EXPOSE = "Access-Control-Expose-Headers"
ALLOW_ORIGIN = "Access-Control-Allow-Origin"
ALLOW_METHOD = "Access-Control-Allow-Method"
ALLOW_HEADERS = "Access-Control-Allow-Headers"
ALLOW_CREDENTIALS = "Access-Control-Allow-Credentials"
ALLOW_MAX_AGE = "Access-Control-Max-Age"
end

class CORS < Base
property allow_origin, allow_headers, allow_methods, allow_credentials,
max_age
alias OriginType = Array(String | Regex)
ALLOW_METHODS = %w(PUT PATCH DELETE)
ALLOW_HEADERS = %w(Accept Content-type)

ALLOW_METHODS = %w(GET HEAD POST DELETE OPTIONS PUT PATCH)
ALLOW_HEADERS = %w(accept content-type)
property origins, headers, methods, credentials, max_age
@origin : Origin

def initialize(
@allow_origin = "*",
@allow_methods = ALLOW_METHODS,
@allow_headers = ALLOW_HEADERS,
@allow_credentials = false,
@max_age = 0
@origins : OriginType = ["*", %r()],
@methods = ALLOW_METHODS,
@headers = ALLOW_HEADERS,
@credentials = false,
@max_age : Int32? = 0,
@expose_headers : Array(String)? = nil,
@vary : String? = nil
)
@origin = Origin.new(origins)
end

def initialize(
@allow_origin = "*",
allow_methods : String = ALLOW_METHODS.join(", "),
allow_headers : String = ALLOW_HEADERS.join(", "),
@allow_credentials = false,
@max_age = 0
)
@allow_methods = allow_methods.strip.split /[\s,]+/
@allow_headers = allow_headers.strip.split /[\s,]+/
def call(context : HTTP::Server::Context)
if @origin.match?(context.request)
put_expose_header(context.response)
if context.request.method == "OPTIONS" &&
Preflight.request?(context, self)
end
put_response_headers(context.response)
end
end

def call(context : HTTP::Server::Context)
context.response.headers["Access-Control-Allow-Origin"] = allow_origin
private def put_response_headers(response)
response.headers[Headers::ALLOW_CREDENTIALS] = @credentials.to_s if @credentials
response.headers[Headers::ALLOW_ORIGIN] = @origin.request_origin.not_nil!
response.headers[Headers::VARY] = vary unless @origin.any?
end

# TODO: verify the actual origin matches allowed origins.
# if requested_origin = context.request.headers["Origin"]
# if allow_origins.includes? requested_origin
# end
# end
private def put_expose_header(response)
response.headers[Headers::ALLOW_EXPOSE] = @expose_headers.as(Array).join(",") if @expose_headers
end

if allow_credentials
context.response.headers["Access-Control-Allow-credentials"] = "true"
private def vary
String.build do |str|
str << Headers::ORIGIN
str << "," << @vary if @vary
end
end
end

module Preflight
extend self

if max_age > 0
context.response.headers["Access-Control-Max-Age"] = max_age.to_s
def request?(context, cors)
if valid_method?(context.request, cors.methods) &&
valid_headers?(context.request, cors.headers)
set_preflight_headers(context, cors.max_age)
end
end

# if asking permission for request method or request headers
if context.request.method.downcase == "options"
context.response.status_code = 200
response = ""

if requested_method = context.request.headers["Access-Control-Request-Method"]
if allow_methods.includes? requested_method.strip
context.response.headers["Access-Control-Allow-Methods"] = allow_methods.join(", ")
else
context.response.status_code = 403
response = "Method #{requested_method} not allowed."
end
end
def set_preflight_headers(context, max_age)
context.response.headers[Headers::ALLOW_METHOD] = context.request.headers[Headers::REQUEST_METHOD]
context.response.headers[Headers::ALLOW_HEADERS] = context.request.headers[Headers::REQUEST_HEADERS]
context.response.headers[Headers::ALLOW_MAX_AGE] = max_age.to_s if max_age
context.response.content_length = 0
context.halt!
end

if requested_headers = context.request.headers["Access-Control-Request-Headers"]
requested_headers.split(",").each do |requested_header|
if allow_headers.includes? requested_header.strip.downcase
context.response.headers["Access-Control-Allow-Headers"] = allow_headers.join(", ")
else
context.response.status_code = 403
response = "Headers #{requested_headers} not allowed."
end
end
end
def valid_method?(request, methods)
methods.includes? request.headers[Headers::REQUEST_METHOD]?
end

def valid_headers?(request, headers)
!(headers & request.headers[Headers::REQUEST_HEADERS].split(',')).empty?
end
end

context.response.content_type = "text/html; charset=utf-8"
context.response.print(response)
else
call_next(context)
struct Origin
getter request_origin : String?

def initialize(@origins : Array(String | Regex))
end

def match?(request)
return false if @origins.empty?
return false unless origin_header?(request)
return true if any?

@origins.any? do |origin|
case origin
when String then origin == request_origin
when Regex then origin =~ request_origin
end
end
end

def any?
@origins.includes? "*"
end

private def origin_header?(request)
@request_origin ||= request.headers[Headers::ORIGIN]? || request.headers[Headers::X_ORIGIN]?
end
end
end
end

0 comments on commit a53194a

Please sign in to comment.