Software Securityhome
Fix a findingSearch
Fix card · A01:2025 Broken Access Control

Fix CSRF in Ruby on Rails

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Keep the protection ActionController::Base already has (load_defaults 5.2 adds protect_from_forgery with: :exception): forms from form_with carry the token, fetch() sends csrf_meta_tags' token in X-CSRF-Token, and Rails also checks Origin. Change state only on POST, PATCH, PUT or DELETE, and never skip the check for a format or a controller that uses the session cookie.

Stack
  • Language Ruby
  • Runtime Ruby 4.0.7
  • Framework Ruby on Rails 8.1.4 (protect_from_forgery, cookie sessions)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks6 cards
Download the project ZIP, 28 KB
On this page

Steps

  1. Find skip_forgery_protection, skip_before_action :verify_authenticity_token, protect_from_forgery with: :null_session or :reset_session, and routes that change state on GET (get, or match with via: :all).
  2. Keep config.load_defaults at 5.2 or later (or call protect_from_forgery with: :exception in ApplicationController), with forgery_protection_origin_check on (5.0 and later).
  3. Build forms with form_with or form_tag, which add the authenticity_token field; put csrf_meta_tags in the layout and send its token in the X-CSRF-Token header from fetch().
  4. Route state changes to POST, PATCH, PUT or DELETE only; a GET link such as an email confirmation should show a form that posts.
  5. Call reset_session at sign-in so the session, and with it the token, changes.
  6. Add regression tests that forge requests with another site's Origin, a same-site subdomain, Origin: null, another user's token and the .json URL.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerableapp/csrf/email_changes/vulnerable.rbRuby
extend ActiveSupport::Concern

included do
  skip_forgery_protection # added when a fetch() call from the account page kept getting 422
end

# GET /account/email: one click from the "confirm your new address" email.
def edit = update

def update
  if current_user.update(email: params[:email].to_s)
    redirect_to "/account"
  else
    render plain: "Enter a valid email address", status: :bad_request
  end
end

With IMPL=vulnerable, 8 of 13 tests fail (8 exploit tests).

The fix

Fixedapp/csrf/email_changes/fixed.rbRuby
# GET /account/email: the link from the confirmation email shows a form; it changes nothing.
def edit; end

# POST /account/email: verify_authenticity_token has already checked the token and the Origin.
def update
  if current_user.update(email: params[:email].to_s)
    redirect_to "/account"
  else
    render plain: "Enter a valid email address", status: :bad_request
  end
end

With IMPL=fixed, 13 of 13 tests pass.

A fix that looks safe but is not

Wrong fixapp/csrf/email_changes/wrong.rbRuby
extend ActiveSupport::Concern

included do
  skip_forgery_protection if: -> { request.format.json? }
end

def edit; end

def update
  if current_user.update(email: params[:email].to_s)
    redirect_to "/account"
  else
    render plain: "Enter a valid email address", status: :bad_request
  end
end

With IMPL=wrong, 1 of 13 tests fail (1 exploit test).

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testtest/csrf_test.rbRuby
# Exploit: each forged request carries Alice's session cookie. Browsers withhold a
# SameSite=Lax cookie from cross-site POSTs, but not from sibling subdomains or top-level
# GET navigations, and older browsers ignore SameSite, so the server must refuse anyway.

def forge(browser, headers, fields = {}, path: "/account/email")
  change(browser, headers, fields.merge("email" => "mallory@evil.example"), path:)
end

test "exploit: cross-site form post without a token is refused" do
  assert_refused(forge(sign_in("alice"), form_from(EVIL)))
end

test "exploit: foreign Origin is refused when the browser sends no fetch metadata" do
  assert_refused(forge(sign_in("alice"), { "Origin" => EVIL, "Referer" => "#{EVIL}/" }))
end

test "exploit: sibling subdomain is refused although SameSite cookies reach it" do
  assert_refused(forge(sign_in("alice"), form_from(SIBLING, "same-site")))
end

test "exploit: token from the attacker's own session is refused" do
  bobs_fields = hidden_fields(account_page(sign_in("bob")))
  assert_refused(forge(sign_in("alice"), form_from(EVIL), bobs_fields))
end

test "exploit: Referer that merely contains the host name is refused" do
  assert_refused(forge(sign_in("alice"), form_from(LOOKALIKE)))
end

