Skip to content

Commit

Permalink
Allow for dynamic hosts in CORS origin config (#409)
Browse files Browse the repository at this point in the history
## Status

- Closes #405

## What's changed?

This extends the current origin-setting functionality (literal origins
set via a comma-separated environment variable) and allows the env var
string to also contain regular expressions. These allow the origins to
match dynamic patterns (eg. `https://*.projects-ui.pages.dev`) and
ensures that configuration still remains in the environment as we don't
want to assume that anyone running this application will want the same
allowed origins configured as us (other than for `localhost` for local
and test environments only).

eg. instead of currently having to set the following in
`ALLOWED_ORIGINS`:

```
https://foo.raspberrypi.org, https://bar.raspberrypi.org, https://foo.editor-standalone-eyq.pages.dev, https://bar.editor-standalone-eyq.pages.dev
```

The following can be set:

```
/https:\/\/(?:[a-z0-9-]+\.)?raspberrypi\.org$/, /https:\/\/.+\.editor-standalone-eyq\.pages\.dev$/
```

Associated updates in Terraform allow for the origins to match the spec
in the
[issue](#405)
(eg. the addition of wildcards / regex matching):

* RaspberryPiFoundation/terraform#898
  • Loading branch information
grega authored Aug 21, 2024
1 parent 406c81a commit c0fd6b3
Show file tree
Hide file tree
Showing 7 changed files with 92 additions and 23 deletions.
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
ALLOWED_ORIGINS=localhost:3000,localhost:3002,localhost:3009,localhost:3010,localhost:3012,editor.rpfdev.com
# localhost is set as an origin by default in development (config/initializers/cors.rb)
# so you probably only need to set ALLOWED_ORIGINS for debugging purposes
ALLOWED_ORIGINS=""

AWS_ACCESS_KEY_ID=changeme
AWS_S3_ACTIVE_STORAGE_BUCKET=changeme
Expand Down
6 changes: 1 addition & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,11 +127,7 @@ docker-compose run api rspec spec/path/to/spec.rb

### CORS Allowed Origins

Add a comma separated list to the relevant enviroment settings. E.g for development in the `.env` file:

```
ALLOWED_ORIGINS=localhost:3002,localhost:3000
```
Handled in `config/initializers/cors.rb`.

### Webhooks

Expand Down
31 changes: 17 additions & 14 deletions config/initializers/cors.rb
Original file line number Diff line number Diff line change
@@ -1,24 +1,27 @@
# frozen_string_literal: true

# Be sure to restart your server when you modify this file.
# Read more: https://github.com/cyu/rack-cors

origins_array = ENV['ALLOWED_ORIGINS']&.split(',')&.map(&:strip) || []
# Handle Cross-Origin Resource Sharing (CORS) in order to accept cross-origin Ajax requests.
require Rails.root.join('lib/origin_parser')

Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins origins_array
resource '*', headers: :any, methods: %i[get post patch put delete], expose: ['Link']
# localhost and test domain origins
origins(%r{https?://localhost([:0-9]*)$}) if Rails.env.development? || Rails.env.test?

standard_cors_options
end

allow do
# environment-specific origins set through ALLOWED_ORIGINS env var
# should only be necessary for staging / production environments (see above for local and test)
origins OriginParser.parse_origins

standard_cors_options
end
end
# Read more: https://github.com/cyu/rack-cors

# Rails.application.config.middleware.insert_before 0, Rack::Cors do
# allow do
# origins "example.com"
#
# resource "*",
# headers: :any,
# methods: [:get, :post, :put, :patch, :delete, :options, :head]
# end
# end
def standard_cors_options
resource '*', headers: :any, methods: %i[get post patch put delete], expose: ['Link']
end
8 changes: 6 additions & 2 deletions lib/corp_middleware.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# frozen_string_literal: true

require_relative 'origin_parser'

class CorpMiddleware
def initialize(app)
@app = app
Expand All @@ -8,9 +10,11 @@ def initialize(app)
def call(env)
status, headers, response = @app.call(env)
request_origin = env['HTTP_HOST']
allowed_origins = ENV['ALLOWED_ORIGINS']&.split(',')&.map(&:strip) || []
allowed_origins = OriginParser.parse_origins

if env['PATH_INFO'].start_with?('/rails/active_storage') && allowed_origins.include?(request_origin)
if env['PATH_INFO'].start_with?('/rails/active_storage') && allowed_origins.any? do |origin|
origin.is_a?(Regexp) ? origin =~ request_origin : origin == request_origin
end
headers['Cross-Origin-Resource-Policy'] = 'cross-origin'
end

Expand Down
17 changes: 17 additions & 0 deletions lib/origin_parser.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# frozen_string_literal: true

# fetch origins from the environment in a comma-separated string
# these can be literal strings or regexes
# regexes must be wrapped in forward slashes eg. /https?:\/\/localhost(:[0-9]*)?$/
module OriginParser
def self.parse_origins
ENV['ALLOWED_ORIGINS']&.split(',')&.map do |origin|
stripped_origin = origin.strip
if stripped_origin.start_with?('/') && stripped_origin.end_with?('/')
Regexp.new(stripped_origin[1..-2])
else
stripped_origin
end
end || []
end
end
10 changes: 9 additions & 1 deletion spec/lib/corp_middleware_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,15 @@
allow(ENV).to receive(:[]).with('ALLOWED_ORIGINS').and_return('test.com')
end

it 'sets the Cross-Origin-Resource-Policy header for allowed origins' do
it 'sets the Cross-Origin-Resource-Policy header for a literal origin' do
_status, headers, _response = middleware.call(env)

expect(headers['Cross-Origin-Resource-Policy']).to eq('cross-origin')
end

it 'sets the Cross-Origin-Resource-Policy header for regex origin' do
allow(ENV).to receive(:[]).with('ALLOWED_ORIGINS').and_return('/test\.com/')

_status, headers, _response = middleware.call(env)

expect(headers['Cross-Origin-Resource-Policy']).to eq('cross-origin')
Expand Down
39 changes: 39 additions & 0 deletions spec/lib/origin_parser_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe OriginParser do
describe '.parse_origins' do
after { ENV['ALLOWED_ORIGINS'] = nil }

it 'returns an empty array if ALLOWED_ORIGINS is not set' do
ENV['ALLOWED_ORIGINS'] = nil
expect(described_class.parse_origins).to eq([])
end

it 'parses literal strings correctly' do
ENV['ALLOWED_ORIGINS'] = 'http://example.com, https://example.org'
expect(described_class.parse_origins).to eq(['http://example.com', 'https://example.org'])
end

it 'parses regexes correctly' do
ENV['ALLOWED_ORIGINS'] = '/https?:\/\/example\.com/'
expect(described_class.parse_origins).to eq([Regexp.new('https?:\/\/example\.com')])
end

it 'parses a mix of literals and regexes' do
ENV['ALLOWED_ORIGINS'] = 'http://example.com, /https?:\/\/localhost$/'
expect(described_class.parse_origins).to eq(['http://example.com', Regexp.new('https?:\/\/localhost$')])
end

it 'strips whitespace from origins' do
ENV['ALLOWED_ORIGINS'] = ' http://example.com , /regex$/ '
expect(described_class.parse_origins).to eq(['http://example.com', Regexp.new('regex$')])
end

it 'returns an empty array if ALLOWED_ORIGINS is empty' do
ENV['ALLOWED_ORIGINS'] = ''
expect(described_class.parse_origins).to eq([])
end
end
end

0 comments on commit c0fd6b3

Please sign in to comment.