Security#
Bialet keeps the framework small, and that extends to security: {{ }}
interpolation escapes output by default, and there is no ORM hiding your SQL —
you write the queries yourself. The sharp edges are explicit and few. This page
is the central reference for keeping a Bialet app safe.
Three rules cover most real-world damage:
Trust
{{ }}to escape untrusted output — interpolation escapes by default. Mark intentionally-raw HTML withHtmlNodeor.raw.Parameterize SQL — never build a query from string concatenation.
Validate state-changing requests — check
Session.csrfOkon POSTs.
Built-in Protections#
Protection |
Where |
Notes |
|---|---|---|
Parameterized SQL |
Backtick Query objects |
String interpolation is rejected by the compiler |
HTML escaping |
|
Automatic; HTML literals and |
CSRF tokens |
|
Constant-time comparison |
HttpOnly + SameSite cookies |
|
|
Private file blocking |
Server |
|
Salted password hashes |
|
Never store plaintext |
Constant-time compare |
|
Used by |
Cross-Site Scripting (XSS)#
The {{ }} interpolation escapes HTML by default. Any plain value you
interpolate — a string from user input, the database, or a URL parameter — is
escaped before it reaches the page, in attribute values as well as element
bodies:
var userInput = "<script>alert('xss')</script>"
var safe = <p>{{ userInput }}</p>
// Renders: <p><script>alert('xss')</script></p>
Escaping replaces &, <, >, ", and ' with their HTML entities. The
standalone helper Util.htmlEscape(str) returns the same result:
var escaped = Util.htmlEscape(userInput)
⚠️ Pitfall: Do not add
.safeinside{{ }}. Since interpolation already escapes,{{ userInput.safe }}escapes the text twice (&lt;instead of<). Drop.safefrom templates.
Intentionally raw HTML#
Values that are already rendered HTML are marked with an HtmlNode and are
left untouched by interpolation:
HTML string literals —
<div>...</div>evaluates to anHtmlNode.Nested
{{ }}blocks — their result is anHtmlNode, so nesting{{ outer }}around inner markup does not double-escape it..raw—"<b>bold</b>".rawmarks a string as safe HTML.HtmlNode.new(...)— wrap a runtime string explicitly.
var userText = "<script>alert('xss')</script>"
var card = <div class="card"><p>kept <b>raw</b></p></div>
var safeMarkup = "<em>raw</em>".raw
<p>{{ userText }}</p> // escaped
<p>{{ card }}</p> // rendered as written
<p>{{ safeMarkup }}</p> // rendered as written
// A string that really does contain markup must be marked safe:
var html = "<b>bold</b>".raw
<p>{{ html }}</p>
Sequences are flattened by interpolation, so building a list of fragments
with map escapes each item’s user data while keeping the surrounding
markup:
var items = ["<b>a</b>", "b"]
<ul>{{ items.map{|x| <li>{{ x }}</li> } }}</ul>
// <li><b>a</b></li><li>b</li>
⚠️ Pitfall:
HtmlNodeand.rawassert that the string is already safe. They do not sanitize JavaScript, CSS, or URLs. Never interpolate untrusted input into a<script>block or ajavascript:URL — restructure the page so it cannot happen.
Markdown rendering#
The built-in Markdown parser escapes HTML only inside code blocks and inline
code. Raw HTML in paragraphs, headings, and lists passes through unescaped,
and link/image URLs are not validated. Rendering untrusted input with
Markdown.html() can therefore produce XSS:
// WRONG — raw HTML passes through to the output
var content = Markdown.html("<script>alert('xss')</script>")
// WRONG — javascript: links are not filtered
var link = Markdown.html("[x](javascript:alert('xss'))")
Render only Markdown you trust, or sanitize the output before serving it. See the Markdown guide for the full syntax and security notes.
SQL Injection#
Backtick Query objects use prepared statements with ? placeholders.
The query string and the values are sent to SQLite separately, so values
cannot change the structure of the SQL.
// CORRECT — parameterized
`SELECT * FROM users WHERE name = ? AND active = 1`.fetch(name)
// CORRECT — multiple parameters
`INSERT INTO messages (text, session) VALUES (?, ?)`.query(msg, Session.id)
// WRONG — never build SQL from strings
`SELECT * FROM users WHERE name = '%(name)'`.fetch
You cannot concatenate or interpolate into a backtick Query object — the compiler rejects it. That constraint removes the most common injection path entirely. Stay inside it.
Two places tempt people out of it:
Dynamic
ORDER BYcolumns. Column names cannot be placeholders. Use theorder()method with an allow-list of column names:`SELECT * FROM products`.order("price", "ASC", ["price", "name", "createdAt"], 50).fetch
LIMIT/OFFSET. These accept only integers. Parameterize them — never concatenate user input:`SELECT * FROM products LIMIT ? OFFSET ?`.fetch([limit, offset])
⚠️ Pitfall: database values come back as strings. Before using one as a number, convert with
Num.fromString(...)orquery.num. This is a correctness concern, but it also stops “it works when I type a number” bugs from becoming injection-adjacent string handling.
See the Database and Advanced Routing guides for more Query examples.
CSRF Protection#
Bialet ships built-in CSRF tokens via Session. A token is generated per
session, stored server-side, and rendered as a hidden form field. On
submission, the token in the request is compared against the stored token
using Util.secureEquals — a constant-time comparison that does not leak
timing information.
var session = Session.new()
var verified
if (Request.isPost) {
verified = session.csrfOk
if (verified) {
`INSERT INTO messages (text) VALUES (?)`.query(Request.post("msg") || "")
}
}
return <form method="POST">
{{ session.csrf }}
<input name="msg">
<button>Submit</button>
</form>
Session.csrfreturns a<input type="hidden" name="_bialet_csrf" ...>field. Put it inside every form that changes state.Session.csrfOkreturnstruewhen the submitted token matches the stored one. Check it on every POST (and PUT/DELETE viaRequest.method).
⚠️ Pitfall: the session cookie is
SameSite=Lax, which already stops most cross-site POSTs. Do not treat that as enough. SameSite is advisory (older browsers ignore it) and it does nothing for same-site subdomain attacks. Keep the explicit token check on any form that writes data.
Password Storage#
Use Util.hash and Util.verify instead of rolling your own:
var stored = Util.hash(password) // store this in the database
var ok = Util.verify(password, stored) // true if the password matches
hash produces a salted SHA-256 digest in hash$salt format. The salt is
random per password, so identical passwords produce different stored values,
and the stored format is all you need to keep — verify parses it back out.
Be honest about the limits: this is salted SHA-256, a fast hash. It is fine for internal tools and low-stakes apps. For user-facing accounts that face real attackers, consider a deliberately slow KDF (bcrypt, scrypt, argon2) implemented at the app or proxy layer.
Secure Configuration & Deployment#
TLS#
Bialet speaks HTTP/1.0 and has no native HTTPS. Run it behind a reverse
proxy (nginx, Apache, or Caddy) for TLS. This is the recommended production
setup, and it also lets you bind Bialet to 127.0.0.1 so only the proxy
talks to it. See Deployment for configs.
When requests arrive through a proxy, Bialet detects HTTPS from the
X-Forwarded-Proto / Forwarded headers. That is how the Secure cookie
attribute is decided. Only set those headers from a proxy you trust — never
forward user-supplied ones unchanged.
Reverse Proxy Hardening#
Bialet is single-threaded: it accepts and serves one connection at a time in a blocking loop. The reverse proxy is the layer that protects it from hostile clients. Configure the proxy to:
Cap the request body. Bialet rejects bodies over its own cap with
413before parsing: the cap is the smaller of-b(default 128 KB) and a memory-safe ceiling set by-m(soft limit / 512). Keep these defaults for forms that send only fields; raise them only when your app genuinely accepts large uploads, and raise-u(the per-file upload cap) alongside — the effective upload limit is the smaller of-uand the body cap minus ~8 KB of multipart framing. At the proxy, setclient_max_body_size(nginx),LimitRequestBody(Apache), or an equivalent to match your app’s real upload limit — a cap above it just lets oversized bodies reach the framework’s own check, and a cap below it breaks legitimate uploads.Enforce a total body-read deadline. Bialet’s 5-second socket timeout is per
recv()call, so a peer that dribbles bytes slowly can hold a connection open indefinitely. Set a total read timeout at the proxy (client_body_timeoutin nginx,RequestReadTimeout ... MinRatein Apache) so stalled uploads are cut off.Buffer the full request before forwarding (
proxy_request_buffering onin nginx; the default in most proxies). This keeps a slow client from holding the upstream socket.Deny private files at the proxy too. Bialet already returns 403 for
_/.-prefixed paths, but the proxy can return 403 first — a second layer in case an app or a planted symlink ever serves such a file.
See Deployment for ready-to-paste nginx, Apache, and Caddy configs with these settings.
⚠️ Pitfall: a proxy body cap that is higher than your app’s needs re-opens the CPU-exhaustion and memory-exhaustion risks above. Keep it as low as your uploads actually require.
Private Files#
Files and directories whose name starts with _ or . are forbidden from
direct HTTP access — the server returns 403. This is what protects
_app.wren, _migration.wren, _db.sqlite3, and your configuration from
being downloaded. Name anything private with a leading _ or ..
The single exception is the RFC 8615 .well-known namespace: a URL whose
first segment is exactly .well-known is public, and everything below it is
served like any other file. That is required by ACME/Let’s Encrypt, OAuth 2.0
and OpenID Connect, WebFinger, app links, and security.txt. The exemption is
case-sensitive, applies only as the first path segment (/foo/.well-known/x
is still 403), and does not survive path traversal
(/.well-known/../.env is still 403). See
Advanced Routing. Never place
secrets under .well-known — it is world-readable by definition.
⚠️ Pitfall:
_db.sqlite3contains your data and your session table. It is already blocked from HTTP access, but the app directory on disk is not a sandbox. Keep it out of version control backups you share, and restrict filesystem access to the user that runs the server.
Database File Permissions#
Bialet creates _db.sqlite3 with SQLite’s default mode, 0666 & ~umask.
Under the common umask 022 that yields a world-readable 0644 file, so
any local user on a shared host can read the whole database — uploaded
files, password hashes, and cached remote modules included. The server does
not tighten the mode after opening the file.
Run Bialet with a restrictive umask so the file is created 0600:
umask 0077
bialet -p 7001 /www/myapp
Under systemd, set UMask= on the service instead:
[Service]
UMask=0077
If the database already exists, tighten it with chmod. Redo this after
any migration or restore that recreates the file:
chmod 600 /www/myapp/_db.sqlite3
⚠️ Pitfall: a world-readable
_db.sqlite3is as exposed as a leaked backup — no HTTP request is needed to read it. Restrict the app root to the user that runs the server and keep the file out of shared backups.
Resource Limits#
The server can enforce memory and CPU ceilings per app with CLI flags, which limits the blast radius of a runaway script or a memory-exhaustion attack:
bialet -p 7001 -m 1024 -M 2048 -c 25 -C 50 /www/myapp
-m/-M— soft / hard memory limit in MB-c/-C— soft / hard CPU limit in percent-b— max request body in KB (default 128 KB)
Request bodies are rejected with 413 Payload Too Large before parsing when
they exceed the smaller of the -b limit and a memory-safe ceiling (-m /
512, about 256 KB at the default 128 MB soft limit). This keeps worst-case body
parsing (one Wren string per line) inside the enforced RLIMIT_AS budget, so
a crafted body cannot crash the server child or stall it for minutes.
See the resource limits table in Deployment for defaults.
Other Defaults#
SQLite
PRAGMA foreign_keysis on by default, so orphaned rows cannot accumulate through a careless delete.File uploads are stored inside the SQLite database, not on disk — they are never served from a writable directory. See the
Fileclass in the Reference.Validate user input before trusting it: check types and ranges, convert with
Num.fromString(), and reject values you did not expect. Do this in your domain classes before saving, and again in the form handler.
Security Checklist#
Interpolate untrusted values with
{{ }}and rely on the automatic escaping; mark intentional markup withHtmlNodeor.raw.Never build SQL from strings — use
?placeholders and pass values as parameters.Use
.order()with an allow-list for sortable columns.Put
{{ session.csrf }}in every state-changing form and checksession.csrfOkon POST.Guard
Request.post(...)with|| ""before string operations.Store passwords with
Util.hash/Util.verify, never plaintext.Run behind a reverse proxy with TLS; bind Bialet to
127.0.0.1.Cap the request body and set a total read deadline at the proxy; buffer the full body before forwarding.
Keep private files (
_-prefixed) out of shared backups and repositories.Use
-m/-M/-c/-Cto cap memory and CPU.Validate and convert input with
Num.fromString()before numeric math.