Security · the model in plain words
Safe by default
dhtml is built so that the safe thing is what happens when you do nothing. A page cannot print unescaped HTML, because there is no way to ask for it. A query cannot be built from a string, because a value is never part of the SQL text. A page cannot call another site, because nothing is allowed until you list it. There is no shell to leave open.
You do not configure any of this. It is how the engine works, and each rule below is a test in the repository.
§ 01 · Output
Escaping
- Every {{ }} is escaped for the place it lands: text, attribute, URL, URL piece, script, style. The page becomes one template, and the engine does the escaping.
- There is no raw filter. The only values that are not escaped are the result of markdown (built from a safe subset: raw HTML in the source is shown as text, unsafe addresses are dropped) and json (with <, > and & escaped).
- In a URL, only http, https, mailto and relative addresses pass. javascript: becomes #ZgotmplZ.
- Data cannot choose what loads or runs: a value at the start of a <script src>, <iframe src>, <form action> or <link href>, or anywhere in srcdoc or <meta http-equiv>, is a compile error. Put a fixed path first.
- Tags cannot be placed inside an HTML tag, an attribute, or a <script> or <style> element, so a template cannot build markup or code out of logic.
- What a model writes is untrusted text and is escaped like any other value.
The default Content-Security-Policy forbids inline scripts. dhtml will not add 'unsafe-inline' for you; code goes in a file and receives data through data-* attributes.
§ 02 · Data
What the SQL checker refuses
Every {{ }} inside <d:query> or <d:exec> is sent to SQLite as a separate bound parameter. The SQL text is checked when the project is compiled, and these are errors:
- A value used as a name: after FROM, JOIN, ORDER BY, GROUP BY, AS, inside quotes, or glued to other text.
- A parameter written by hand: ?, ?1, :name, @name, $name.
- In a <d:query>: anything but one SELECT (or WITH … SELECT). INSERT, UPDATE, DELETE, CREATE, PRAGMA, comments, a second statement. The connection itself refuses writes as well.
- In a <d:exec>: schema changes, PRAGMA, ATTACH, RETURNING, and any name starting with sqlite_ or _dhtml_.
- Reading the password hash, a session token or a one-time link column, and writing the account tables, from a query.
Only fields declared with <d:validate> exist in an action. A visitor who adds user_id or is_admin to a form sends a field that is dropped, and reading an undeclared form.other is a compile error. That is the answer to mass assignment.
§ 03 · Requests
Forms, CSRF, uploads
- Every form that posts to this site gets a hidden CSRF token when the page is compiled. Every non-GET action checks it, together with Origin and Sec-Fetch-Site. A missing or wrong token answers 403. The token is an HMAC under a key in server/data/secret.key, tied to the visitor's session once there is one, and shown differently on every page view.
- Uploads are limited to a fixed list of types (png, jpg, jpeg, gif, webp, pdf, txt, csv). HTML, SVG, JS and XML are never accepted. A file is saved as server/data/uploads/<32 random hex>.<ext> and never under its own name, so ../../x and .htaccess cannot happen. It is served with a fixed content type, nosniff and a sandboxing CSP.
- Rate limits: login attempts, sign-ups and reset requests are counted per address (30 in 15 minutes) and per account (10 in 15 minutes). Every action can carry its own limit.
§ 04 · Accounts
Cookies, sessions, passwords
- The session cookie is HttpOnly, SameSite=Lax, and over HTTPS Secure with a __Host- prefix, so a neighbouring subdomain cannot set it. It holds 32 random bytes; the database stores only their SHA-256.
- A session ends after 7 days without use, or 30 days at most, at logout, and when the password changes or is reset. Every login makes a new session and a new CSRF cookie.
- Passwords are PBKDF2-SHA256 with 600,000 rounds and a random salt, compared in constant time. An unknown address costs the same hashing as a wrong password. Passwords, tokens and hashes are never logged.
- Sign-up gives the same answer whether or not the address exists. A link in mail is never used by opening it: it opens a page with a button, since mail scanners open links. Reset links are single-use, expire, and are stored hashed.
- Links in mail are built from url in [app], never from the request, so nobody can aim them elsewhere.
§ 05 · Permissions
Everything outside is declared
Anything that reaches outside the app is listed in dhtml.config, in the open, and the check on every upload shows what a project actually uses.
[http]allow = api.stripe.com [webhooks]stripe = stripe env:STRIPE_WEBHOOK_SECRET- [http]. <d:http> can call only the hosts in allow. Calls use https on the standard port, follow no redirects, and use no proxy from the environment. A host name that points at a private, loopback, link-local (cloud metadata) or reserved address is refused at connect time, however DNS answers, unless private = on. The address is built from fixed text, so a value can never add a host, a path segment or a parameter. Keys are written as env:NAME and read from .env; nothing about a call is logged.
- [webhooks]. A signed endpoint is checked over the raw body, in constant time, before anything is read from it. A bad, missing or old signature answers 401 and the action does not run. A cookie or a token does not take the place of the signature. dedupe makes an event run once.
- AI calls are allowed only in actions that cost something to abuse: not GET, and with a ratelimit of at most 120 a minute. Keys never appear in a response, a log or an error message; prompts and answers are never logged.
- Secrets live in .env. A literal key or password in dhtml.config is an error, and errors on those lines never repeat the value.
- Shell is off, and it stays off on dhtml.net. Nothing in the template language can run a command.
§ 06 · Reach
What can be reached
Only public/ and your routes. Never served, whatever is on disk: anything under server/ or bin/, dhtml.config, .env, any name starting with a dot, and any .dhtml file. Links are never followed. Names that mean something to file systems (CON, NUL, drive letters, trailing dots) are refused. A visitor never sees a stack trace, an absolute path, a file name or a hint: only the status text.
Every response dhtml writes carries X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: strict-origin-when-cross-origin and the closed Content-Security-Policy. Strict-Transport-Security is added over HTTPS.
§ 07 · Good practice
Running it well
- HTTPS is on. Every site on dhtml.net, on its own address or your domain, is served over HTTPS with a certificate that is renewed for you.
- Keep private files out of public/ and /uploads. Uploads get random, unguessable names; anyone who has a name can fetch the file.
- Treat model answers as data. dhtml keeps your data apart from the instruction and escapes the answer; do not let an answer decide what your app does.
- Read the result of every upload. It explains errors with file, line and hint. Visitors see only the status text.
- Check your site once. Open /dhtml.config and /server/data on it: both answer 404.
- Keep backups with the same care as the database. A backup holds password hashes; dhtml writes it with mode 0600 in a 0700 folder and leaves the signing key out.
§ 08 · Reporting
Where to report a problem
Please do not open a public issue. Write to security@dhtml.net with what you found and where, the steps or a small project that reproduces it, and the address of the site. You get an answer within a few days. Confirmed problems are fixed first, and an advisory is published with the fix, crediting you if you wish. dhtml.net, the documentation, this website and the starters are covered.
