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 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
| 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.
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
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.