Apologies for the necro-post but I have recently had this exact issue and none of the above suggestions correctly addressed my issue. Arnold's post is a potential problem as well but wasn't my issue and I already had a secret provided. I also couldn't find this issue documented anywhere any of my Google searches would find (but I did find this question), so hopefully can help those coming after me.
EDIT: on re-reading the entire page I can see Yuval is referring to exactly the same issue. I really wish I'd noticed this comment earlier:) Anyway I believe my answer still helps in giving a bit more context and how to fix things
This has been bit painful and given others above are asking why? I'm going to document what the root problem was and how it was caused. However, for the impatient amongst you: tl;dr you need to add the nginx directive proxy_set_header Origin http://$host;
inside the location block for the app routes. The virtual host file should look something like this:
upstream puma_example {
server unix://var/www/.../puma.sock fail_timeout=0;
}
server {
server_name example.com;
root /var/www/example.com/.../public;
access_log /var/www/example.com/.../nginx-access.log;
location / {
try_files $uri @app;
}
location @app {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_set_header Origin http://$host;
proxy_pass http://puma_example;
}
Adapted from this page
Now for the why!
Primary explanation
Sinatra uses a sub-library called rack-protection. This protects your project against a lot of the varieties of internet badness. There's a base class that does most of the work and various subclasses for each type of check done. The one we're interested in is HTTP Origin.
The subclass overrides the accepts?
method, as in fact do all the subclasses for the different attack types. This method (in this subclass) is called to determine whether the request is safe from a Cross-Site Request Forgery. The method will return true
if the request is good, and false
if it's a CSRF problem
The first line of the accepts?
method calls the safe?
method from the base class. Now we can see why GET requests sail through without an issue: if the request method is one of GET
, HEAD
, OPTIONS
or TRACE
then the request is marked as safe and the CSRF check evaluates to true.
But that doesn't cover POST
requests, so we have to check the rest of accepts?
method to see what other checks are applied. There are two further ways we can get the check to evaluate to true:
- Make sure we have the internet header
HTTP_ORIGIN
in our request, with the same value as base_url(env)
- Make sure we have the internet header
HTTP_ORIGIN
in our request & have the origin listed as one of the permitted origins
I went with option 1, not least because messing around with permitted options would have meant moving away from the default Sinatra sessions (I think!). As it turned out, I didn't have HTTP_ORIGIN set at all in any of my requests anyway, which would need to be solved for route 2. However by simply adding the proxy_set_header directive noted above, it does get set.
Don't forget to reboot Nginx service after you've made config changes!
N.B. Nginx prepends the HTTP_
part & upcases it. That's why I get away with calling it Origin
in the directive
More detailed explanation
If you've not had enough fun geeking out github repositories yet, there's also the exact means by which accepts?
method evaluating to false
then de-rails your app:
- The protection is initialised in the
Base Sinatra class
. The default reaction is set to :drop_session
towards the end of this method
- When the protection is called, it check the
accepts?
method discussed above
- When false (because of the missing headers already discussed), the
react
method is called
react
, in turn calls the reaction, which was set to :drop_session
when the protection was initialised
drop_session
calls session.clear
, which takes us into rack proper. Unfortunately rack has recently been refactored to remove sessions from the main code base, and move it to rack-session, but my version of Sinatra is still relying on 2.2.4 of rack so we need to look at old commit files
- The
clear
method can be found in the session/abstract/id.rb file
clear
calls load_for_write!
load_for_write!
calls load!
load!
calls load_session
load!
takes the decoded session data & sets it to the @data
class variable
load!
finally sets the class variable @loaded
to true
, which ultimately stops subsequent load_for_read!
& load_for_write!
calls from loading the data from the session cookie again
clear
then empties the class internal variable @data
, but crucially leaves @loaded
set to true
Therefore after a session.clear
call we have a session object that is loaded (so won't ever reload data from the session cookie) but has no data in it.
...and this is why it looks like POST requests are dropping the session cookie. The session cookie was getting to Rack / Sinatra all along. However with the wrong headers the session data was read in, and then wiped, and then set so it would never re-read in the data.
:key => "rack.session", :path => "/backend"
which isn't required). – Discuss