Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 15 minutes
  • Examples run with Ruby 4.0.7 and rails 8.1.4
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 3 interview questions.

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:

The authenticity token in production mode: refused, accepted, refused, accepted Ruby · csrf_probe.rb
# 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:

Interactive · message by message How the authenticity token refuses a forged POST Step through a teacher publishing a notice, then through a form on another site that posts to Kaksha with her cookie.
How the authenticity token refuses a forged POST, step 6 of 6. Messages so far: Browser to Kaksha: GET form; Kaksha to Browser: 200 + token; Browser to Kaksha: POST + token; Kaksha to Browser: 302 to notices; Other site to Kaksha: POST and cookie; Kaksha to Other site: 422 refused.Browserhas tokenKaksharefusedOther siteno tokenGET form200 + tokenPOST + token302 to noticesPOST and cookie422 refused

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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:

lab/test/integration/csrf_test.rb: the flag switched on for its own tests Ruby · lab/test/integration/csrf_test.rb
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: with the generated test environment, and with the flag on Ruby · sessions/csrf_session.rb
# 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

  1. Question 1 of 2 In the production-mode run, a POST to the notice form carries a made-up authenticity_token. What does Kaksha answer?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 422, and the create action never runs

    Forgery protection raises ActionController::InvalidAuthenticityToken when a non-GET request carries no valid token. The run saw 422 for no token and for a made-up one, and 302 for the form's token and for the X-CSRF-Token header.

  2. Question 2 of 2 A test posts to the notices controller without a token and expects 422, but it gets 302 although the controller keeps forgery protection on. Why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The generated test environment sets allow_forgery_protection to false, so the test must set it to true

    The configuring guide says allow_forgery_protection is false in the test environment and true in every other one. A test about forgery protection sets the flag to true in setup, as the run's second test does.

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 &lt;. 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:

xss_views/weak/_notice.html.erb: escaping switched off in four places ERB · xss_views/weak/_notice.html.erb
<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>
xss_views/fixed/_notice.html.erb: the same notice, with the defaults back ERB · xss_views/fixed/_notice.html.erb
<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:

One notice, rendered by the weak partial and by the fixed one Ruby · xss_marker.rb
# 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">&lt;b&gt;tag&lt;/b&gt;</p>
    <p id="html-safe">&lt;b&gt;tag&lt;/b&gt;</p>
    <p id="double-equals">&lt;b&gt;tag&lt;/b&gt;</p>
    <p id="link">Website</p>
    <div id="data" data-notice="{&quot;title&quot;:&quot;Fee reminder&quot;,&quot;body&quot;:&quot;&lt;b&gt;tag&lt;/b&gt;&quot;,&quot;website&quot;:&quot;javascript:marker()&quot;}"></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 &lt;b&gt;tag&lt;/b&gt;, which a browser shows as the characters typed, the link became its text, and the data sits in an attribute where &quot; and &lt; 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

  1. Question 1 of 2 In the xss_marker run the weak partial printed the body three ways. Which of these also printed the marker <b>tag</b> as markup?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: <%== notice.body %>

    raw, html_safe and the double-equals tag all skip escaping. The weak partial produced three bold elements, the fixed one none.

  2. Question 2 of 2 Which of these hand a notice's data to JavaScript without letting a value act as markup? Choose all that apply.

    Choose every answer that is right.

    Show the answer: Question 2 of 2

    Answer:

    • A data- attribute printed with <%= JSON.generate(notice.to_h) %>
    • A script tag that prints raw json_escape(json)

    Put the data in an attribute and let ERB escape it, or escape the JSON itself with json_escape before raw. Marking JSON as safe without escaping it leaves angle brackets raw.

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 O'Brien Ruby · sql_forms.rb
# 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.

Interactive · answer and compare Which form fits a value from the request? Answer two questions about the value. The table rules out the forms that would let it act as markup or SQL.
Does the value come from a request, or from a column that a user can write?
What does the page or the query need from the value?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Three ways to print a value and four ways to put it in a query, against what each did in the recorded runs
Form Value stays data What the run showed Use it when
Plain output tag Written as: <%= value %> Good the marker came out as &lt;b&gt;tag&lt;/b&gt;, 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

  1. Question 1 of 2 A parent searches for O'Brien. Which form of the query did SQLite refuse with a syntax error near "Brien"?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Notice.where("title LIKE '%#{q}%'") with the value pasted into the string

    Only the interpolated string put the raw value into the SQL text. A value that can end a quoted string can also add SQL after it, which is why interpolation is the pattern to remove.

  2. Question 2 of 2 In the run, order("length(title)") was accepted and order("banana") reached the database. What follows for sorting by a key from params?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Map the key through a list of keys you wrote, with a default for any other value

    A column name is not a value, so nothing can bind it. The safe form lets the user choose among keys you offer, and anything else falls back to a default.

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:

