Skip to content
CWE-352A01:2021 – Broken Access Control

Rails

protect_from_forgery, why :null_session is worse than it looks, forgery_protection_origin_check, and API-only mode.

The rule

Leave the defaults alone, and never use with: :null_session on a controller that still authenticates by cookie.

Since Rails 5.2, config.action_controller.default_protect_from_forgery = true is set by the framework defaults, so ApplicationController is protected without writing anything. Rails also enables an Origin check (forgery_protection_origin_check) and masks the token per render for BREACH.

The two things that go wrong:

  1. with: :null_session — the historical default, and still widely copied. It does not reject a forged request; it lets it through with an empty session. On an endpoint that acts without needing a session, or in combination with any other authentication path, the request succeeds.
  2. API-only mode--api skips ActionController::RequestForgeryProtection entirely. That is correct for a token-authenticated API and dangerous the moment a cookie is introduced.

Why :null_session is the trap

Rubyapp/controllers/application_controller.rbVulnerable
class ApplicationController < ActionController::Base
  # This was the Rails default for years and is still copied from old
  # tutorials constantly.
  #
  # It does NOT reject a forged request. It nils out the session and lets the
  # action run. The intent was graceful degradation for API clients.
  #
  # The consequences:
  #   - An action that does not read the session still executes.
  #   - An action authenticated by anything OTHER than session -- an API key
  #     cookie, a "remember me" token, a devise scope -- still executes,
  #     because only the session was nilled.
  #   - Failures are silent. Nothing is logged and nothing 403s, so the gap
  #     is invisible in monitoring.
  protect_from_forgery with: :null_session
end

class ReportsController < ApplicationController
  # Skipping entirely, usually to make a webhook or a mobile client work.
  skip_forgery_protection

  def destroy_all
    current_user.reports.destroy_all
    head :ok
  end
end

API-only mode

Rubyapp/controllers/api/base_controller.rbVulnerable
# rails new myapp --api produces controllers inheriting from
# ActionController::API, which does NOT include
# ActionController::RequestForgeryProtection.
#
# That is correct for an API authenticated by a bearer token: the credential
# is not ambient, so there is nothing to forge.
#
# It becomes a vulnerability the moment a cookie enters the picture -- and
# that happens more often than teams expect, usually when a browser client
# is added to a project that started as a mobile backend.

class Api::BaseController < ActionController::API
  include ActionController::Cookies      # <- the moment this appears...

  before_action :authenticate

  private

  def authenticate
    # ...and authentication reads a cookie, every endpoint under this
    # controller is CSRF-able. There is no token check anywhere in
    # ActionController::API to fall back on.
    @current_user = User.find_by(id: cookies.signed[:user_id])
    head :unauthorized unless @current_user
  end
end

# Two ways out:
#
# 1. Authenticate with a bearer token instead of a cookie. Preferred --
#    it removes the ambient credential and the problem with it.
#
# 2. If a cookie is required, add protection back explicitly:
#
#      class Api::BaseController < ActionController::API
#        include ActionController::RequestForgeryProtection
#        protect_from_forgery with: :exception
#      end
#
#    plus an Origin or Sec-Fetch-Site check.

Views and JavaScript

Rubyapp/views/accounts/edit.html.erbSecure
<%# form_with includes the authenticity token automatically. A hand-written
    <form> tag does not -- which is the usual reason a legitimate form starts
    returning 422 after protection is enabled. %>
<%= form_with model: @account, url: account_path, method: :patch do |f| %>
  <%= f.email_field :email %>
  <%= f.submit 'Update' %>
<% end %>

<%# For fetch/XHR, expose the token in the head. csrf_meta_tags is in the
    default application layout; check it has not been removed. %>
<%# <head> <%= csrf_meta_tags %> </head> %>

Checklist

  • grep -rn 'null_session' returns nothing. It is not protection.
  • grep -rn 'skip_forgery_protection\|skip_before_action :verify_authenticity_token' — every hit justified in a comment, and none of them on a cookie-authenticated action.
  • protect_from_forgery with: :exception is the effective setting, whether stated or inherited from load_defaults.
  • config.load_defaults is at least 5.2, so default_protect_from_forgery is on.
  • forgery_protection_origin_check is enabled.
  • API-only controllers do not include ActionController::Cookies unless protection was added back.
  • Session cookie: secure: true, httponly: true, same_site: :lax, and a __Host- prefixed key with no domain option.
  • Routes use explicit verbs — no via: :all, no match without via.
  • csrf_meta_tags is present in the application layout.
  • reset_session is called on login. Devise does this; a hand-rolled SessionsController frequently does not. See Login CSRF.