Lesson 13 / 13
Security Defaults
Measures why the anti-forgery token is kept in the session, default escaping in the template and an allow list against mass assignment, and closes the course with a table of the stays of thirteen lessons.
Published Updated
In this lesson
- Measures the stay of the anti-forgery token and shows why it is kept in the session
- Shows that the token of one client is invalid in the session of another client
- Measures default escaping in the template and an allow list against mass assignment with a small helper
- Measures the difference between applying a secure default in a single layer and applying it twice
Contents
The previous lesson measured the isolation of tests from one another. This lesson takes up the last application service of the course: the security defaults an application has to offer even when it does nothing on behalf of the user. Three were chosen: the anti-forgery token, escaping in the template and an allow list against mass assignment. The first question of the lesson is why the anti-forgery token is kept in the session.
The measuring axis has been the same since the start of the course: how many requests does this value stay for? The four stays: this request, next request, session, persistent. The question asked in M12/K03 was a different one: who can change a result, the server, the environment, the client or a third party. The token goes to the client and returns inside the form; but the answer to that question does not decide the stay of the token. Its stay is the session, because the session is where it is written.
Core
The measurements rest on the same small request loop. The file is the course core and is given as it is.
# stay.rb — a small request loop and a helper that classifies how many requests a value stays for
# Flash: a value written in this request is carried to the next request only.
class Flash
def initialize(incoming)
@incoming = incoming
@fresh = {}
end
def [](key) = @fresh.fetch(key) { @incoming[key] }
def []=(key, value)
@fresh[key] = value
end
def passed_on = @fresh
end
# Every request gets a new context: params, flash and the request store start empty each time.
class Context
attr_reader :app, :params, :session, :flash, :request_store
def initialize(app, session)
@app = app
@params = {}
@session = session
@flash = Flash.new(session.delete(:flash) || {})
@request_store = {}
end
end
# The application store is the same across all requests and all clients;
# the session is chosen per client.
class App
attr_reader :store
def initialize
@sessions = Hash.new { |h, client| h[client] = {} }
@store = { products: {}, schema: { products: %i[name price] }, templates: {},
queue: [], cache: {}, counter: 0 }
end
def request(client)
context = Context.new(self, @sessions[client])
result = yield context
context.session[:flash] = context.flash.passed_on unless context.flash.passed_on.empty?
result
end
end
# Rule: request 1 (client A) writes the value; requests 2 and 3 (A) and request 4 (B) read it.
# If B sees it, persistent; if A's third request sees it, session; if only the second
# request sees it, next; if none sees it, this_request.
def stay(write, read)
app = App.new
first = app.request("A") { |b| write.call(b); read.call(b) }
raise ArgumentError, "value could not be read in the request that wrote it" if first.nil?
seen = [["A", 2], ["A", 3], ["B", 4]].map do |client, order|
app.request(client) { |b| read.call(b) }.nil? ? "--" : "#{client}#{order}"
end
kind = if seen[2] != "--" then "persistent"
elsif seen[1] != "--" then "session"
elsif seen[0] != "--" then "next"
else "this_request"
end
[kind, ["A1", *seen].join(" "), first]
end
This block is source; the file is written to disk and pulled in with require_relative. The core is not a framework but a model of one. It carries one difference: Product.lookup in the core returns the stored object itself, while in many mappers every read builds a new object from the row. The measurements in this lesson do not depend on this difference.
The Anti-Forgery Token
Cross-site request forgery (CSRF) is another site sending a request from the user’s browser on behalf of the user. The defense is simple: a value produced by the server is placed in the form, and the submitted value is compared with the value the server stored. This is the anti-forgery token. In the pattern measured in this lesson (the synchronizer token), the server has to store the token somewhere for the comparison to work; there are also stateless patterns that do not store the token, and this lesson does not measure them. Where? The place it is written decides the stay of the token. The excerpt gives two helpers and the form of a result line: csrf_token, which produces the token, escape, which escapes text, and report, which prints the measurement line.
# security.rb — token, escaping and the result line
require_relative "stay"
def escape(text) = text.to_s.gsub("&", "&").gsub("<", "<").gsub(">", ">").gsub('"', """)
def csrf_token(b) = b.session[:csrf] ||= "token-#{b.app.store[:counter] += 1}"
def report(name, kind, evidence, value)
printf("%-16s%-14s%-13s%s\n", name, kind, evidence, value.inspect)
end
This block is source as well. csrf_token returns the value in the session if there is one; if not, it produces one and writes it to the session. The measurement produces the token in the first request and reads it from the session in later requests.
# token.rb — how many requests does the csrf token stay for
require_relative "security"
write = ->(b) { csrf_token(b) }
read = ->(b) { b.session[:csrf] }
report("csrf token", *stay(write, read))
csrf token session A1 A2 A3 -- "token-1"
The token appeared in all three requests of A and did not appear in the request of B. This is the session stay. The token goes to a form, but on the server it stays in the session of one client. If it were in the shared store of the application, it would be persistent and every client would see the same value; then the token of one client would be valid for everyone and the comparison would prove nothing. AS10. In the core the token derives from a counter; a real token must be unpredictable. This measurement measures only the stay, not the security of the production.
The token goes away with the session. If the token in the session is deleted, the next request produces a new token; the old one no longer exists on the server. In the measurement A gets a token, the token in its session is deleted and A asks for a token again; B also asks for its own token.
# renewal.rb — what happens when the token in the session is deleted
require_relative "security"
app = App.new
first = app.request("A") { |b| csrf_token(b) }
app.request("A") { |b| b.session.delete(:csrf) }
fresh = app.request("A") { |b| csrf_token(b) }
other = app.request("B") { |b| csrf_token(b) }
p [first, fresh, other]
["token-1", "token-2", "token-3"]
Each new token got a separate value, and because the counter is shared across clients the token of B came out different from those of A. The token exists as long as it stays in the session; a token deleted from the session is not on the server, so a request arriving with the old value does not match in the comparison (this was not tried in this measurement).
Does One Client’s Token Pass for Another?
The consequence of the stay is that it separates by client. In the same application A, B and A again make requests; each asks for the token in its own session.
# clients.rb — the token in the order A, B, A
require_relative "security"
app = App.new
a1 = app.request("A") { |b| csrf_token(b) }
b1 = app.request("B") { |b| csrf_token(b) }
a2 = app.request("A") { |b| csrf_token(b) }
puts [a1, b1, a2].inspect
["token-1", "token-2", "token-1"]
The second request of A received the first token; B received a separate one. The comparison is added next: if the submitted value matches the value in the session, the request is accepted.
# compare.rb — does the submitted token match the one in the session
require_relative "security"
def csrf_valid?(b, submitted) = !submitted.nil? && submitted == b.session[:csrf]
app = App.new
in_form = app.request("A") { |b| csrf_token(b) }
app.request("B") { |b| csrf_token(b) }
p app.request("A") { |b| csrf_valid?(b, in_form) }
p app.request("B") { |b| csrf_valid?(b, in_form) }
p app.request("A") { |b| csrf_valid?(b, nil) }
true false false
The own token of A is valid in the session of A. The same value is invalid in the session of B, and a request that submits no token at all is also invalid. There is no value another site could put in the form: the token is bound only to the session of this client. AS11. Here the comparison is plain equality; a real application compares in constant time and can renew the token in every form. These were not measured.
Default Escaping in the Template
The second default was measured in the Views and Templates lesson: the template passes local values through escape when placing them. The text the user wrote appears on the page as text, not as markup. The safe behavior is the default; turning escaping off must be requested separately. The same helper is measured here with harmless markup.
# escaping.rb — default escaping
require_relative "security"
p escape("Tom & Jerry")
p escape("<b>pen</b>")
"Tom & Jerry" "<b>pen</b>"
Three characters turned into their counterparts. This measurement covers only these three characters. The definition of the helper also converts the double quote; this measurement did not show it. The single quote is not in the definition. AS12. A real view layer handles other contexts separately, for example the address of a link or the inside of a script; the core does not model them.
An Allow List Against Mass Assignment
The third default prevents the fields coming from a form from being written directly to a record. Even if the form has only name and price, the request can carry an extra field. If the record takes all the fields, the client also writes a field that is not in the form. This is called mass assignment. The defense is an allow list: only the fields whose names are in the list pass.
# allow.rb — allow list
require_relative "security"
def permitted(params, *keys) = params.slice(*keys)
incoming = { name: "pen", price: 12, admin: true }
p permitted(incoming, :name, :price)
{name: "pen", price: 12}
The admin field did not enter the result. An allow list is not a deny list: everything not in the list, including a field added tomorrow, stays out automatically. AS13. The helper filters only a single-level hash; nested fields need a separate rule, and it was not written in this lesson.
What happens if a field named in the list is missing from the request? Call the helper with a request that has no price field.
# missing.rb — a field in the list but not in the request
require_relative "security"
def permitted(params, *keys) = params.slice(*keys)
p permitted({ name: "pen" }, :name, :price)
p permitted({ name: "pen" }, :name, :price).key?(:price)
{name: "pen"}
false
The missing field took its place in the result not with a nil value but by not being there at all. An allow list does not make a field required; it only draws the boundary of the fields that may pass. A check that requires a field to be present is a separate rule, and it was measured as validation in the Data Layer topic.
The Bounding Measurement
A secure default must be applied only once and in the right layer. If it is applied twice, security does not increase, the output breaks. What happens if the same text is escaped twice?
# double.rb — escaping twice
require_relative "security"
p escape("<")
p escape(escape("<"))
"<" "&lt;"
The second escaping also changed the & character of the first escaping; the page shows < instead of <. A security default therefore stays in a single layer: the template escapes, the controller does not escape, and the record is not stored in its escaped form. In whichever layer a value is to be escaped, it is not touched again after that layer. The same rule applies to partial templates; this breakage was measured in the Views and Templates lesson.
Summary
- The anti-forgery token goes to the client but its stay is the session: it appears in three requests of the same client and does not appear in another client.
- Clients making requests in the order A, B, A received
"token-1","token-2","token-1"; the token of one client is invalid in the session of another. - Default escaping in the template shows user text as text on the page.
- An allow list does not write a field that is not in the form to the record.
- Mass assignment is closed with the allow list: a missing field is not present as
nilbut not present at all; the list is not a deny list, and every field not in the list stays out. - A secure default is applied in a single layer:
escape(escape("<"))gives"&lt;".
Course Wrap-Up
Every lesson of the course asked the same question: how many requests does this value stay for? Thirteen measurements answered this question with an observation. The table below gathers all of them by stay.
| Lesson | Measured value | Stay | Bounding measurement |
|---|---|---|---|
| Convention over Configuration | template name |
this request | the name is produced; the template’s existence is not checked |
| Routing | path parameter |
this request | the client carries the value to the next request |
| Controllers and Filters | flash |
next request | a flash rewritten on every request appears in the session |
| Views and Templates | compiled template |
persistent | a second compilation keeps the first |
| Object–Relational Mapping | record |
persistent | the model returns the stored object itself |
| Migrations | schema column |
persistent | the old row has no new column |
| Relationships and Validations | validation error |
this request | the error is not stored with the record |
| The Query Interface and the N+1 Problem | query cache |
this request | a nil result is stored too |
| Authentication and Authorization | user id |
session | a value that cannot be read in the request that wrote it raises ArgumentError |
| Background Jobs | queued job |
persistent | the result depends on where it is written |
| Caching | fragment cache |
persistent | stays stale while the key does not change |
| Testing | controller variable |
this request | a shared app leaks between tests |
| Security Defaults | csrf token |
session | escape(escape("<")) gives "&lt;" |
The distribution of stays: 5 this request · 1 next request · 2 session · 5 persistent. The table carries one finding of the course: a stay depends not on the type of a value but on the place it is written and the behavior of the code that reads it. The same name can hide two stays: the query cache was this request, the fragment cache was persistent; the same flash became a session value when it was rewritten on every request.
Two inferences can be drawn. The first is that all five persistent values stay in the shared store of the application and do not separate clients; that is why each carried a bound. The second is that the session and next request stays are bound to the client; they are not visible outside the client that wrote them.
AS14. The table is only a classification of thirteen measurements; the distribution reflects the choice of the measured values, not the distribution of all the values in an application. The stay cells were taken from the statement in the Summary section of each lesson.
The core model carried a fact here as well: Product.lookup returns the stored object itself, while in many mappers every read builds a new object from the row. The table is therefore the behavior of the model of a framework, not the behavior of a framework.
The course closes here. The next course takes up the packages that make up a Ruby project, dependency management and development tools. The same rule remains valid there too: nothing is guessed, everything is measured.
To keep your progress and take notes, Log in
My notes
Log in to take notes.