Cloudfront CORS issue serving fonts on Rails application Cloudfront CORS issue serving fonts on Rails application heroku heroku

Cloudfront CORS issue serving fonts on Rails application


This was an incredibly difficult issue to deal with, for two reasons:

  1. The fact that CloudFront is mirroring our Rails app’s response headers requires you to twist your mind around. The CORS protocol is hard enough to understand as it is, but now you have to follow it at two levels: between the browser and CloudFront (when our Rails app uses it as a CDN), and between the browser and our Rails app (when some malicious site wants to abuse us).

    CORS is really about a dialog between the browser and the 3rd-party resources a web page wants to access. (In our use-case, that’s the CloudFront CDN, serving assets for our app.) But since CloudFront gets its Access-Control response headers from our app, our app needs to serve those headers as if it is CloudFront talking, and simultaneously not grant permissions that would expose itself to the type of abuse that led to the Same-Origin Policy / CORS being developed in the first place. In particular, we should not grant * access to * resources on our site.

  2. I found so much outdated information out there -- an endless line of blog posts and SO threads. CloudFront has improved its CORS support significantly since many of those posts, although it is still not perfect. (CORS should really be handled out-of-the-box.) And the gems themselves have evolved.

My setup: Rails 4.1.15 running on Heroku, with assets served from CloudFront. My app responds to both http and https, on both "www." and the zone apex, without doing any redirection.

