Ruby on Rails Module 10 – Securing Rails applications
Rails security defaults: CSRF, XSS and SQLi
See how Rails 8.1 defends against CSRF, XSS and SQL injection by default, which lines of code switch each defence off, and how to test your own app.
What you will learn
- Explain how the authenticity token refuses a forged POST, and why a test must switch forgery protection on
- Recognise the template patterns that stop Rails escaping output, and replace each with a safe form
- Choose a placeholder or an allowlist for every value and column name that a query takes from a request
- Protect a model from mass assignment by listing only safe attributes in params.expect
- Run Brakeman on your own app and read a warning by its type, confidence, file and line
Before you start
What you’ll learn · Before you start
What you will learn
- Explain how the authenticity token refuses a forged POST, and why a test must switch forgery protection on
- Recognise the template patterns that stop Rails escaping output, and replace each with a safe form
- Choose a placeholder or an allowlist for every value and column name that a query takes from a request
- Protect a model from mass assignment by listing only safe attributes in params.expect
- Run Brakeman on your own app and read a warning by its type, confidence, file and line
Before you start
On this page
Four lines of ordinary-looking code undo four Rails defaults in the Kaksha lab: skip_forgery_protection in a controller, a string built into a where, a raw in a view, and a :role inside a params.expect list. Each default holds until someone writes the line that bypasses it, and that line reads like any other.
Rails security defaults cover CSRF, XSS and SQLi, the three attacks Rails defends against most visibly, with mass assignment beside them. You will read recorded runs of small Rails 8.1 apps and of a lab with those four lines planted, then explain how the authenticity token refuses a forged POST, recognise the patterns that stop escaping, bind or allowlist every part of a query, and read a Brakeman warning.
A forged POST is refused unless it carries the token of the form
A Kaksha teacher is signed in. In another tab she opens a page on a different site, which holds a hidden form aimed at Kaksha. Her browser attaches her Kaksha cookie, because a cookie goes to the site it belongs to whichever page triggered the request, so Kaksha sees what looks like her own click. This is CSRF: Cross-site request forgery: a page on another site makes a signed-in user's browser send a request to your site, which trusts it because the browser attached the user's cookie., and the Rails defence is a value that only Kaksha’s own pages contain.
Rails turns it on for a new app. The authenticity token: A per-session value that Rails writes into form fields and the csrf-token meta tag. A request that changes data must send it back, and only the site's own pages contain it. goes into a hidden field of every form that form_with builds and into a csrf-token meta tag in the layout, and forgery protection: A Rails feature that rejects form submissions without a CSRF token, protecting against cross-site request forgery. More in “Integration tests for controllers and APIs” refuses a POST, PATCH or DELETE whose token does not belong to the session. Turbo sends the meta tag’s value back in an X-CSRF-Token header. This one-file app runs in production mode and posts to its notice form and its sign-in form; tokens change on every run, so it prints only their lengths:
# What the authenticity token does in a Rails 8.1 app running in production mode. One form posts a notice and one
# posts a sign-in; the browser is an integration session, the tool Rails' own integration tests use, so it keeps the
# session cookie between requests. Each POST is sent without a token, with the token the form carried, with the
# token from the csrf-token meta tag (what Turbo sends), and with a made-up token. Token values change on every
# run, so only their lengths are printed.
ENV["RAILS_ENV"] = "production"
require "rails"
require "action_controller/railtie"
require "action_view/railtie"
require "action_dispatch/testing/integration"
require "nokogiri"
module Kaksha
class Application < Rails::Application
config.load_defaults 8.1
config.eager_load = false
config.logger = Logger.new(nil)
config.secret_key_base = "kaksha-one-file-example-" * 3 # production keeps this in its credentials
end
end
Kaksha::Application.initialize!
class ApplicationController < ActionController::Base
prepend_view_path File.join(__dir__, "csrf_views")
end
class NoticesController < ApplicationController
def new; end
def create
redirect_to "/notices", notice: "Notice published"
end
end
class SessionsController < ApplicationController
def new; end
def create
redirect_to "/", notice: "Signed in"
end
end
Kaksha::Application.routes.draw do
resources :notices, only: %i[new create]
resource :session, only: %i[new create]
end
puts "Rails.env: #{Rails.env}"
puts "allow_forgery_protection: #{ActionController::Base.allow_forgery_protection}"
browser = ActionDispatch::Integration::Session.new(Kaksha::Application)
browser.host! "kaksha.example"
# Fetches a form page and returns the token its hidden field carries and the one in its csrf-token meta tag.
read_tokens = lambda do |path|
browser.get path
page = Nokogiri::HTML5(browser.response.body)
form = page.at_css("form")
field = form.at_css("input[name=authenticity_token]")
meta = page.at_css("meta[name=csrf-token]")
puts "GET #{path} -> #{browser.response.status}"
puts " form: #{form['method']} #{form['action']}, hidden field #{field['name']} of #{field['value'].length} characters"
puts " head: meta csrf-param=#{page.at_css('meta[name=csrf-param]')['content']}, csrf-token of #{meta['content'].length} characters"
cookie = Array(browser.response.headers["set-cookie"]).join("\n").lines.first.to_s
puts " session cookie: #{cookie.split('; ').drop(1).join('; ').strip}" unless cookie.empty?
[field["value"], meta["content"]]
end
send_post = lambda do |label, path, params: {}, headers: {}|
browser.post path, params: params, headers: headers
response = browser.response
where = response.redirect? ? " to #{response.location.delete_prefix('http://kaksha.example')}" : ""
puts "POST #{path}, #{label} -> #{response.status}#{where}"
end
form_token, meta_token = read_tokens.("/notices/new")
send_post.("no token", "/notices", params: { title: "Fee reminder" })
send_post.("the token from the form", "/notices", params: { title: "Fee reminder", authenticity_token: form_token })
send_post.("the meta token in X-CSRF-Token", "/notices", params: { title: "Fee reminder" }, headers: { "X-CSRF-Token" => meta_token })
send_post.("a made-up token", "/notices", params: { title: "Fee reminder", authenticity_token: "not-the-token" })
login_token, = read_tokens.("/session/new")
send_post.("no token", "/session", params: { email_address: "asha@example.com", password: "a-long-password" })
send_post.("the token from the form", "/session", params: { email_address: "asha@example.com", password: "a-long-password", authenticity_token: login_token }) Output
Rails.env: production allow_forgery_protection: true GET /notices/new -> 200 form: post /notices, hidden field authenticity_token of 86 characters head: meta csrf-param=authenticity_token, csrf-token of 86 characters session cookie: path=/; httponly; samesite=lax POST /notices, no token -> 422 POST /notices, the token from the form -> 302 to /notices POST /notices, the meta token in X-CSRF-Token -> 302 to /notices POST /notices, a made-up token -> 422 GET /session/new -> 200 form: post /session, hidden field authenticity_token of 86 characters head: meta csrf-param=authenticity_token, csrf-token of 86 characters session cookie: path=/; httponly; samesite=lax POST /session, no token -> 422 POST /session, the token from the form -> 302 to /
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby csrf_probe.rb
Both pages carried a token of 86 characters twice, in the hidden field and in the meta tag. A POST with no token, or with a made-up one, got 422 and the action never ran. The form’s own token and the header copy got 302. The sign-in form behaved the same way, which matches OWASP’s advice to protect login forms too. Follow one request through the check, then the forged one:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The form is requested. GET /notices/new reaches a controller that inherits from ActionController::Base. In production mode allow_forgery_protection is true, so Rails prepares a token for this session.
- The token travels twice. The page comes back with the token in a hidden authenticity_token field of the form and in the csrf-token meta tag of the head. In the recorded run each was 86 characters.
- The submit carries it back. The teacher presses Publish. The browser sends the session cookie and the hidden field together, and Rails checks that the token belongs to this session before the action runs.
- The action runs. The token matches, so the create action runs and answers 302 to /notices. The same check passed for the header Turbo sets from the meta tag, X-CSRF-Token.
- Another site posts for her. A page on another site holds a form that targets Kaksha. When she opens it, her browser attaches her Kaksha cookie, but the page cannot read the form Kaksha sent, so it has no token to add.
- Rails refuses it. No token, or a made-up one, raises ActionController::InvalidAuthenticityToken. Production answers 422 and the action never runs. The recorded run saw 422 for both on the notice form and for a missing token on the sign-in form.
The session cookie in the run carried samesite=lax, but OWASP lists that attribute as an extra layer beside the token, not a replacement. Neither helps once a page can run a stranger’s script: the security guide notes that XSS can read the token, so the next section matters as much as this one.
Key idea
The token proves that a request came from a page Kaksha itself sent, because a page on another site cannot read it.
The configuring guide says allow_forgery_protection is false in the test environment and true in every other one, and the generated config/environments/test.rb sets it that way. A test that expects a refusal therefore sees none unless it switches the protection back on. The lab’s check does that in setup and restores the flag afterwards:
require "test_helper"
class CsrfTest < ActionDispatch::IntegrationTest
# The generated test environment switches forgery protection off, so a test about it has to switch it on.
setup do
@before = ActionController::Base.allow_forgery_protection
ActionController::Base.allow_forgery_protection = true
end
teardown do
ActionController::Base.allow_forgery_protection = @before
end
test "a post without a token is refused" do
assert_no_difference "Notice.count" do
post notices_path, params: { notice: { title: "Fee reminder", body: "Due Friday" } }
end
assert_response :unprocessable_content
end
test "a post with the token from the form is accepted" do
get new_notice_path
token = css_select("form input[name=authenticity_token]").first["value"]
assert_difference "Notice.count", 1 do
post notices_path, params: { notice: { title: "Fee reminder", body: "Due Friday" }, authenticity_token: token }
end
assert_response :redirect
end
end The session runs that file and a copy that leaves the environment alone, both against the fixed controller:
# The same refusal tested twice against the fixed controller, which keeps forgery protection on. The first test
# leaves the test environment as Rails generated it and fails; the second switches the protection on for its own
# tests and passes.
require_relative "session"
Session.run(<<~SH, fails: ["bin/rails test test/integration/csrf_default_test.rb --seed 1"])
cd ../kaksha
cp -R ../lab/. .
cp -R ../lab-fixed/. .
cp ../lab-trap/test/integration/csrf_default_test.rb test/integration/csrf_default_test.rb
grep -n forgery config/environments/test.rb
bin/rails test test/integration/csrf_default_test.rb --seed 1
bin/rails test test/integration/csrf_test.rb --seed 1
SH Output
$ cd ../kaksha $ cp -R ../lab/. . $ cp -R ../lab-fixed/. . $ cp ../lab-trap/test/integration/csrf_default_test.rb test/integration/csrf_default_test.rb $ grep -n forgery config/environments/test.rb 28: # Disable request forgery protection in test environment. 29: config.action_controller.allow_forgery_protection = false $ bin/rails test test/integration/csrf_default_test.rb --seed … Running 1 tests in a single process (parallelization threshold is 50) Run options: --seed … # Running: F Failure: CsrfDefaultTest#test_a_post_without_a_token_is_refused [test/integration/csrf_default_test.rb:7]: Expected response to be a <422: unprocessable_content>, but was a <302: Found> redirect to <http://www.example.com/notices/756651094> Response body: bin/rails test test/integration/csrf_default_test.rb:5 Finished in …s, … runs/s, … assertions/s. 1 runs, 1 assertions, 1 failures, 0 errors, 0 skips [exit status 1] $ bin/rails test test/integration/csrf_test.rb --seed … Running 2 tests in a single process (parallelization threshold is 50) Run options: --seed … # Running: .. Finished in …s, … runs/s, … assertions/s. 2 runs, 6 assertions, 0 failures, 0 errors, 0 skips
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby csrf_session.rb
The first test expected 422 and got a 302 redirect from a controller that does protect itself, so it proves nothing: a controller that skipped protection would get the same answer. The second switches the flag on for its own tests, refuses the bare POST and accepts the one with the form’s token.
Skipping protection to make a form work
skip_forgery_protection on a cookie-authenticated controller removes the check from every action in it. Leave it for endpoints that authenticate each request with something a browser does not send by itself, such as a bearer token.
Rails 8.1.4 verifies the token and, as a further defence on by default since load_defaults 5.0, compares the Origin header with the site’s own origin. The changelog of the unreleased Rails main branch describes a check that goes further, by reading the Sec-Fetch-Site header, one of the fetch metadata: Request headers such as Sec-Fetch-Site that modern browsers send to say how the page that started a request relates to the site it targets, so a server can refuse cross-site requests that change data. headers that browsers send.
Planned for Rails 8.2, not in Rails 8.1
The Action Pack changelog on the main branch lists protect_from_forgery using: :header_only, the default for new Rails 8.2 apps, and :header_or_legacy_token, which falls back to the token for older browsers. It adds trusted_origins: for legitimate cross-site posts such as an OAuth callback. Nothing on this page was run on that branch.
Checkpoint
Check: The authenticity token
ERB escapes by default, and a few helpers switch that off
A teacher pastes the plain marker <b>tag</b> into a notice. If the page prints it as text, the browser shows the characters; if it prints markup, the browser builds a bold element, and a value that can build any element can build a script element too. That is XSS: Cross-site scripting: a page prints input as markup, so a stranger's script runs inside your own page, where it can act as the user and read everything on the page, including a CSRF token.. Rails prevents it by default, because <%= %> applies output escaping: Replacing the characters that would start markup, such as the angle brackets and the ampersand, with character references at the place where a value is printed into a page, so that typed text stays text. More in “ERB views, layouts, partials and helpers” and turns < into <. A short list of places turns the default off, and the table names the four to look for:
| Pattern | What goes wrong | Safe form |
|---|---|---|
raw value, value.html_safe, <%== value %> |
The value is printed as markup, whatever it holds | <%= value %>, or sanitize with a short list when markup is wanted |
render inline: with text interpolated into the string |
The guide says the string is ERB, so the value is parsed as a template | render a template file and pass the value as a local |
link_to "Site", url where a user chose the url |
Any scheme is accepted, including javascript: |
Build the link only for http and https addresses |
var x = <%= raw json %>; inside a script tag |
The characters < and & stay raw inside the script |
A data- attribute printed with <%= %>, or raw json_escape(json) |
The first, third and fourth rows, as a weak template and its fixed twin:
<article>
<p id="raw"><%= raw notice.body %></p>
<p id="html-safe"><%= notice.body.html_safe %></p>
<p id="double-equals"><%== notice.body %></p>
<p id="link"><%= link_to "Website", notice.website %></p>
<script id="data">var notice = <%= raw JSON.generate(notice.to_h) %>;</script>
</article> <article>
<p id="raw"><%= notice.body %></p>
<p id="html-safe"><%= notice.body %></p>
<p id="double-equals"><%= notice.body %></p>
<p id="link"><%= safe_link_to "Website", notice.website %></p>
<div id="data" data-notice="<%= JSON.generate(notice.to_h) %>"></div>
</article> The program renders both partials for one notice. Its body is the marker <b>tag</b> and its website is javascript:marker(), a call to a function nobody defined. Nokogiri then counts what reached the page:
# The same notice rendered by a template that switches escaping off and by its fixed twin. The notice holds a plain
# marker, <b>tag</b>, and a link that starts with javascript: but only calls a function that does not exist; nothing in
# this file attacks anything. Nokogiri counts what reached the page: bold elements that came from the text and links
# whose address starts with javascript:; a pattern counts the "<" characters left in the notice's data as written in
# the HTML source, inside the script or inside the attribute.
ENV["RAILS_ENV"] = "production"
require "rails"
require "action_controller/railtie"
require "action_view/railtie"
require "nokogiri"
module Kaksha
class Application < Rails::Application
config.load_defaults 8.1
config.eager_load = false
config.logger = Logger.new(nil)
config.secret_key_base = "kaksha-one-file-example-" * 3
end
end
Kaksha::Application.initialize!
# A link helper that only builds a link for http and https addresses and prints the text of any other address.
module SafeLinkHelper
def safe_link_to(name, address)
scheme = URI.parse(address.to_s).scheme
%w[http https].include?(scheme) ? link_to(name, address) : name
rescue URI::InvalidURIError
name
end
end
class ApplicationController < ActionController::Base
prepend_view_path File.join(__dir__, "xss_views")
helper SafeLinkHelper
end
Notice = Struct.new(:title, :body, :website, keyword_init: true)
notice = Notice.new(title: "Fee reminder", body: "<b>tag</b>", website: "javascript:marker()")
%w[weak fixed].each do |name|
html = ApplicationController.render(partial: "#{name}/notice", locals: { notice: notice })
page = Nokogiri::HTML5.fragment(html)
puts "#{name}/_notice.html.erb"
html.each_line { |line| puts " #{line.rstrip}" unless line.strip.empty? }
bold = page.css("p b").size
js_links = page.css("a").count { |a| a["href"].to_s.start_with?("javascript:") }
data = html[/var notice = (.*?);<\/script>/m, 1] || html[/data-notice="(.*?)"/m, 1]
angle = data.count("<")
puts " check: bold elements from text #{bold}, links to javascript: #{js_links}, angle brackets in the data source #{angle}"
end Output
weak/_notice.html.erb
<article>
<p id="raw"><b>tag</b></p>
<p id="html-safe"><b>tag</b></p>
<p id="double-equals"><b>tag</b></p>
<p id="link"><a href="javascript:marker()">Website</a></p>
<script id="data">var notice = {"title":"Fee reminder","body":"<b>tag</b>","website":"javascript:marker()"};</script>
</article>
check: bold elements from text 3, links to javascript: 1, angle brackets in the data source 2
fixed/_notice.html.erb
<article>
<p id="raw"><b>tag</b></p>
<p id="html-safe"><b>tag</b></p>
<p id="double-equals"><b>tag</b></p>
<p id="link">Website</p>
<div id="data" data-notice="{"title":"Fee reminder","body":"<b>tag</b>","website":"javascript:marker()"}"></div>
</article>
check: bold elements from text 0, links to javascript: 0, angle brackets in the data source 0
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby xss_marker.rb
In the weak page the three raw forms each produced a bold element, the link kept its javascript: address, and two angle brackets stood unescaped in the data. In the fixed page the marker came out as <b>tag</b>, which a browser shows as the characters typed, the link became its text, and the data sits in an attribute where " and < do the escaping. Paste that output into the HTML entity decoder to turn the entities back into the marker. When a field really does hold markup, sanitize with a short list of tags is the form Rails supplies, and the ERB lesson runs it.
Escaping by default does not replace upgrading
The Rails releases 8.1.2.1, 8.0.4.1 and 7.2.3.1 fixed ten issues, three of them XSS. One covered Action View tag helpers when a blank string is used as an attribute name, which affects apps that let users choose HTML attributes. One covered SafeBuffer#%, which could report a string as HTML-safe after an in-place change, and one the debug exceptions page that development mode shows. An 8.1 app below 8.1.2.1 has all three and should move to the newest 8.1 patch release, and bin/bundler-audit in the local CI lesson is the step that notices an app left behind.
Checkpoint
Check: Escaping and the helpers that skip it
Placeholders bind values, and a string built by hand does not
Parents search Kaksha’s notices by surname, and one of them types O’Brien, an ordinary name that shows how a query breaks. The script builds the same search four ways, prints the SQL that to_sql produces for each, and runs them on an in-memory database. It also passes sort values to order, pluck and find_by_sql:
# The SQL that each way of writing a query produces for the same input, a surname with an apostrophe in it. to_sql
# shows the statement Active Record would send without sending it; the last block runs the statements on an
# in-memory database. The unsafe forms are here to be recognised: the second one breaks on an ordinary surname.
require "active_record"
require "logger"
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Schema.verbose = false
ActiveRecord::Schema.define do
create_table :notices do |t|
t.string :title, null: false
t.timestamps
end
end
class Notice < ActiveRecord::Base
SORTS = { "newest" => { id: :desc }, "title" => { title: :asc } }.freeze
def self.sorted(key)
order(SORTS.fetch(key, SORTS["newest"]))
end
end
["Brown", "O'Brien"].each { |title| Notice.create!(title: title) }
q = "O'Brien" # what a parent typed in the search box
forms = {
"hash condition " => Notice.where(title: q),
"placeholder " => Notice.where("title LIKE ?", "%#{q}%"),
"named placeholder " => Notice.where("title LIKE :q", q: "%#{q}%"),
"string interpolation " => Notice.where("title LIKE '%#{q}%'"),
}
puts "search for #{q}:"
forms.each { |name, relation| puts " #{name} #{relation.to_sql}" }
puts "\nwhat happens when each form runs:"
forms.each do |name, relation|
result = relation.pluck(:title).inspect
puts " #{name} #{result}"
rescue ActiveRecord::StatementInvalid => error
puts " #{name} #{error.class}: #{error.message.lines.first.strip.delete_suffix(":")}"
end
# Runs the block and prints what came back, or the class of the error it raised (with the database's own message
# when the database refused the statement).
show = lambda do |label, &block|
puts " #{label.ljust(30)} #{block.call.inspect}"
rescue ActiveRecord::UnknownAttributeReference => error
puts " #{label.ljust(30)} #{error.class}"
rescue ActiveRecord::StatementInvalid => error
puts " #{label.ljust(30)} #{error.class}: #{error.message.lines.first.strip.delete_suffix(":")}"
end
puts "\nvalues passed straight to order, pluck and find_by_sql:"
["title", "length(title)", "1 + 1", "banana"].each do |value|
show.("order(#{value.inspect})") { Notice.order(value).pluck(:title) }
end
show.("pluck(\"1 + 1\")") { Notice.pluck("1 + 1") }
show.("order(Arel.sql(\"1 + 1\"))") { Notice.order(Arel.sql("1 + 1")).pluck(:title) }
show.("find_by_sql with #{q}") { Notice.find_by_sql("SELECT * FROM notices WHERE title = '#{q}'").map(&:title) }
puts "\nthe same keys through an allowlist:"
%w[title newest banana].each { |key| puts " sort=#{key.ljust(6)} #{Notice.sorted(key).to_sql}" } Output
search for O'Brien:
hash condition SELECT "notices".* FROM "notices" WHERE "notices"."title" = 'O''Brien'
placeholder SELECT "notices".* FROM "notices" WHERE (title LIKE '%O''Brien%')
named placeholder SELECT "notices".* FROM "notices" WHERE (title LIKE '%O''Brien%')
string interpolation SELECT "notices".* FROM "notices" WHERE (title LIKE '%O'Brien%')
what happens when each form runs:
hash condition ["O'Brien"]
placeholder ["O'Brien"]
named placeholder ["O'Brien"]
string interpolation ActiveRecord::StatementInvalid: SQLite3::SQLException: near "Brien": syntax error
values passed straight to order, pluck and find_by_sql:
order("title") ["Brown", "O'Brien"]
order("length(title)") ["Brown", "O'Brien"]
order("1 + 1") ActiveRecord::UnknownAttributeReference
order("banana") ActiveRecord::StatementInvalid: SQLite3::SQLException: no such column: banana
pluck("1 + 1") ActiveRecord::UnknownAttributeReference
order(Arel.sql("1 + 1")) ["Brown", "O'Brien"]
find_by_sql with O'Brien ActiveRecord::StatementInvalid: SQLite3::SQLException: near "Brien": syntax error
the same keys through an allowlist:
sort=title SELECT "notices".* FROM "notices" ORDER BY "notices"."title" ASC
sort=newest SELECT "notices".* FROM "notices" ORDER BY "notices"."id" DESC
sort=banana SELECT "notices".* FROM "notices" ORDER BY "notices"."id" DESC
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby sql_forms.rb
The hash condition and both placeholder forms wrote 'O''Brien': the driver doubles the apostrophe inside the quotes, so the value stays one string, and each found the row. The interpolated form wrote '%O'Brien%', the apostrophe ended the string early, and SQLite refused the statement with a syntax error near “Brien”. A value that can end a string can also add SQL after it, which is why the security guide and the OWASP cheat sheet name SQL injection: An attack where user input is written into SQL text and parsed as code; the defence is parameterised queries that keep every value apart from the SQL. More in “Querying: relations, scopes and calculations”; find_by_sql with interpolation failed the same way. The query guide adds that % and _ inside a bound value still act as wildcards, and sanitize_sql_like escapes them, as the fixed lab controller does.
Sort keys are a different problem, because a column name is not a value and nothing can bind it. Rails refuses some strings (1 + 1 raised UnknownAttributeReference in both order and pluck), but it accepted length(title), and banana reached the database, which said it had no such column. Arel.sql tells Rails that you vouch for a string, so it passes unchecked. That check is a safety net, not an allowlist: a user can still pick any column-shaped name. The allowlist is a small hash, SORTS, that maps the keys you offer to the order you mean, with a default for every other key; in the last block of the output banana fell back to ORDER BY id DESC.
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Form | Value stays data | What the run showed | Use it when |
|---|---|---|---|
| Plain output tag Written as: <%= value %> | Good the marker came out as <b>tag</b>, text on the page | Good 0 bold elements reached the page | Good the value is text, which is almost always |
| sanitize with a list Written as: sanitize value, tags: %w[b i] | Mixed keeps the tags and attributes on your list and drops the rest | Mixed not in this run; the list decides what survives | Mixed the field really holds markup, with the shortest list that works |
| Output marked as safe Written as: raw value, value.html_safe, <%== value %> | Poor prints the value as markup, whatever it holds | Poor 3 bold elements and 1 javascript link reached the page | Poor only for markup your own code built |
| Bound value Written as: where(title: v), where("title LIKE ?", v) | Good the driver quotes the value, so it cannot change the statement | Good O''Brien was quoted and its row was found | Good every value that reaches a query |
| String built into SQL Written as: where("title LIKE '%#{v}%'") | Poor the value becomes part of the SQL text | Poor StatementInvalid near "Brien" on an ordinary surname | Poor never with a value from a request |
| Sort taken from params Written as: order(params[:sort]) | Poor a name is not a value, so nothing binds it | Poor banana raised; length(title) was accepted | Poor never with a key the user can choose |
| Sort from an allowlist Written as: order(SORTS.fetch(key, default)) | Good only keys you wrote can reach the ORDER BY | Good banana fell back to the newest-first order | Good any column name or direction that comes from a request |
How the answers decide
- “Does the value come from a request, or from a column that a user can write?” Yes, a user typed it somewhere: rules out Output marked as safe, String built into SQL and Sort taken from params. A value from a request must stay data. These forms hand it to the browser or the database as markup or SQL.
- “What does the page or the query need from the value?” The page needs some HTML in it: rules out Plain output tag and adds a caveat to sanitize with a list. Escaping shows markup as text, so it cannot give you markup. An allowlist can, if you keep the list short.
- “What does the page or the query need from the value?” A column name or a sort direction: rules out Bound value, Plain output tag and sanitize with a list and adds a caveat to Sort from an allowlist. Placeholders bind values, not names. A column or direction must come from a list of keys you wrote.
- “What does the page or the query need from the value?” It is plain text or a plain value: adds a caveat to sanitize with a list. Plain text needs no list of tags. Escaping on output and binding in the query are the defaults for a reason.
Read a query for the word you did not write
Any #{} inside a string that reaches where, order, pluck or find_by_sql pastes a value into SQL. Replace it with a placeholder, a hash condition or a list.
Checkpoint
Check: Bound values and allowlisted sort keys
An allowlist in the controller keeps role and centre out of reach
A request is a hash that the client writes, and Active Record will assign any column it has from a hash of attributes. mass assignment: Setting many attributes of a model at once from a hash such as request params. Without an allowlist of keys, a request can set columns, such as a role, that the form never showed. is that use of the hash, and strong parameters: Rails' filter for request input: an action lists the keys it accepts, and only that filtered copy of params is marked permitted, so a model refuses to mass-assign anything else. More in “Controllers, params and strong parameters” is Rails’ guard: a model refuses params that nobody permitted. params.expect(student: [ :name, :bio ]) writes the permission as a list of what a student’s profile form may change. The controllers lesson covers the shapes params.expect: The Rails 8 filter method that requires a key, insists on its shape (a hash, or an array of hashes) and permits the keys named inside it; a body that does not match becomes 400 Bad Request. More in “Controllers, params and strong parameters” accepts; this section covers the one mistake that matters for security.
The mistake is adding :role or :centre_id to the list because an admin form needs it, while the student form shares the action. The lab’s weak controller does exactly that, and its three checks patch a student’s record with extra keys:
# Deliberately weak and for the lab only: never deploy this file.
class StudentsController < ApplicationController
def update
student = Student.find(params[:id])
student.update!(student_params)
redirect_to notices_path
end
private
def student_params
params.expect(student: [ :name, :bio, :role, :centre_id ])
end
end The session below copies the lab over Kaksha, shows the line, runs the checks, then swaps in the fixed controller and runs them again:
# One controller, three checks about mass assignment: the name may change, the role and the centre may not. The first
# run is against the controller that permits :role and :centre_id, and it fails; then the fixed controller replaces
# it and the same checks run again.
require_relative "session"
Session.run(<<~SH, fails: ["bin/rails test test/integration/mass_assignment_test.rb --seed 1"])
cd ../kaksha
cp -R ../lab/. .
grep -n "params.expect" app/controllers/students_controller.rb
bin/rails test test/integration/mass_assignment_test.rb --seed 1
cp ../lab-fixed/app/controllers/students_controller.rb app/controllers/students_controller.rb
grep -n "params.expect" app/controllers/students_controller.rb
bin/rails test test/integration/mass_assignment_test.rb --seed 2
SH Output
$ cd ../kaksha $ cp -R ../lab/. . $ grep -n "params.expect" app/controllers/students_controller.rb 11: params.expect(student: [ :name, :bio, :role, :centre_id ]) $ bin/rails test test/integration/mass_assignment_test.rb --seed … Running 3 tests in a single process (parallelization threshold is 50) Run options: --seed … # Running: .F Failure: MassAssignmentTest#test_a_student_cannot_move_themselves_to_another_centre [test/integration/mass_assignment_test.rb:19]: the centre was set from a request parameter. Expected: 7 Actual: 99 bin/rails test test/integration/mass_assignment_test.rb:16 F Failure: MassAssignmentTest#test_a_student_cannot_make_themselves_an_admin [test/integration/mass_assignment_test.rb:13]: the role was set from a request parameter. Expected: "student" Actual: "admin" bin/rails test test/integration/mass_assignment_test.rb:10 Finished in …s, … runs/s, … assertions/s. 3 runs, 3 assertions, 2 failures, 0 errors, 0 skips [exit status 1] $ cp ../lab-fixed/app/controllers/students_controller.rb app/controllers/students_controller.rb $ grep -n "params.expect" app/controllers/students_controller.rb 10: params.expect(student: [ :name, :bio ]) $ bin/rails test test/integration/mass_assignment_test.rb --seed … Running 3 tests in a single process (parallelization threshold is 50) Run options: --seed … # Running: ... Finished in …s, … runs/s, … assertions/s. 3 runs, 3 assertions, 0 failures, 0 errors, 0 skips
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby mass_assignment_session.rb
On the weak controller the name check passed and the other two failed: the student’s role became admin and their centre became 99. After the fix, which only shortens the list, all three passed. The OWASP cheat sheet gives the same advice: accept an allowlist of fields for the operation and nothing else.
| Attribute | Who may set it | In the student’s list? |
|---|---|---|
name, bio |
The student, about themselves | Yes |
centre_id |
Whoever enrols the student, in a staff-only action | No |
role |
An owner, in an action behind the authorization checks | No |
Key idea
A permitted list says which fields this actor may change in this action, so a field that someone else controls does not belong in it, however convenient.
Checkpoint
Check: params.expect as an allowlist
Brakeman reads your code for these patterns and misses the rest
Brakeman reads the source of a Rails app, without running it, and reports code that looks like a vulnerability. That is static analysis: Examining a program from its source, without running it, such as a scanner that looks for vulnerable code patterns. More in “Local CI, linting and security scans”, and the sections above are its target list: cross-site request forgery, cross-site scripting, SQL injection and mass assignment are all warning types in its documentation. A Rails 8.1 app has the gem and a bin/brakeman already, and bin/ci runs it:
# Run using bin/ci
CI.run do
step "Setup", "bin/setup --skip-server"
step "Style: Ruby", "bin/rubocop"
step "Security: Gem audit", "bin/bundler-audit"
step "Security: Importmap vulnerability audit", "bin/importmap audit"
step "Security: Brakeman code analysis", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error"
step "Tests: Rails", "bin/rails test"
step "Tests: Seeds", "env RAILS_ENV=test bin/rails db:seed:replant"
# Optional: Run system tests
# step "Tests: System", "bin/rails test:system"
# Optional: set a green GitHub commit status to unblock PR merge.
# Requires the `gh` CLI and `gh extension install basecamp/gh-signoff`.
# if success?
# step "Signoff: All systems go. Ready for merge and deploy.", "gh signoff"
# else
# failure "Signoff: CI failed. Do not merge or deploy.", "Fix the issues and try again."
# end
end The generated bin/brakeman adds --ensure-latest, which fails the run when a newer Brakeman exists. The README says Brakeman returns a non-zero exit code when it finds a warning, and the 8.1.0 source uses 3 for warnings and 5 for an outdated version, so a CI step fails for either. Each warning carries three things to read:
| Part | What it tells you | How to use it |
|---|---|---|
| Warning type | The kind of weakness, such as SQL Injection or Cross-Site Scripting | Go to the section of this lesson that fixes it |
| Confidence | High, Medium or Weak: how likely user input reaches the unsafe code | Start at High and work down |
| File and line | Where the pattern is | Open it and follow where the value comes from |
Brakeman can only guess at what code means, so some warnings are false positives and some real defects raise none. It checks patterns, not your rules: a student who can open another student’s record by changing an id (an IDOR: Insecure Direct Object Reference: a vulnerability where an attacker changes an id in a URL or form to access another user's data; fixed by scoping queries to the current tenant. More in “Authorization, roles and multi-tenancy”), or a fee that can be waived twice, are decisions of your own logic that no scanner can call wrong. Tests like this lesson’s weakness checks, and authorization tests for access, cover what it cannot. Brakeman’s README grants non-commercial use without charge and points to its licence for anything else, so read that licence before relying on it at work, and run the tool on your own copy of the app.
Checkpoint
Check: Reading a Brakeman warning
Practise: switch each weakness on, then off
The lab plants five weaknesses in three files, at least one for each of the four defaults, and nine tests watch them. Step through the loop once, then read the recorded run that follows it:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Copy the weak files. cp -R ../lab/. . lays three deliberately weak files over Kaksha: the students controller, the notices controller and the notice view. Each starts with a comment saying it is for the lab only.
- Run the checks. bin/rails test --seed 1 runs nine tests. Six fail on the weak files. Three pass either way: the name may change, sort=title works, and a POST that carries the form token is accepted.
- Read each red check. The XSS check found one bold element, the CSRF check saw the notice count rise, two mass-assignment checks saw role admin and centre 99, and two SQL checks raised StatementInvalid.
- Fix one file. Edit a file so that its check goes green: delete skip_forgery_protection, bind the search value, choose the sort from a list, print the body with the plain output tag, take :role and :centre_id out of the list.
- Run the checks again. With all three fixed files in place, the same nine tests ran with 16 assertions and no failures. After a single fix you rerun them and read what is still red.
- Back to the loop. If a check is still red, the branch leads back to reading it. A fix is finished when its own check is green and the checks that were green stay green.
# The whole lab, weak and then fixed: copy the weak files over Kaksha and run every check (six of the nine fail), then
# copy the fixed files and run the same checks again.
require_relative "session"
Session.run(<<~SH, fails: ["bin/rails test --seed 1"])
cd ../kaksha
cp -R ../lab/. .
bin/rails test --seed 1
cp -R ../lab-fixed/. .
bin/rails test test/integration --seed 1
SH Output
$ cd ../kaksha
$ cp -R ../lab/. .
$ bin/rails test --seed …
Running 9 tests in a single process (parallelization threshold is 50)
Run options: --seed …
# Running:
F
Failure:
XssTest#test_a_notice_body_is_shown_as_text,_not_as_markup [test/integration/xss_test.rb:6]:
Expected exactly 0 elements matching "div.body b", found 1.
Expected: 0
Actual: 1
bin/rails test test/integration/xss_test.rb:4
.F
Failure:
CsrfTest#test_a_post_without_a_token_is_refused [test/integration/csrf_test.rb:15]:
`Notice.count` didn't change by 0, but by 1.
Expected: 2
Actual: 3
bin/rails test test/integration/csrf_test.rb:14
.F
Failure:
MassAssignmentTest#test_a_student_cannot_move_themselves_to_another_centre [test/integration/mass_assignment_test.rb:19]:
the centre was set from a request parameter.
Expected: 7
Actual: 99
bin/rails test test/integration/mass_assignment_test.rb:16
F
Failure:
MassAssignmentTest#test_a_student_cannot_make_themselves_an_admin [test/integration/mass_assignment_test.rb:13]:
the role was set from a request parameter.
Expected: "student"
Actual: "admin"
bin/rails test test/integration/mass_assignment_test.rb:10
E
Error:
SqlTest#test_a_search_for_a_surname_with_an_apostrophe_finds_its_notice:
ActiveRecord::StatementInvalid: SQLite3::SQLException: near "Brien": syntax error:
SELECT "notices".* FROM "notices" WHERE (title LIKE '%O'Brien%')
^
app/controllers/notices_controller.rb:7:in 'Enumerable#map'
app/controllers/notices_controller.rb:7:in 'NoticesController#index'
test/integration/sql_test.rb:5:in 'block in <class:SqlTest>'
bin/rails test test/integration/sql_test.rb:4
E
Error:
SqlTest#test_a_sort_key_that_is_not_on_the_list_falls_back_to_the_default:
ActiveRecord::StatementInvalid: SQLite3::SQLException: no such column: banana:
SELECT "notices".* FROM "notices" WHERE (title LIKE '%%') ORDER BY banana
^
app/controllers/notices_controller.rb:7:in 'Enumerable#map'
app/controllers/notices_controller.rb:7:in 'NoticesController#index'
test/integration/sql_test.rb:11:in 'block in <class:SqlTest>'
bin/rails test test/integration/sql_test.rb:10
.
Finished in …s, … runs/s, … assertions/s.
9 runs, 10 assertions, 4 failures, 2 errors, 0 skips
[exit status 1]
$ cp -R ../lab-fixed/. .
$ bin/rails test test/integration --seed …
Running 9 tests in a single process (parallelization threshold is 50)
Run options: --seed …
# Running:
.........
Finished in …s, … runs/s, … assertions/s.
9 runs, 16 assertions, 0 failures, 0 errors, 0 skips
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby weakness_session.rb
On the weak files nine tests ran with 4 failures and 2 errors. On the fixed files the same nine ran with 16 assertions and no failures. The two errors were StatementInvalid from the search and the sort, and the four failures named the bold element, the notice count, the role and the centre. The weak files the loop starts from are the students controller above, the notices controller and the notice view:
# Deliberately weak and for the lab only: never deploy this file.
class NoticesController < ApplicationController
skip_forgery_protection
def index
notices = Notice.where("title LIKE '%#{params[:q]}%'").order(params[:sort])
render plain: notices.map(&:title).join(", ")
end
def show
@notice = Notice.find(params[:id])
end
def new
end
def create
notice = Notice.create!(params.expect(notice: [ :title, :body ]))
redirect_to notice
end
end <%# Deliberately weak and for the lab only: never deploy this file. %>
<h1><%= @notice.title %></h1>
<div class="body"><%= raw @notice.body %></div> Fix one file at a time
Copy lab/ over a fresh Rails 8.1 app and run bin/rails test --seed 1. Edit notices_controller.rb until the CSRF and SQL checks go green, then the view and the students controller, and compare each with its file in lab-fixed/ only once its check is green.
The self-check asks about this lesson’s runs, from the 86-character token to the nine tests:
Practice that adapts to you
Practise this lesson
18 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: what is the difference between CSRF and XSS, and which one does the authenticity token stop?
CSRF makes a signed-in user’s browser send a request that the site trusts because the browser attached the cookie by itself. XSS makes the site’s own page run a stranger’s script, because the page printed some input as markup. The authenticity token stops CSRF: only pages the site sent contain it, so a form on another site arrives without one and gets 422. It does nothing against XSS, and XSS defeats it, since a script inside the page can read the token. So the defences differ: keep forgery protection on for CSRF and test it with the flag switched on, and keep escaping on for XSS, with sanitize or a Content Security Policy for markup you allow.
The bank of this module takes the same ideas into three cases: an API beside a browser app, a search-and-sort query to assess, and a role that someone wants in a profile form.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
Kaksha serves Turbo forms to browsers and a JSON API to a mobile app that sends a bearer token. A teammate adds skip_forgery_protection to the whole API controller. Say which requests need the authenticity token, which do not, and what you would change.
-
Mid to senior
Assess this action: Notice.where("title LIKE '%#{params[:q]}%'").order("#{params[:sort]} #{params[:dir]}"). Name each problem in order of risk, write the fixed action, and describe a check that fails on the original without sending anything hostile.
-
Mid to senior
A pull request adds :role and :centre_id to the params.expect list of the profile update, because owners edit them in the same form that students use. Explain what is wrong, what you would ask for instead, and the test that proves the fix.
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What does Rails answer to a POST that arrives without the authenticity token?
422. The form's own token, or the same value in the X-CSRF-Token header, is accepted, and the sign-in form gets the same check.
-
Why can a CSRF test go green while proving nothing?
The test environment switches forgery protection off by default, so a test of the token check has to switch it on first.
-
How do CSRF and XSS differ, and which one does the authenticity token stop?
CSRF makes a signed-in user's browser send a request your site trusts because the cookie came along; XSS makes your own page run a stranger's script. The token stops CSRF only: a form on another site cannot read it, but a script running inside your page can.
-
Besides raw and html_safe, which ways of printing in a Rails view let a value act as markup?
The <%== %> tag, render inline:, a link whose address is a javascript: URL, and raw JSON written into a script element.
-
Which query forms bind a value safely, and why does a sort key need an allowlist?
Placeholders and hash conditions bind the value; interpolating it into the SQL string does not. A sort key from the request needs an allowlist, since Rails accepted an expression such as length(title) as the order.
-
What should a params.expect list hold for a student editing their own profile?
Only the fields this actor may change in this action, so attributes such as role and centre_id stay out of it.
-
What does Brakeman report for a weakness it finds, and what can it not see?
A warning type, a confidence, a file and a line. A bug in your business rules shows up only in your own tests.
Key takeaways
- Rails answers a POST without the authenticity token with 422, accepts the form’s token and the
X-CSRF-Tokenheader with 302, and applies the same check to the sign-in form. - The test environment turns forgery protection off by default, so a CSRF test must switch it on; a header-based check is planned for Rails 8.2, not 8.1.
<%= %>escapes the marker<b>tag</b>, whileraw,html_safe,<%==,render inline:, ajavascript:link and raw JSON in a script each let a value act as markup.- Placeholders and hash conditions bind a value, interpolation does not, and a sort key needs an allowlist because Rails accepted
length(title). params.expectlists what this actor may change, soroleandcentre_idstay out of a student’s list.- Brakeman reports a type, a confidence, a file and a line for patterns like these; only your own tests show a business-logic bug.
Terms in this lesson
- authenticity token
- A per-session value that Rails writes into form fields and the csrf-token meta tag. A request that changes data must send it back, and only the site's own pages contain it.
- CSRF
- Cross-site request forgery: a page on another site makes a signed-in user's browser send a request to your site, which trusts it because the browser attached the user's cookie.
- fetch metadata
- Request headers such as Sec-Fetch-Site that modern browsers send to say how the page that started a request relates to the site it targets, so a server can refuse cross-site requests that change data.
- mass assignment
- Setting many attributes of a model at once from a hash such as request params. Without an allowlist of keys, a request can set columns, such as a role, that the form never showed.
- XSS
- Cross-site scripting: a page prints input as markup, so a stranger's script runs inside your own page, where it can act as the user and read everything on the page, including a CSRF token.
References
- Securing Rails Applications (Ruby on Rails)
- Configuring Rails Applications, Action Controller (Ruby on Rails)
- ActionController::RequestForgeryProtection::ClassMethods (Rails 8.1.4 API) (Ruby on Rails)
- Action Controller Overview, Strong Parameters (Ruby on Rails)
- Active Record Query Interface, Conditions (Ruby on Rails)
- Layouts and Rendering in Rails, Using render with inline (Ruby on Rails)
- ERB::Util.json_escape (Rails 8.1.4 API) (Ruby on Rails)
- Rails Versions 7.2.3.1, 8.0.4.1, and 8.1.2.1 have been released (Ruby on Rails)
- Possible XSS vulnerability in Action View tag helpers (GHSA-v55j-83pf-r9cq) (Ruby on Rails)
- Action Pack CHANGELOG (main branch) (Ruby on Rails)
- Cross-Site Request Forgery Prevention Cheat Sheet (OWASP Foundation)
- Cross Site Scripting Prevention Cheat Sheet (OWASP Foundation)
- SQL Injection Prevention Cheat Sheet (OWASP Foundation)
- Mass Assignment Cheat Sheet (OWASP Foundation)
- Brakeman documentation (Brakeman)
- Brakeman README (Brakeman)
- Brakeman 8.1.0 exit codes (lib/brakeman.rb) (Brakeman)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress