LaunchKit
← All posts
· 5 min read · by The LaunchKit team · 3 views

CORS in Rails isn't a security control

Somebody opens a ticket saying the API is blocked by CORS, you install rack-cors, the error goes away, and a belief forms that you've added a protection. You haven't. The measurements below take about five minutes to run and they change how you think about the next CORS ticket.

All of it ran on Rails 8.1.4, in a rails new --minimal app with one ActionController::API controller.

Without rack-cors, the server doesn't notice

get "/things", headers: { "HTTP_ORIGIN" => "https://other.example" }
# status                 200
# access-control headers {}

A cross origin GET arrives, Rails serves it, and there's not a single access-control-* header in the response. The Origin header is just a header. No middleware in a default Rails app reads it, and nothing about the request is treated differently.

The browser is what's different. It made the request, got a response with no access-control-allow-origin on it, and refused to hand the body to the JavaScript that asked. Your server already did the work and already sent the bytes.

The preflight is the error you're actually seeing

process :options, "/things",
        headers: { "HTTP_ORIGIN" => "https://other.example",
                   "HTTP_ACCESS_CONTROL_REQUEST_METHOD" => "POST" }
# status  404

For anything that isn't a simple request, the browser sends an OPTIONS first and waits for permission. Rails has no route for OPTIONS /things, so it answers 404, the browser treats that as a refusal, and the real request never leaves. That 404 is what most CORS tickets are underneath.

Install rack-cors and the same preflight answers properly:

Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins "https://trusted.example"
    resource "/things", headers: :any, methods: [ :get, :post, :options ], credentials: true
  end
end

Now the same preflight answers 200 and carries the four headers the browser was waiting for: access-control-allow-origin: https://trusted.example, access-control-allow-methods: GET, POST, OPTIONS, access-control-allow-credentials: true and access-control-max-age: 7200.

That last one is worth knowing about: the browser caches the permission for two hours, so a change to your CORS config won't show up in a browser that already asked. Clearing it means a hard reload or a different origin, and a good half of "I changed the config and nothing happened" is this.

A disallowed origin runs your code anyway

Here's the measurement that matters.

get "/things", headers: { "HTTP_ORIGIN" => "https://evil.example" }
# status                 200
# access-control headers {}
# body                   {"ok":true}

https://evil.example isn't on the allow list. rack-cors did its job: it withheld the headers. And the action ran, built the JSON, and sent it. The only thing standing between that body and the attacker's script is the attacker's own browser choosing to discard it.

Say that back slowly, because the consequence is the whole article. If the request has a side effect, the side effect happened. A cross origin POST that charges a card charges the card. CORS decides whether a script may read your response, and nothing else.

Two requests to the same endpoint, one from an allowed origin and one from a disallowed one, traced through the same four stages. Both reach Rails, both run the controller action, and both produce a 200 with the real JSON body. The only difference appears at the last stage: the allowed origin's response carries access-control-allow-origin and the browser hands the body to the script, while the disallowed origin's response carries no CORS headers and the browser discards a body that was already built and already sent. A caption notes that the side effect happened in both lanes.

Which is why the form POST slips through

post "/things", headers: { "HTTP_ORIGIN" => "https://other.example" }, params: { a: 1 }
# status  200
# body    {"created":true}

No rack-cors, no preflight, no permission asked. A form encoded POST is a simple request: the browser considers it something an HTML form could have done before CORS existed, so it sends it without checking first. The response gets withheld from the script, and the record gets created.

That's not a gap in CORS, it's the design. CORS was retrofitted onto a web where cross site form submissions already worked, and it was never allowed to break them.

The thing that stops that POST is CSRF protection, and whether you have any depends on a class name. ActionController::Base enables it by default. ActionController::API doesn't respond to protect_from_forgery at all, which is the right default for a token authenticated API and the wrong one the moment that API also accepts a session cookie. If both are true of your app, that's the hole, and Can't verify CSRF token authenticity is the other half of this story.

One thing rack-cors refuses to do

allow { origins "*"; resource "*", credentials: true }
# Rack::Cors::Resource::CorsMisconfigurationError:
#   Allowing credentials for wildcard origins is insecure.

It raises at boot rather than shipping it. A wildcard origin with credentials would mean any site on the internet could make authenticated requests as your signed-in user and read the replies, which is the one combination browsers also refuse. Good library.

If you reached for origins "*" to make an error go away, that exception is telling you the honest fix is to name the origins.

What this page doesn't cover

No browser. Everything here came from integration tests, which means the enforcement half of CORS, the part a browser does, is described rather than demonstrated. If you want to watch it happen, the network tab is a better tool than a test suite.

access-control-expose-headers came back empty above and never got exercised, which matters if your client reads a pagination or rate limit header off the response: by default it can't see any header you don't name there. Nothing here covers private network access, the Vary: Origin header you want when a cached response differs per origin, or what a CDN does to all of this.

#rails #http #security

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.