test "exploit: page that suppresses the Referer is refused" do
  # <meta name="referrer" content="no-referrer"> on the attacking page: no Referer, Origin: null.
  headers = { "Origin" => "null", "Sec-Fetch-Site" => "cross-site", "Sec-Fetch-Mode" => "navigate", "Sec-Fetch-Dest" => "document" }
  assert_refused(forge(sign_in("alice"), headers))
end

test "exploit: cross-site GET navigation cannot change the email" do
  # Lax cookies are sent on top-level GET navigations, so only the method protects this.
  alice = sign_in("alice")
  alice.get "/account/email?email=mallory%40evil.example", headers: link_from(EVIL)
  assert_equal "alice@example.test", email_of("alice"), "a GET changed Alice's email (HTTP #{alice.response.status})"
end

test "exploit: cross-site form post to the .json URL is refused" do
  # Rails reads the format from the path as well as the Accept header: a plain HTML form can
  # post to /account/email.json, and the browser still sends the session cookie.
  assert_refused(forge(sign_in("alice"), form_from(EVIL), path: "/account/email.json"))
end

Pitfalls

  • Rails checks the token only on requests that are not GET or HEAD; a route that changes state on GET has no protection, and SameSite=Lax cookies still travel on top-level GET navigations.
  • Rails reads the request format from the path as well as the Accept header, so skip_forgery_protection if: -> { request.format.json? } lets a plain cross-site form post to /account/email.json with the session cookie: this card's wrong implementation.
  • An API that authenticates with a cookie needs CSRF protection like any form; only token-in-header APIs (Authorization: Bearer) can skip it, and then they must not accept the session cookie.
  • InvalidAuthenticityToken becomes a 422 response; don't rescue it into a redirect that completes the action.
  • Origin: null (a page with no-referrer, or a sandboxed frame) raises InvalidAuthenticityToken when forgery_protection_origin_check is on; a missing Origin falls back to the token alone.
  • Rails 8.1 does not check Sec-Fetch-Site; the token and the Origin check are the protection.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs csrf/ruby-rails does this for you, in the pinned Ruby container).
  2. Against your own app, post the change-email form from another origin, from a sibling subdomain and to the .json URL without a token, and expect 422 with nothing changed.
  3. grep for skip_forgery_protection and verify_authenticity_token; Semgrep's rails-skip-forgery-protection rule finds the first, Brakeman reported neither here.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix CSRF in Ruby on Rails
ToolRuleWhat it found here
Semgrepruby.rails.security.audit.rails-skip-forgery-protection.rails-skip-forgery-protection1 finding in vulnerable.rb and 1 in wrong.rb (each skip_forgery_protection), none in fixed.rb. The rule does not weigh the if: condition, so it reports the narrower skip in the wrong code the same way.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep image); every Ruby rule in semgrep-rules develop 9bbf021 run on app/ and config/, then --config r/<id>
Semgrepruby.lang.security.missing-csrf-protection.missing-csrf-protection1 finding on ApplicationController, a false positive: it looks for protect_from_forgery in the class, while load_defaults 5.2 and later add protect_from_forgery with: :exception to ActionController::Base.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep image); every Ruby rule in semgrep-rules develop 9bbf021 run on app/ and config/, then --config r/<id>
CodeQLrb/csrf-protection-disabledExpected to flag skip_forgery_protection in vulnerable.rb and in wrong.rb: ActionController.qll matches the call by name, inside a concern's included block too, and ignores the if: condition. Nothing in fixed.rb.Checked: github/codeql main at b5b8165; query and Ruby library source read, not executed
CodeQLrb/csrf-protection-not-enabledNot expected to flag ApplicationController: the query accepts a config.load_defaults of 5.2 or later as protection being on (this card has 8.1); it does not read default_protect_from_forgery itself.Checked: github/codeql main at b5b8165; query and Ruby library source read, not executed
BrakemanCSRFTokenForgeryCVE, ForgerySetting, SkipBeforeFilterCross-site request forgeryNo warnings on this card, or on a probe with skip_forgery_protection, skip_forgery_protection if: -> { request.format.json? } and skip_before_action :verify_authenticity_token written directly in controllers.Checked: Brakeman 8.1.0 in the ruby:4.0.7 image; brakeman -A -q /card (all checks, optional ones included)

Libraries and versions

Libraries and versions
ComponentVersion
LanguageRuby
RuntimeRuby 4.0.7
FrameworkRuby on Rails 8.1.4 (protect_from_forgery, cookie sessions)
rails (railties, actionpack, actionview, activerecord)8.1.4
sqlite32.9.6 (bundles SQLite 3.53.2)
bcrypt3.1.22
minitest6.0.6
minitest-reporters (JUnitReporter)1.8.0

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the csrf/ruby-railsfolder.

