4. Forms, sessions and a login
A signed cookie, a
beforefilter, and what CSRF is actually stopping.
Right now anyone who finds the board can edit it. Move the editing behind a shared secret: a public page that
lists open notes, and an /admin page that needs a sign-in.
The session
session is a Hash-like object backed by a cookie that melee signs with HMAC. The browser can read what is
in it but cannot change it without the signature failing, so it is safe for “who is this”, and wrong for
anything secret.
session[:admin] = "1"
session[:admin] # => "1"
session.delete(:admin)
session.clear
Values are Strings. Assigning an Integer stores "1"; assigning nil deletes the key. If you want a
number back out, .to_i it.
The login
get "/login" do
render :login, error: nil
end
post "/login" do
if secure_equal?(params.fetch(:secret), ENV["NOTES_SECRET"].to_s)
session[:admin] = "1"
redirect "/admin"
else
status 401
render :login, error: "That secret is wrong."
end
end
secure_equal? compares in constant time — it takes the same amount of time whether the first character is
wrong or only the last. Comparing secrets with == leaks, slowly, how much of a guess was right. Use it for
tokens, secrets and signatures; ordinary == is fine for everything else.
The secret comes from the environment, never from the source. In development that is your shell:
NOTES_SECRET=letmein melee dev
In production it is melee env NOTES_SECRET <value>, which step 6 covers.
views/login.erb:
<%# locals: (error: nil) %>
<% if error %><p class="error"><%= error %></p><% end %>
<form method="post" action="/login">
<%= csrf_field %>
<input type="password" name="secret" required autofocus>
<button>Sign in</button>
</form>
(error: nil) declares the local as optional with a default, which is why render :login, error: nil and a
hypothetical render :login would both be valid.
The filter
before "/admin" do
redirect "/login" unless session[:admin]
end
before takes a path prefix, not a list of action names. This one runs before any route whose path starts
with /admin, and because redirect never returns, a visitor without a session never reaches the route.
before with no prefix runs before everything.
That one filter is what protects all three admin routes:
get "/admin" do
render :admin, notes: db.query("SELECT id, text, done FROM notes ORDER BY done, id DESC")
end
post "/admin/notes" do
text = params.fetch(:text).strip
halt 400, "A note needs some text" if text.empty?
db.run "INSERT INTO notes (text, created_at) VALUES (?, ?)", text, Time.now.to_i
redirect "/admin"
end
post "/admin/notes/:id/done" do
db.run "UPDATE notes SET done = 1 - done WHERE id = ?", params.fetch(:id).to_i
redirect "/admin"
end
And the public page keeps only the list:
get "/" do
render :index, notes: db.query("SELECT id, text FROM notes WHERE done = 0 ORDER BY id DESC")
end
The templates that go with them
views/index.erb loses the form it had in step 3 — the public page only reads now:
<%# locals: (notes:) %>
<ul class="notes">
<% notes.each do |note| %>
<%== partial :note, note: note %>
<% end %>
</ul>
<p class="count"><%= notes.size %> open. <a href="/admin">Admin</a></p>
and views/_note.erb loses its “Done” button along with it:
<%# locals: (note:) %>
<li><%= note["text"] %></li>
views/admin.erb is new, and is where both forms now live:
<%# locals: (notes:) %>
<form method="post" action="/admin/notes">
<%= csrf_field %>
<input type="text" name="text" placeholder="Something to remember" required>
<button>Add</button>
</form>
<ul class="notes">
<% notes.each do |note| %>
<li class="<%= note["done"].to_i == 1 ? "done" : "open" %>">
<%= note["text"] %>
<form method="post" action="/admin/notes/<%= note["id"] %>/done">
<%= csrf_field %>
<button><%= note["done"].to_i == 1 ? "Reopen" : "Done" %></button>
</form>
</li>
<% end %>
</ul>
Note note["done"].to_i == 1 rather than if note["done"]: SQLite has no boolean, so the column comes back
as 0 or 1, and 0 is truthy in Ruby.
CSRF, and why every form needs csrf_field
Your session cookie is sent by the browser on every request to your app — including a request triggered by a
form on somebody else’s website. Without a check, a page anywhere could contain a hidden form posting to
https://notes.example/admin/notes and your browser would obligingly submit it, signed in.
So melee checks every POST, PUT, PATCH and DELETE that arrives with a session cookie for a _csrf
field matching a token in that session. csrf_field renders it:
<%= csrf_field %>
<input type="hidden" name="_csrf" value="WW-pl2fc9HCsnir-AoAiBQ">
A request without it gets a 403 and never reaches your route. The attacker’s page cannot read the token,
because it cannot read your cookies.
For a route that is deliberately called by something that is not a browser form — a webhook, an API endpoint authenticated some other way — exempt it explicitly:
post "/hooks/stripe", csrf: false do
# ...
end
Only do that when something else is authenticating the caller.
Try it
GET /admin -> 303 (0.1 ms) # no session, bounced to /login
GET /login -> 200 (0.3 ms)
POST /login -> 303 (0.2 ms) # signed in
POST /admin/notes -> 303 (0.2 ms)
POST /login -> 401 (0.1 ms) # wrong secret
POST /admin/notes -> 403 (0.1 ms) # no _csrf
Next
Work that happens on its own — the part with no equivalent in a normal Ruby app.
Full detail: Security and Logging people in.