lab/app/controllers/students_controller.rb: a permitted list that is too long Ruby · lab/app/controllers/students_controller.rb
# 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:

Three mass-assignment checks, red on the long list and green on the short one Ruby · sessions/mass_assignment_session.rb
# 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

  1. One question A student's profile action permits [ :name, :bio, :role, :centre_id ] and a request sets student[role] to admin. What does the weak controller do?

    Choose one answer.

    Show the answer: One question

    Answer: It saves role admin, because params.expect returns every key on the list

    The list is the allowlist, so a field on it can be set by anyone who can reach the action. Role and centre belong in an action that only the right actor can reach.

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:

config/ci.rb as Rails 8.1 generates it: the Brakeman step stops the build on a warning Ruby · kaksha/config/ci.rb
# 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

  1. One question Which of these would a Brakeman scan most likely not report?

    Choose one answer.

    Show the answer: One question

    Answer: A student who can read another student's record by changing the id in the URL

    Brakeman matches code patterns. Access rules and other business logic need tests of your own.

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:

Interactive · stage by stage Switch a weakness on, watch the checks, fix it Step through the lab's two recorded runs. The branch is the learner's loop when a check is still red.
Switch a weakness on, watch the checks, fix it, step 6 of 6. Showing The weak files (role, SQL, raw, CSRF), The weakness checks (4 failures, 2 errors), One fix (less trust in input), Checks again (no failures).still red: read itThe weak filesrole, SQL, raw, CSRFThe weakness checks4 failures, 2 errorsOne fixless trust in inputChecks againno failuresstill red: read it

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.

All nine weakness checks: six red on the weak files, then green on the fixed ones Ruby · sessions/weakness_session.rb
# 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:

