Security#
Bialet keeps the framework small, and that extends to security: there is no magic that escapes your output for you, and no ORM that hides your SQL. The good news is that 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:
Escape untrusted output —
{{ }}does not escape. Use.safe.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 |
|
You must call it — never automatic |
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 does not escape HTML by default. When you
display user-generated content, database values, or URL parameters, you must
escape them yourself. This is deliberate: it keeps inline HTML readable and
fast, and it makes the escaping explicit where it matters.
var userInput = "<script>alert('xss')</script>"
// WRONG — XSS vulnerability
var dangerous = <p>{{ userInput }}</p>
// Renders: <p><script>alert('xss')</script></p>
// CORRECT — HTML characters are escaped
var safe = <p>{{ userInput.safe }}</p>
// Renders: <p><script>alert('xss')</script></p>
.safe replaces &, <, >, ", and ' with their HTML entities. The
equivalent helper Util.htmlEscape(str) returns the same result as a plain
string:
var escaped = Util.htmlEscape(userInput)
⚠️ Pitfall: Forgetting
.safeis an XSS vulnerability. Every string that comes from user input, the database, or the URL must be escaped before it reaches HTML. There is no automatic escaping — not even for values pulled fromRequest.post().
Escaping applies in attributes too, not just element bodies:
// WRONG — break out of the attribute, inject an event handler
<a href="{{ url }}">link</a>
// CORRECT
<a href="{{ url.safe }}">link</a>
If you generate HTML in Wren code rather than inline blocks, escape with
Util.htmlEscape() before concatenating it into a template string.
⚠️ Pitfall:
.safeescapes for HTML text and attribute contexts. It does 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.
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 ..
⚠️ 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.
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
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#
Escape every string from user input, the database, or the URL with
.safebefore interpolating into HTML.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.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.