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 usual place for it is a <meta> tag in the layout, which melee new writes:

<meta name="csrf-token" content="<%= csrf_token %>">
fetch("/notes", { method: "POST", headers: { "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content } })

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.

Keys, signatures and shared secrets

For Web Push, ES256 tokens, ECDH and the like, OpenSSL is there on both runtimes with one shape: keys are raw bytes, not PEM or DER, and an ECDSA signature is raw r || s (64 bytes on P-256, which is what every JWT and WebAuthn verifier expects). The compiled binary has this from Spinel’s openssl package; under melee dev and melee test melee adds the same names to the openssl gem, so the code below runs unchanged in both places.

key = OpenSSL::PKey::EC.generate("prime256v1")           # or .from_private_bytes("prime256v1", bytes)
key.private_key_bytes                                    # 32 bytes: store these, base64url'd
key.public_key_bytes                                     # 65 bytes, uncompressed: what a peer or a browser gets
sig = key.sign_raw("SHA256", signing_input)              # 64 bytes; ES256 is b64u(sig)
verifier = OpenSSL::PKey::EC.from_public_bytes("prime256v1", key.public_key_bytes)
verifier.verify_raw("SHA256", sig, signing_input)        # true; false for anything wrong, never an error
shared = key.dh_compute_key(peer_public_bytes)           # 32 bytes, the X coordinate
OpenSSL::KDF.hkdf(shared, salt: salt, info: info, length: 16, hash: "SHA256")

Curves are OpenSSL’s short names — prime256v1 (P-256), secp384r1, secp521r1; P-256 is not one. Digests are SHA256 and SHA1, as a String or Symbol; anything else raises OpenSSL::Digest::DigestError on both runtimes. Public keys are the uncompressed point only: a compressed one, or one that is not a point on the curve, is refused where it arrives (from_public_bytes and dh_compute_key raise OpenSSL::PKey::ECError), which matters for the off-curve case — an ECDH against it leaks the private key to whoever chose it. Build keys with generate and the two from_*_bytes, not EC.new.

OpenSSL::HMAC.digest, OpenSSL::Digest::SHA256.digest and OpenSSL::Cipher for aes-128-gcm and aes-256-gcm behave the same on both runtimes (the stdlib’s tests hold them to it). Everything else the openssl gem offers — RSA, other cipher modes, PEM and DER parsing, OpenSSL::PKCS5 — exists under melee dev only; the binary has none of it, so a program that uses it passes every local test and fails when deployed. For passwords see the authentication guide.

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.