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

Security

What is on by default — escaping, CSRF, signed sessions, TLS — and the four helpers you call yourself.

On by default

  • <%= %> escapes HTML in every template. Only <%== %> does not.
  • CSRF is checked on POST, PUT, PATCH and DELETE for any request carrying a session cookie.
  • The session cookie is signed, HttpOnly, SameSite=Lax, and Secure when the request came over HTTPS.
  • Outbound HTTPS verifies certificates, with no way to turn it off.
  • db.query/first/run take ? placeholders, so bound values are never parsed as SQL.
  • The app runs as its own user in a kernel sandbox with no access to other apps’ files or databases.

Sessions

post "/login" do
  if secure_equal?(params.fetch(:secret), ENV["ADMIN_SECRET"].to_s)
    session[:admin] = "1"
    redirect "/admin"
  else
    status 401
    render :login, error: "That secret is wrong."
  end
end

before "/admin" do
  redirect "/login" unless session[:admin]
end

The session is a cookie named melee_session: base64url(JSON) plus an HMAC-SHA256 signature, keyed on the app’s MELEE_SESSION_SECRET, which the server generates per app. It holds Strings only — session[:n] = 5 stores "5", and reading it back gives "5".

Signed is not encrypted. The visitor can read every value in their own session. Put an id or a flag there, never a password, a token or anything you would not show them. A tampered or unsigned cookie is treated as no session at all, silently.

The cookie is sent only when the request changed the session, lasts 30 days, and lives at Path=/. Keep it small: browsers drop cookies over about 4 KB, and everything in the session travels on every request.

CSRF

Every form that changes something needs the field:

<form method="post" action="/admin/notes">
  <%= csrf_field %>
  <input type="text" name="text" required>
  <button>Add</button>
</form>

csrf_field -> String is the hidden input, already HTML and not escaped by <%= %>. csrf_token -> String is the raw value if you need to put it in a header from JavaScript (X-CSRF-Token is accepted in place of the _csrf field).

The check runs before filters and before the route. A missing or wrong token is 403 Missing or invalid CSRF token, and the route never runs.

One thing to know: the check only applies when the request carries a session cookie. A POST from a visitor who has no session is not checked, because there is no session to ride on. This is the right rule for cross-site request forgery, but it means an unauthenticated write endpoint is protected by nothing — put those behind a token or a login.

For a webhook, opt out at the route:

post "/webhook", csrf: false do
  halt 403 unless secure_equal?(request.header("X-Hook-Secret"), ENV["HOOK_SECRET"].to_s)
  text "ok"
end

The helpers

CallReturns
secure_equal?(a, b)true / false. Constant-time for equal-length inputs; false when either side is nil or the lengths differ
random_token(bytes = 24)String, URL-safe base64 without padding — 32 characters for the default 24 bytes
h(value)String, HTML-escaped (& < > " '). Templates do this for you; use it when you build HTML in Ruby
csrf_token / csrf_fieldsee above

Use secure_equal? for every comparison against a secret — a password, a token in a URL, a webhook signature. == on Strings leaks the answer through timing.

get "/d/:token" do
  halt 404 unless secure_equal?(params.fetch(:token), setting("display_token"))
  render :display, layout: false
end

random_token is the right source for anything unguessable: display tokens, invite codes, API keys. Store it with setting (see database.md) so it can be rotated.

Things to get right yourself

  • Validate every value out of params before it reaches SQL, a URL you fetch, or a filename.
  • <%== %> on anything derived from params, the database or an HTTP response is an XSS. Use <%= %>.
  • Do not build SQL by interpolation, ever. There is no escaping helper because there is no safe one.
  • HTTP.get(params.fetch(:url)) lets a visitor make the server fetch a URL of their choosing. Check it against a list you control first.
  • Secrets belong in melee env, read as ENV["NAME"]. Never in app.rb, never in the repository.

What does not exist

No passkeys or WebAuthn, no password hashing helper, no rate limiting, no per-user authorisation layer, no encrypted cookie, no signed URL helper, no CORS handling, and no Content-Security-Policy unless you set the header yourself.