Make it Secure 3, CSRF for GraphQL on Rails

Published: Mar 29, 2023

This is the third post about securing Rails GraphQL API app. The topic is CSRF protection for GraphQL API. CSRF is an acronym of Cross-Site Request Forgery, and one of well-known vulnerabilities and a type of session hijack.

If an attacker succeeds to hijack session, the request from the attacker looks a proper one from a victim. The attacker is able to execute state changing operations using victim’s identity.

According to the document, Securing Rails Applications:

CSRF appears very rarely in CVE (Common Vulnerabilities and Exposures) - less than 0.1% in 2006 - but it really is a ‘sleeping giant’ [Grossman]. This is in stark contrast to the results in many security contract works - CSRF is an important security issue.

Above being said, we should think about CSRF protection.

GraphQL needs CSRF protection?

OK, we had a basic idea of CSRF attack. Then, the question is whether GraphQL API needs CSRF protection or not. If it is truly API only app, the app doesn’t use a cookie nor session at all. There should be no session to be hijacked… Well, developers might have a vague belief that there should be no session skipping actual HTTP response headers checks.

Some GraphQL clients, such as GraphiQL, don’t handle HTTP request/response headers well. People might miss what are passed in HTTP response headers. In contract, Insomnia (https://insomnia.rest/) or Postman (https://www.postman.com/) does a good job. Postman’s handling is awesome. By default, Postman adds meaningful HTTP response headers to HTTP request header automatically. Besides, those are controllable. We can choose what should be sent back with GraphQL request. Postman is famous for REST API client, however, GraphQL support is also good.

Once we check HTTP response headers returned with GraphQL query result, cookie and/or session might be found. For example, the previous post, Make it Secure 2, GraphQL by Rails and Devise uses Devise for authentication. To make it work, cookie and session setup was added to config/application.rb since Devise needs that. The Rails app was created with --api option, so the addition of cookie and session was done manually. We knew that GraphQL response would come back with the session. If GraphQL API app is created without --api option, the cookie and sessions might come back with GraphQL response unknowingly. The blog post, What’s Old Becomes New Again: CSRF Attacks on GraphQL APIs, mentions “misconfiguration,” which would include the case of “unknowingly.”

The answer to the question, GraphQL needs CSRF protection?, depends on what gems are used.

CSRF Protection Setup for GraphQL

Rails provides CSRF protection out of the box, however, it is based on traditional web application. The meta tag is used to embed CSRF token. It uses flash UI when the session nullify option is used. API only, GraphQL app needs tweaks to make it work.

How Rails CSRF Protection Works

Before moving on to the code, let’s revisit how traditional Rails app does for CSRF protection.

  1. Rails app creates CSRF token
  2. Rails app ties the CSRF token to a session
  3. Rails app passes the CSRF token to a client using HTML meta tag with the session
  4. A client sends back the CSRF token using X-CSRF-TOKEN HTTP request header with the session
  5. Rails app compares the CSRF token returned as X-CSRF-TOKEN and tokens in the session
  6. Rails app does what protect_from_forgery specifies if the token is failed to verify

Among the steps above, API only app should do something else for step 3 and 6. In general, the cookie is used to return a pair of CSRF-TOKEN as key and the token as value. Using the cookie might be controversial in terms of better security. Another way is to return the token as a part of GraphQL login/register mutation response body. This might be better than the cookie, however, other queries and mutations lose a chance to get updated token. It is recommended that the CSRF token should be updated in every interaction to the server. Given that, using cookie would be an agreeable solution.

When the CSRF token verification fails, three below protect_from_forgery strategies are possible behaviors.

  • :exception – Raises ActionController::InvalidAuthenticityToken exception, which is captured in the GraphQL response body.
  • :reset_session – Resets the session. New token will be created and returned to the client.
  • :null_session – Provides an empty session during request. The cookies/sessions added by Devise are also deleted.

Not like REST API, GraphQL always uses HTTP POST method by design. The HTTP methods is useless to control the protect_from_forgery strategies, so we should add a custom strategy.

GraphQL Rails app

The CSRF protection will be added to the previously created mini-blog-2 app. How to create the app is explained in Make it Secure 2, GraphQL by Rails and Devise. The code is at https://github.com/yokolet/mini-blog-2.

The app uses Devise gem for a user authentication.

Update for null session strategy

This is to fix a flash error caused by no UI, API only setting. This GraphQL app doesn’t use null_session strategy, but it’s good to have the update below for a future change.

Add config.middleware.use ActionDispatch::Flash in config/application.rb. The three lines for cookie and session were added when Devise authentication was set up. The cookie and session are used for CSRF protection as well, so leave those lines as are.

# config/application.rb
# ...
# ...
module MiniBlog2
  class Application < Rails::Application
    # ...
    # ...
    config.session_store :cookie_store, key: "_mini-blog-2_session_#{Rails.env}"
    config.middleware.use ActionDispatch::Cookies
    config.middleware.use config.session_store, config.session_options

    # when protect_from_forgery with: :null_session is used, add blow.
    config.middleware.use ActionDispatch::Flash
  end
end

Add method to create and set CSRF token

As mentioned above, the CSRF token will be added to cookie. Add set_csrf_token method in ApplicationController. Since the Rails app is configured API only, two modules are included to use cookie and form_authenticity_token method. The form_authenticity_token method generates a token and ties it to the session.

# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
  include GraphqlDevise::SetUserByToken
  include ActionController::Cookies
  include ActionController::RequestForgeryProtection

  protected
  
  def set_csrf_token
    cookies['CSRF-TOKEN'] = form_authenticity_token
  end
end

Implement Custom protect_from_forgery Strategy

By the nature of GraphQL API, it’s very hard to choose protect_from_forgery strategies depending on queries and mutations. For that reason, a custom strategy class is added here. The strategy plan is:

  • userLogin and userRegister mutations don’t need CSRF token verification, but want the token for later queries. Use reset_session.
  • users and posts queries, createPost mutation do CSRF token verification. Use exception.

Something extra is that schema fetching query is issued very often while using the GraphQL client such as Insomnia or Postman. Absolutely, that doesn’t need CSRF token verification. We should consider such query exists to implement the custom strategy.

The below implementation parses GraphQL query and gets query/mutation names defined in the schema. The implementation is primitive and doesn’t support multiple or nested queries done in a single HTTP request. Assuming only one query comes in, it finds the name of a query or mutation.

# app/controllers/concerns/mini_blog_strategy.rb
class MiniBlogStrategy
  def initialize(controller)
    @controller = controller
  end

  def handle_unverified_request
    query_string = JSON.parse(@controller.request.body.string)["query"]
    operationName = GraphQL.parse(query_string).definitions[0].selections[0].name
    if %(users posts postCreate).include?(operationName)
      exception.handle_unverified_request
    else
      reset_session.handle_unverified_request
    end
  end

  private

  def reset_session
    ActionController::RequestForgeryProtection::ProtectionMethods::ResetSession.new(@controller)
  end

  def exception
    ActionController::RequestForgeryProtection::ProtectionMethods::Exception.new(@controller)
  end
end

Update graphql_controller

The last piece is a GraphqlController update. It’s simple. Just add set_csrf_token method and the custom strategy.

# app/controllers/graphql_controller.rb
class GraphqlController < ApplicationController
  # If accessing from outside this domain, nullify the session
  # This allows for outside API access while preventing CSRF attacks,
  # but you'll have to authenticate your user separately
  # protect_from_forgery with: :null_session
  protect_from_forgery with: MiniBlogStrategy
  after_action :set_csrf_token

  def execute
    # ...
  end

  # ...
  # ...
end

Try CSRF Protection

All are ready. Let’s see CSRF Protection is working. The GraphQL client used here is Postman since its HTTP request/response header handling is great.

The first step is the userLogin mutation. Open Postman, then:

  • select POST for HTTP method
  • input http://localhost:3000/graphql
  • select Body
  • select GraphQL

Right after GraphQL is selected, Postman makes a schema fetch query. As in the image below, “Schema Fetched” status appears.

What happened behind the scene? The GraphQL request comes to GraphQLController. Sine the HTTP request header doesn’t include x-csrf-token header, session reset was performed following the unverified strategy. At the same time, the cookie and session are returned. If we look at the terminal where Rails is running, “Can’t verify CSRF token authenticity.” should be spotted among the bunch of outputs.

img: postman schema fetch img: postman schema fetch cookie session

The userLogin mutation looks like below:

mutation login {
    userLogin(
        email: "finn.smith@example.com"
        password: "password!"
    ) {
        credentials {
            accessToken
            client
            uid
        }
    }
}

Write the mutation and click “Send” button. Again, we’ll see “Can’t verify CSRF token authenticity.” on the terminal, but get the CSRF-TOKEN cookie and session in the HTTP response header.

img: postman login query img: postman login query cookie session

The next step is to make posts or users query. For example, posts query looks like below:

query {
    posts {
        id
        userId
        title
        content
    }
}

Add X-CSRF-TOKEN to the HTTP request header. The token can be seen in the login response’s header, so copy and paste it to the value column. Also, make sure the session is set in the cookie. Write the query and click “Send” button.

img: postman posts query x-csrf token img: postman posts query session img: postman posts query

Let’s see CSRF verification failure. Click check box on the left of X-CSRF-TOKEN to deactivate. The header won’t be sent. Now we see ActionController::InvalidAuthenticityToken exception. On the terminal where Rails is running, “Can’t verify CSRF token authenticity.” appears again. That means the exception strategy is working.

img: postman posts query failure

Lastly, let’s try postCreate mutation. The mutation looks like below:

mutation create_post {
    postCreate(input: {
        title: "This is a test post title",
        content: "This is a test post content."
    }) {
        post {
            id
            userId
            title
            content
        }
    }
}

As explained in the previous post, access-token, client and uid should be set in the HTTP request header in addition to X-CSRF-TOKEN and session. Previously tried userLogin mutation gave us those three values already. Set those to the request header. Make sure the session is attached in the cookie if the GraphQL client is not Postman.

img: postman create post headers

Write the mutation query and click “Send” button.

img: postman create post query

The new post was successfully created.

Code

The example Rails app code is on the GitHub repo. Please see https://github.com/yokolet/mini-blog-3.

References

Latest Posts

Vite + Vue + Bun on Rails

Vue.js is one of frontend frameworks gaining popularity among rapidly emerging JavaScript technologies. The combination of Vue.js and Rails is becoming more popular as well, however, Vue.js development on Rails is not so straightforward. The reason would be that Vue.js relies on Vite for a development environment such as HMR (Hot Module Replacement) and bundling.

Bun + React on Rails

In the frontend world, new technologies keep emerging rapidly these years. Still, React is a well-established and very popular frontend framework, Vue.js, Svelte, Astro and more frameworks are gaining popularity. Not just the frameworks, tools for a transpiler/bundler or sort are also under a rapid development. In JavaScript domain, esbuild, rollup, vite and some more are out.

Ruby on Rails Secrets Management

A web application needs various kinds of values, params and etc which should not be revealed to the public, say GitHub repo. For example, API keys, tokens, passwords, and endpoints, all those should be kept secret. Another important factor is that such secrets should be shared among the team members. Additionally, all those secrets should come along with the deployment.