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:

  1. Trust {{ }} to escape untrusted output — interpolation escapes by default. Mark intentionally-raw HTML with HtmlNode or .raw.

  2. Parameterize SQL — never build a query from string concatenation.

  3. Validate state-changing requests — check Session.csrfOk on POSTs.

Built-in Protections#

Protection

Where

Notes

Parameterized SQL

Backtick Query objects

String interpolation is rejected by the compiler

HTML escaping

{{ }} interpolation

Automatic; HTML literals and HtmlNode stay raw

CSRF tokens

Session.csrf / Session.csrfOk

Constant-time comparison

HttpOnly + SameSite cookies

Cookie.set defaults

Secure added automatically behind TLS

Private file blocking

Server

_ and . prefixed files return 403

Salted password hashes

Util.hash / Util.verify

Never store plaintext

Constant-time compare

Util.secureEquals

Used by csrfOk, available to you

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>&lt;script&gt;alert(&#x27;xss&#x27;)&lt;/script&gt;</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 .safe inside {{ }}. Since interpolation already escapes, {{ userInput.safe }} escapes the text twice (&amp;lt; instead of &lt;). Drop .safe from 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 an HtmlNode.

  • Nested {{ }} blocks — their result is an HtmlNode, so nesting {{ outer }} around inner markup does not double-escape it.

  • .raw — "<b>bold</b>".raw marks 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>&lt;b&gt;a&lt;/b&gt;</li><li>b</li>

⚠️ Pitfall: HtmlNode and .raw assert that the string is already safe. They do not sanitize JavaScript, CSS, or URLs. Never interpolate untrusted input into a <script> block or a javascript: 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 BY columns. Column names cannot be placeholders. Use the order() 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(...) or query.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.csrf returns a <input type="hidden" name="_bialet_csrf" ...> field. Put it inside every form that changes state.

  • Session.csrfOk returns true when the submitted token matches the stored one. Check it on every POST (and PUT/DELETE via Request.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.

Sessions & Cookies#

Cookies set through Cookie.set get secure defaults:

Attribute

Default

Notes

Path

/

HttpOnly

true

Not readable from JavaScript

SameSite

Lax

Blocks most cross-site cookie sending

Secure

auto

Added when the request arrives over HTTPS

You can override any of these by passing an options map, for example Cookie.set("pref", "dark", {"SameSite": "Strict"}). Be careful overriding HttpOnly or Secure — both are there for a reason.

Sessions are stored in SQLite in the BIALET_SESSION table. The session cookie (BIALETSESSID by default) holds only a 40-character random ID; all data lives server-side. Clear a session with Session.destroy().

The table is keyed on (id, key): Session.set replaces the row for a key instead of appending, and Session.get always returns the latest value written. Older databases that lack the primary key are rebuilt in place on startup.

⚠️ Pitfall: Request.post(name) returns null when the key is missing. Calling string methods on it crashes the request. Always guard with || "" or a null check:

var msg = Request.post("msg") || ""

Cookie names and values are validated (Util.cookieToken, Util.cookieValue) and reject characters that could break out of the Set-Cookie header, so a user-controlled value cannot inject headers.

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 413 before 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 -u and the body cap minus ~8 KB of multipart framing. At the proxy, set client_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_timeout in nginx, RequestReadTimeout ... MinRate in Apache) so stalled uploads are cut off.

  • Buffer the full request before forwarding (proxy_request_buffering on in 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.sqlite3 contains 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.sqlite3 is 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_keys is 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 File class 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#

  1. Interpolate untrusted values with {{ }} and rely on the automatic escaping; mark intentional markup with HtmlNode or .raw.

  2. Never build SQL from strings — use ? placeholders and pass values as parameters.

  3. Use .order() with an allow-list for sortable columns.

  4. Put {{ session.csrf }} in every state-changing form and check session.csrfOk on POST.

  5. Guard Request.post(...) with || "" before string operations.

  6. Store passwords with Util.hash / Util.verify, never plaintext.

  7. Run behind a reverse proxy with TLS; bind Bialet to 127.0.0.1.

  8. Cap the request body and set a total read deadline at the proxy; buffer the full body before forwarding.

  9. Keep private files (_-prefixed) out of shared backups and repositories.

  10. Use -m / -M / -c / -C to cap memory and CPU.

  11. Validate and convert input with Num.fromString() before numeric math.