I looked briefly at the font_assets gem mentioned in the question, but quickly dropped it in favor of rack-cors, which seemed more on point. I did not want to simply open up all origins and all paths, as that would defeat the point of CORS and the security of the Same-Origin Policy, so I needed to be able to specify the few origins I would allow. Finally, I personally favor configuring Rails via individual config/initializers/*.rb files rather than editing the standard config files (like config.ru or config/application.rb) Putting all that together, here is my solution, which I believe is the best available, as of 2016-04-16:

  1. Gemfile

    gem "rack-cors"

    The rack-cors gem implements the CORS protocol in a Rack middleware.In addition to setting Access-Control-Allow-Origin and related headers on approved origins, it adds a Vary: Origin response header, directing CloudFront to cache the responses (including the response headers) for each origin separately. This is crucial when our site is accessible via multiple origins (e.g. via both http and https, and via both "www." and the bare domain)

  2. config/initializers/rack-cors.rb

    ## Configure Rack CORS Middleware, so that CloudFront can serve our assets.## See https://github.com/cyu/rack-corsif defined? Rack::Cors    Rails.configuration.middleware.insert_before 0, Rack::Cors do        allow do            origins %w[                https://example.com                 http://example.com                https://www.example.com                 http://www.example.com                https://example-staging.herokuapp.com                 http://example-staging.herokuapp.com            ]            resource '/assets/*'        end    endend

    This tells the browser that it may access resources on our Rails app (and by extension, on CloudFront, since it is mirroring us) only on behalf of our Rails app (and not on behalf of malicious-site.com) and only for /assets/ urls (and not for our controllers). In other words, allow CloudFront to serve assets but don't open the door any more than we have to.

    Notes:

    • I tried inserting this after rack-timeout instead of at the head of the middleware chain.It worked on dev but was not kicking in on Heroku, despitehaving the same middleware (other than Honeybadger).
    • The origins list could also be done as Regexps.Be careful to anchor patterns at the end-of-string.

      origins [    /\Ahttps?:\/\/(www\.)?example\.com\z/,    /\Ahttps?:\/\/example-staging\.herokuapp\.com\z/]

      but I think it’s easier just to read literal strings.

  3. Configure CloudFront to pass the browser's Origin request header on to our Rails app.

    Strangely, it appears that CloudFront forwards the Origin header from the browser to our Rails app regardless whether we add it here, but that CloudFront honors our app’s Vary: Origin caching directive only if Origin is explicitly added to the headers whitelist (as of April 2016).

    The request header whitelist is kind of buried.

    If the distribution already exists, you can find it at:


    If you have not created the distribution yet, create it at:

    • https://console.aws.amazon.com/cloudfront/home#distributions
    • Click Create Distribution

      (For the sake of completeness and reproducibility, I'm listing all the settings I changed from the defaults, however the Whitelist settings are the only ones that are relevant to this discussion)

    • Delivery Method: Web (not RTMP)

    • Origin Settings

      • Origin Domain Name: example.com
      • Origin SSL Protocols: TLSv1.2 ONLY
      • Origin Protocol Policy: HTTPS only
    • Default Cache Behavior Settings

      • Viewer Protocol Policy: Redirect HTTP to HTTPS
      • Forward Headers: Whitelist
      • Whitelist Headers: Select Origin and click Add >>
      • Compress Objects Automatically: Yes

After changing all these things, remember that it can take some time for any old, cached values to expire from CloudFront. You can explicitly invalidate cached assets by going to the CloudFront distribution's Invalidations tab and creating an invalidation for *.


If you run Rails on Passenger and Heroku: (if not, jump straight to Noach Magedman's answer)

Noach Magedman's answer was very useful for me to set up CloudFront properly.

I also installed rack-cors exactly as described and whilst it worked fine in development, the CURL commands in production never returned any of the CORS configurations:

curl -H "Origin: https://tidyme-staging.com.au" -I http://tidyme-staging.com.au/assets/31907B_4_0-588bd4e720d4008295dcfb85ef36b233ee0817d7fe23c76a3a543ebba8e7c85a.ttfHTTP/1.1 200 OKConnection: keep-aliveServer: nginx/1.10.0Date: Wed, 03 Aug 2016 00:29:37 GMTContent-Type: application/x-font-ttfContent-Length: 316664Last-Modified: Fri, 22 Jul 2016 03:31:57 GMTExpires: Thu, 31 Dec 2037 23:55:55 GMTCache-Control: max-age=315360000Cache-Control: publicAccept-Ranges: bytesVia: 1.1 vegur

Note that I ping the server directly without going through the CDN, the CDN then after invalidating all content should just forward whatever the server responds. The important line here is Server: nginx/1.10.0, which indicates that assets are served by nginx and not Rails. As a consequence, the rack-cors configurations do not apply.

The solution that worked for us is here: http://monksealsoftware.com/ruby-on-rails-cors-heroku-passenger-5-0-28/

It basically involved cloning and modifying the nginx config file for Passenger, which is not ideal since this copy needs to be maintained every time Passenger gets upgraded and the template changes.

===

Here's a summary:

Navigate to the root folder of your Rails project and make a copy of the nginx config template

cp $(passenger-config about resourcesdir)/templates/standalone/config.erb config/passenger_config.erb

Open config/passenger_config.erb and comment this line out

<%# include_passenger_internal_template('rails_asset_pipeline.erb', 8, false) %>

Add these configurations below the line mentioned above

### BEGIN your own configuration options #### This is a good place to put your own config# options. Note that your options must not# conflict with the ones Passenger already sets.# Learn more at:# https://www.phusionpassenger.com/library/config/standalone/intro.html#nginx-configuration-templatelocation ~ "^/assets/.+\.(woff|eot|svg|ttf|otf).*" {    error_page 490 = @static_asset_fonts;    error_page 491 = @dynamic_request;    recursive_error_pages on;    if (-f $request_filename) {        return 490;    }    if (!-f $request_filename) {        return 491;    }}# Rails asset pipeline support.location ~ "^/assets/.+-([0-9a-f]{32}|[0-9a-f]{64})\..+" {    error_page 490 = @static_asset;    error_page 491 = @dynamic_request;    recursive_error_pages on;    if (-f $request_filename) {        return 490;    }    if (!-f $request_filename) {        return 491;    }}location @static_asset {    gzip_static on;    expires max;    add_header Cache-Control public;    add_header ETag "";}location @static_asset_fonts {    gzip_static on;    expires max;    add_header Cache-Control public;    add_header ETag "";    add_header 'Access-Control-Allow-Origin' '*';    add_header 'Access-Control-Allow-Methods' 'GET, HEAD, OPTIONS';    add_header 'Access-Control-Allow-Headers' '*';    add_header 'Access-Control-Max-Age' 3628800;}location @dynamic_request {    passenger_enabled on;}### END your own configuration options ###

Change the Procfile to include this custom config file

web: bundle exec passenger start -p $PORT --max-pool-size 2 --nginx-config-template ./config/passenger_config.erb

Then deploy...

===

If you know of a better solution, please put in the comments.

After implementing, the CURL command yielded the following response:

curl -H "Origin: https://tidyme-staging.com.au" -I http://tidyme-staging.com.au/assets/31907B_4_0-588bd4e720d4008295dcfb85ef36b233ee0817d7fe23c76a3a543ebba8e7c85a.ttfHTTP/1.1 200 OKConnection: keep-aliveServer: nginx/1.10.0Date: Wed, 03 Aug 2016 01:43:48 GMTContent-Type: application/x-font-ttfContent-Length: 316664Last-Modified: Fri, 22 Jul 2016 03:31:57 GMTExpires: Thu, 31 Dec 2037 23:55:55 GMTCache-Control: max-age=315360000Cache-Control: publicAccess-Control-Allow-Origin: *Access-Control-Allow-Methods: GET, HEAD, OPTIONSAccess-Control-Allow-Headers: *Access-Control-Max-Age: 3628800Accept-Ranges: bytesVia: 1.1 vegur


As of version 5.0, Rails allows for setting custom HTTP Headers for assets and you don't have to use the rack-cors or font-assets gems. In order to set Access-Control-Allow-Origin for assets (including fonts), just add the following code to config/environments/production.rb:

config.public_file_server.headers = {  'Access-Control-Allow-Origin' => '*'}

The header value could also be a specific domain, like the following:

config.public_file_server.headers = {  'Access-Control-Allow-Origin' => 'https://www.example.org'}

This worked for my app and I didn't need to change any settings on Cloudfront.