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.
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.
Comments
No comments yet. Be the first.