Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

4. Forms, sessions and a login

A signed cookie, a before filter, 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.