Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Align CSRF cookie provisioning with Laravel's precedents to eliminate ActionController::InvalidAuthenticityToken edge cases #119

Conversation

jordanhiltunen
Copy link
Contributor

In June 2023, #96 implemented the following strategy for XSRF-TOKEN cookies -- issue a new cookie if:

  • The current endpoint is configured to protect against forgery (e.g. respect skip_forgery_protection).
  • The request is a non-Inertia call (e.g. it's the first request, a standard "full-page browser request, with no special Inertia headers or data" 1).

Issue

This strategy assumes that there is no need to issue the XSRF-TOKEN cookie in response to subsequent Inertia requests made by the client; this is not necessarily the case, and can lead to client applications observing unexpected ActionController::InvalidAuthenticityToken errors as a result; a simplified example:

  • Login. The user visits /login, a full-HTML, non-Inertia request.
    • inertia-rails issues an XSRF-TOKEN cookie.
  • Application Use. The user authenticates and uses the application.
    • All of these requests are Inertia calls; e.g. they are made via XHR with the X-Inertia header and the X-XSRF-TOKEN set by Axios, all using the XSRF-TOKEN issued by inertia-rails in response to the very first request.
  • Logout. The user logs out, triggering DELETE /logout.
    • Let's suppose the logout implementation includes session.clear. In addition to anything else in the session, this also purges the CSRF token stored in the session that was issued during the very first non-Inertia request.
    • ⚠️ The backend replies to DELETE /logout, an Inertia request like all of the others that preceded it -- it does not issue a new XSRF-TOKEN cookie.
  • Reauthentication Attempt. After logging out, the user later triggers a form submission to login again on the same page.
    • This, too, is an Inertia call. Because no new cookie was issued during logout, it still uses the same original X-XSRF-TOKEN that was issued during the user's very first request.
    • 💥 The login attempt throws an ActionController::InvalidAuthenticityToken. This will happen for any request eligible for CSRF protection until the user hard-reloads the page, triggering another initial non-Inertia request that will issue them a new cookie.

Relevant Laravel CSRF Precedents

As it relates to CSRF protection, the interaction between inertia-js & inertia-rails differs from the precedent established by Laravel & inertia-js in one significant way: they issue a new XSRF-TOKEN for every request, preventing this kind of edge case:

Laravel stores the current CSRF token in an encrypted XSRF-TOKEN cookie that is included with each response generated by the framework.
https://laravel.com/docs/10.x/csrf#csrf-x-xsrf-token

Implementation: https://github.com/laravel/framework/blob/e0be50a7b770c46b522377cb46318e06e312014e/src/Illuminate/Foundation/Http/Middleware/VerifyCsrfToken.php#L41-L86

This PR proposes that inertia-rails follow Laravel's precedent, and issue an XSRF-TOKEN cookie on every request, regardless of whether or not the request is an Inertia call.

Thank you so much for your work on this fantastic gem!

Comment on lines +135 to +155
it 'sets the XSRF-TOKEN cookie after the session is cleared during an inertia call' do
with_forgery_protection do
get initialize_session_path
expect(response).to have_http_status(:ok)
initial_xsrf_token_cookie = response.cookies['XSRF-TOKEN']

post submit_form_to_test_csrf_path, headers: { 'X-Inertia' => true, 'X-XSRF-Token' => initial_xsrf_token_cookie }
expect(response).to have_http_status(:ok)

delete clear_session_path, headers: { 'X-Inertia' => true, 'X-XSRF-Token' => initial_xsrf_token_cookie }
expect(response).to have_http_status(:see_other)
expect(response.headers['Location']).to eq('http://www.example.com/initialize_session')

post_logout_xsrf_token_cookie = response.cookies['XSRF-TOKEN']
expect(post_logout_xsrf_token_cookie).not_to be_nil
expect(post_logout_xsrf_token_cookie).not_to eq(initial_xsrf_token_cookie)

post submit_form_to_test_csrf_path, headers: { 'X-Inertia' => true, 'X-XSRF-Token' => post_logout_xsrf_token_cookie }
expect(response).to have_http_status(:ok)
end
end
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I included this spec mostly just to demonstrate the kind of stateful, multi-request scenario that can lead to avoidable ActionController::InvalidAuthenticityToken errors, but it seems out of place here / overly-specific: I'm happy to remove it (and the supporting routes / dummy controllers). The core change is captured in the revised expectation in L116.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, I definitely want to keep this! While L116 test the behavior, this spells out the "why". Something Future Us may appreciate.

cookies['XSRF-TOKEN'] = form_authenticity_token unless request.inertia? || !protect_against_forgery?
cookies['XSRF-TOKEN'] = form_authenticity_token unless !protect_against_forgery?
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bknoles
Copy link
Collaborator

bknoles commented May 27, 2024

Hey @jordanhiltunen ! Thanks for the super clear writeup. I'm going to reword it for my own benefit:

InertiaRails has zero-config CSRF handling. It leverages Axios' default behavior to copy data from an XSRF-TOKEN cookie (if present) into an X-XSRF-Token header that is sent with every request. The library sets this cookie in an after_action callback. Currently, it only does this on a full page reload (and specifically not Inertia requests). This mimics the original manual set up that the library used to have, where a piece of JavaScript would run on the initial page load to copy the Rails csrf meta tag value into the axios instance defaults.

However! Sometimes we may reset the session without fulling reloading the page. The (obvious in retrospect) example is logging out via Inertia requests. In that situation, the user is forced to manually hard refresh the page in order to send any further CSRF protected requests. The fix is to simply copy set the XSRF-TOKEN cookie on every request instead of just on the first page load. There's no real downside, as this is basically the same thing that Rails does when it renders form authenticity tokens.

I checked out the branch and played around and this looks good to me! I'll target a release this week, alongside #111 (after I spend some more time with it)

Comment on lines +135 to +155
it 'sets the XSRF-TOKEN cookie after the session is cleared during an inertia call' do
with_forgery_protection do
get initialize_session_path
expect(response).to have_http_status(:ok)
initial_xsrf_token_cookie = response.cookies['XSRF-TOKEN']

post submit_form_to_test_csrf_path, headers: { 'X-Inertia' => true, 'X-XSRF-Token' => initial_xsrf_token_cookie }
expect(response).to have_http_status(:ok)

delete clear_session_path, headers: { 'X-Inertia' => true, 'X-XSRF-Token' => initial_xsrf_token_cookie }
expect(response).to have_http_status(:see_other)
expect(response.headers['Location']).to eq('http://www.example.com/initialize_session')

post_logout_xsrf_token_cookie = response.cookies['XSRF-TOKEN']
expect(post_logout_xsrf_token_cookie).not_to be_nil
expect(post_logout_xsrf_token_cookie).not_to eq(initial_xsrf_token_cookie)

post submit_form_to_test_csrf_path, headers: { 'X-Inertia' => true, 'X-XSRF-Token' => post_logout_xsrf_token_cookie }
expect(response).to have_http_status(:ok)
end
end
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, I definitely want to keep this! While L116 test the behavior, this spells out the "why". Something Future Us may appreciate.

@bknoles bknoles merged commit 6aa0edf into inertiajs:master May 27, 2024
9 checks passed
@bknoles
Copy link
Collaborator

bknoles commented May 31, 2024

Quick update: I'll release this once we get #111 merged in as well.

@bknoles
Copy link
Collaborator

bknoles commented Jun 19, 2024

Released as part of 3.2.0!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants