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,PATCHandDELETEfor any request carrying a session cookie. - The session cookie is signed,
HttpOnly,SameSite=Lax, andSecurewhen the request came over HTTPS. - Outbound HTTPS verifies certificates, with no way to turn it off.
db.query/first/runtake?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
| Call | Returns |
|---|---|
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_field | see 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
paramsbefore it reaches SQL, a URL you fetch, or a filename. <%== %>on anything derived fromparams, 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 asENV["NAME"]. Never inapp.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.