Shell
BUNDLE_PATH=vendor/bundle BUNDLE_FROZEN=true bundle install
Shell
# Expected: every test passes
IMPL=fixed BUNDLE_PATH=vendor/bundle bundle exec ruby -Itest test/csrf_test.rb

# Expected: an exploit test fails
IMPL=vulnerable BUNDLE_PATH=vendor/bundle bundle exec ruby -Itest test/csrf_test.rb

# Expected: a test fails
IMPL=wrong BUNDLE_PATH=vendor/bundle bundle exec ruby -Itest test/csrf_test.rb

Without Ruby installed, run the same commands with Docker in the image the card was verified in, pinned by its digest. The named volumes keep the downloaded dependencies and the build cache between runs, so the test runs need no network.

Shell
docker run --rm --init \
  -v "$PWD":/card -w /card \
  -e BUNDLE_PATH=vendor/bundle \
  ruby:4.0.7-trixie@sha256:0192d5244fa068587325c3ef4f886fdb2d6775689757d598f9a5261d0f86d40e \
  sh -c 'BUNDLE_PATH=vendor/bundle BUNDLE_FROZEN=true bundle install'
Shell
# Expected: every test passes
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -e IMPL=fixed -e BUNDLE_PATH=vendor/bundle \
  ruby:4.0.7-trixie@sha256:0192d5244fa068587325c3ef4f886fdb2d6775689757d598f9a5261d0f86d40e \
  sh -c 'bundle exec ruby -Itest test/csrf_test.rb'

# Expected: an exploit test fails
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -e IMPL=vulnerable -e BUNDLE_PATH=vendor/bundle \
  ruby:4.0.7-trixie@sha256:0192d5244fa068587325c3ef4f886fdb2d6775689757d598f9a5261d0f86d40e \
  sh -c 'bundle exec ruby -Itest test/csrf_test.rb'

# Expected: a test fails
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -e IMPL=wrong -e BUNDLE_PATH=vendor/bundle \
  ruby:4.0.7-trixie@sha256:0192d5244fa068587325c3ef4f886fdb2d6775689757d598f9a5261d0f86d40e \
  sh -c 'bundle exec ruby -Itest test/csrf_test.rb'

Each run writes a JUnit report to reports/TEST-*.xml. From the top folder of the download, node run-all.mjs csrf/ruby-rails runs all three modes and checks the expected results, in the container, so it needs Docker and Node.js but not the toolchain.

Download the project ZIP, 28 KB

Verified

Machine
Debian 13 (trixie) in Docker, on macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • ruby 4.0.7 (ruby:4.0.7-trixie image, linux/arm64)
  • bundler 4.0.20
  • rails 8.1.4
  • minitest 6.0.6
  • minitest-reporters 1.8.0
  • sqlite3 2.9.6 (SQLite 3.53.2)
  • bcrypt 3.1.22
  • nokogiri 1.19.4
  • semgrep 1.180.0
  • brakeman 8.1.0
Matrix
fixed: 13 of 13 tests pass; vulnerable: 8 of 13 tests fail (8 exploit tests); wrong: 1 of 13 tests fail (1 exploit test)
Test results
fixed 13/13 pass; vulnerable 8/13 fail (8 exploit tests); wrong 1/13 fail (1 exploit test: the cross-site form post to /account/email.json), test runs offline (--network=none) after install; node examples/fixes/run-all.mjs ruby-rails --install, results/2026-10-11-fix-rails.json
Dependency freshness
Rails 8.1.4 was published on 2026-09-24 and Brakeman 8.1.0 on 2026-10-01. uri 1.1.2 and prettyprint 0.2.1 (both 2026-10-08) were three days old and are not security releases, so the Gemfile holds uri at 1.1.1 and prettyprint at 0.2.0; every other gem in Gemfile.lock is at least 13 days old. OSV reported no known vulnerabilities for any pin. Ruby 4.0.7 (2026-09-15) is the latest 4.0 release.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. OWASP Cross-Site Request Forgery Prevention Cheat Sheet
  2. Rails Guides: Securing Rails Applications, CSRF
  3. Rails API: ActionController::RequestForgeryProtection
  4. Rails API: RequestForgeryProtection::ClassMethods (protect_from_forgery, skip_forgery_protection)
  5. Rails Guides: configuring, default_protect_from_forgery
  6. Brakeman: cross-site request forgery
  7. CWE-352: Cross-Site Request Forgery (CSRF)

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.