lab/app/controllers/notices_controller.rb: three weaknesses in one controller Ruby · lab/app/controllers/notices_controller.rb
# 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
lab/app/views/notices/show.html.erb: the notice body printed with raw ERB · lab/app/views/notices/show.html.erb
<%# 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.

  1. Question 1 of 18 How many characters long was the authenticity token in the recorded production run?

    Type a number.

    Show the answer: Question 1 of 18

    Answer: 86 characters

    The hidden field and the csrf-token meta tag each held 86 characters, on the notice form and on the sign-in form.

  2. Question 2 of 18 What status did production answer to a POST that carried no authenticity token?

    Choose one answer.

    Show the answer: Question 2 of 18

    Answer: 422

    A POST without a valid token is refused with 422; the same POST with the form's token got 302.

  3. Question 3 of 18 A test posts without a token and expects a redirect, and it passes in the test environment. What does that show about forgery protection?

    Choose one answer.

    Show the answer: Question 3 of 18

    Answer: Nothing, because the test environment has the protection switched off

    With allow_forgery_protection false, every POST is accepted. A meaningful test sets the flag to true and checks both a refused POST and an accepted one.

  4. Question 4 of 18 A notice page prints a teacher's text with raw. What happens to the authenticity token on that page?

    Choose one answer.

    Show the answer: Question 4 of 18

    Answer: A script in the injected text can read it, so XSS defeats the CSRF defence

    Closing an XSS hole is part of keeping CSRF protection meaningful.

  5. Question 5 of 18 How many bold elements from the notice body reached the page in the weak partial?

    Type a number.

    Show the answer: Question 5 of 18

    Answer: 3 elements

    raw, html_safe and the double-equals tag each printed the marker as markup. The fixed partial produced none.

  6. Question 6 of 18 Which template line prints a user's notice body as plain text?

    Choose one answer.

    Show the answer: Question 6 of 18

    Answer: <%= notice.body %>

    Only the plain output tag escapes the value. The other three skip escaping on purpose, which is why they are the lines to search for when you read a template.

  7. Question 7 of 18 A notice's website is javascript:marker(). What did link_to "Website", notice.website print in the weak partial?
    Show the answer: Question 7 of 18

    Answer: A link whose href was javascript:marker(), as given

    An address from a user needs a check of its scheme. The helper in the lesson builds a link only for http and https and prints the text otherwise.

  8. Question 8 of 18 Notice data must reach JavaScript. Which approach follows the Rails documentation and keeps values from acting as markup?

    Choose one answer.

    Show the answer: Question 8 of 18

    Answer: Print it in a data- attribute with the plain output tag, or print raw json_escape(json) inside the script

    Either escape the JSON characters with json_escape, or let ERB's attribute escaping do the work. Marking a string safe is not escaping it.

  9. Question 9 of 18 Which way of searching for O'Brien raised ActiveRecord::StatementInvalid in the run?

    Choose one answer.

    Show the answer: Question 9 of 18

    Answer: A where call with the surname interpolated into the SQL string

    The same name worked through the hash, ? and named forms, and failed only where it was pasted into the SQL.

  10. Question 10 of 18 In the run, which sort value did order accept without error, although it is a function call?

    Choose one answer.

    Show the answer: Question 10 of 18

    Answer: length(title)

    The check refuses some raw SQL and accepts other shapes. The allowlist in the controller decides what may be sorted.

  11. Question 11 of 18 A controller uses order(SORTS.fetch(params[:sort], SORTS["newest"])). A request sends sort=banana. What SQL ORDER BY does it produce?

    Choose one answer.

    Show the answer: Question 11 of 18

    Answer: ORDER BY id DESC, the default for any key not on the list

    The allowlist maps the keys you offer to orders you wrote, and the default covers every other value.

  12. Question 12 of 18 A search binds the value with a placeholder, as in where("title LIKE ?", "%#{q}%"). A parent types a lone percent sign. What does the query do?

    Choose one answer.

    Show the answer: Question 12 of 18

    Answer: It matches every title, because the percent sign is still a LIKE wildcard; sanitize_sql_like escapes it

    A placeholder keeps the statement intact but cannot decide what a pattern means. Escape wildcards with sanitize_sql_like when the user should search for the literal characters.

  13. Question 13 of 18 params.expect(student: [ :name, :bio ]) receives student[name], student[bio] and student[role]. What does it return?

    Choose one answer.

    Show the answer: Question 13 of 18

    Answer: The name and the bio only; role is left out of the returned copy

    The list is the allowlist. The fixed controller listed two attributes and the student's role stayed what it was.

  14. Question 14 of 18 In the weak run, what did the checks find after a student sent student[role]=admin and student[centre_id]=99?

    Choose one answer.

    Show the answer: Question 14 of 18

    Answer: The role was admin and the centre was 99

    Each extra field on the list is a field any caller of the action can set.

  15. Question 15 of 18 Owners need to change a student's role. Where should that live?

    Choose one answer.

    Show the answer: Question 15 of 18

    Answer: In a separate action that only an owner can reach, with its own short list

    One list per actor and action keeps each list short, and the role action sits behind authorization.

  16. Question 16 of 18 What does a High confidence Brakeman warning mean?

    Choose one answer.

    Show the answer: Question 16 of 18

    Answer: It is a simple warning, or user input is very likely used in an unsafe way

    Confidence estimates how likely the pattern is a real problem. Start with High and work down.

  17. Question 17 of 18 A CI step runs bin/brakeman and stops the build with exit status 3. What does that status mean?

    Choose one answer.

    Show the answer: Question 17 of 18

    Answer: The scan reported warnings

    Different failures use different statuses, so a CI log tells a warning from an out-of-date scanner.

  18. Question 18 of 18 A teacher can open any student's fee page by changing the id in the URL. Brakeman reports nothing. Why, and what finds it?

    Choose one answer.

    Show the answer: Question 18 of 18

    Answer: The code is well-formed; only a test of your own access rules can find it

    Business-logic bugs such as a missing ownership check need authorization tests. A scanner covers patterns only.

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.

  1. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

  3. 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.

    Model answer, Pro. First step and follow-ups

Flashcards · 7 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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-Token header 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>, while raw, html_safe, <%==, render inline:, a javascript: 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.expect lists what this actor may change, so role and centre_id stay 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.