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:

  1. Escape untrusted output{{ }} does not escape. Use .safe.

  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

.safe, Util.htmlEscape()

You must call it — never automatic

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 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>&lt;script&gt;alert(&#x27;xss&#x27;)&lt;/script&gt;</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 .safe is 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 from Request.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: .safe escapes for HTML text and attribute contexts. It does 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().

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

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

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_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. Escape every string from user input, the database, or the URL with .safe before interpolating into HTML.

  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. Keep private files (_-prefixed) out of shared backups and repositories.

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

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