Middleware
security_headers, use_cors, rate_limit, trust_proxy, before and CSRF.
Written once at the application level and applied to every request:
with $app
.trust_proxy(true)
.security_headers({content_security_policy: "default-src 'self'"})
.use_cors({origins: ["https://example.com"], credentials: true})
.rate_limit({limit: 60, window: 60, redis: $redis})
end withSecurity headers#
security.headers({
content_security_policy: "default-src 'self'",
frame_options: "SAMEORIGIN",
hsts_days: 180
})Written by default:
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-originCSP and HSTS are not written unless asked for. The right CSP depends on the application; an invented value either breaks the page or protects nothing. HSTS makes a site that has not moved to HTTPS unreachable in the browser — and undoing it means waiting for the max-age to expire.
trust_proxy#
$app.trust_proxy(true)By default $req.ip does not read the proxy header (X-Forwarded-For). If it did, a client could invent its own address and every IP-based limit — including the rate limit — would become meaningless.
Turn trust_proxy(true) on only when you really are behind a reverse proxy. Even then the header is read only when the connection comes from a trusted proxy, and the chain is read from the right:
$app.trust_proxy(true) # nginx on the same machine (127.0.0.1, ::1)
$app.trust_proxy({proxies: ["127.0.0.1", "10.0.0.0/8"]}) # connections from these addresses
$app.trust_proxy({hops: 1}) # exactly one proxy in frontnginx's default ($proxy_add_x_forwarded_for) appends the real address to the incoming chain; the leftmost address can be written by the client. So trusted proxy addresses are skipped from the right and the first remaining one is the client:
connection 127.0.0.1, X-Forwarded-For: 6.6.6.6, 203.0.113.9 → $req.ip = 203.0.113.9
connection 198.51.100.7, X-Forwarded-For: 203.0.113.9 → $req.ip = 198.51.100.7$req.forwarded_ip gives the raw leftmost entry; do not trust it.
Rate limiting#
$app.rate_limit({limit: 60, window: 60, redis: $redis})Sixty requests a minute. The limit runs before routing; once it is exceeded the handler does not run at all.
The counter is per IP. Without redis the counter is kept in memory — enough for an application running as one process, but with several processes each keeps its own counter and the real limit multiplies.
The Redis key is lexer:rate:<name>:<ip>. If you restrict key patterns per application with Redis ACLs, change the prefix:
$app.rate_limit({name: "login", limit: 20, window: 300, prefix: "shop:rate:", redis: $redis})before — stopping a request#
Writing the login or IP check into every route is a hole the day one route forgets it. before runs on every request, before static files and routes:
$app.before(func($req, $res) as bool
if not allowed($req.ip) then
$res.status(403).send("forbidden")
return false
end if
return true
end func, {skip: ["/public/"]})
$app.before(login_required, {paths: ["/admin/"], skip: ["/admin/login"]})- If it writes a response (
send,redirect) the request ends there with that response. - If it returns
falsewithout writing one, the answer is403. - If it returns
trueor writes nothing, the request goes on. pathslimits it to those prefixes;skipleaves paths out.
Order: CORS preflight and rate limit → before → static files → routes. Static files sit behind it on purpose: an IP allow list protects /public/ too.
CORS#
$app.use_cors({origins: ["https://example.com"], credentials: true})credentials: true cannot be combined with origins: ["*"]; browsers already reject that combination. For requests carrying credentials you have to name the allowed origins.
CSRF#
The token lives in the session and does not change for its duration — if it were renewed on every request, two tabs open at the same time would invalidate each other's token.
# in the form
$doc.body.add(`
<form method="POST" action="/submit">
` + security.csrf.field() + `
<button>Submit</button>
</form>
`)# in the handler
if not security.csrf.check($req) then
$res.status(403)
$res.send("invalid request")
return
end ifcheck looks at the _csrf form field first and the X-CSRF-Token header second: form submissions use the first, requests made from the browser the second. The comparison is constant-time.
| Call | Where it goes |
|---|---|
security.csrf.token() | the raw value |
security.csrf.field() | a hidden field inside a form |
security.csrf.meta() | a meta tag in the document head |
security.csrf.check($req) | verification |
Rich text#
sanitize_html is only for places where you really do have to store HTML — the output of a rich text editor, for instance.
$doc.body.add(security.sanitize_html($post.body))It works from an allow list: p b strong em i u s a img ul ol li h1-h4 code pre blockquote hr br. Every other tag becomes text and every attribute is dropped. script and style are removed along with their contents. href/src accept only http, https, mailto, tel and relative addresses — javascript: and data: are both ways to run code.
On the SQL side#
There is no escaping function for SQL: values already travel as parameters. The only place parameters do not cover is table and column names:
$column = security.identifier($req.get("sort"), ["name", "price", "stock"])
$db.query("SELECT * FROM products ORDER BY " + $column)identifier does not escape; it picks from an allow list. With no match it returns the first item, so the caller always gets a valid name.
$db.query("SELECT * FROM products WHERE name LIKE ?",
"%" + security.escape_like($search) + "%")escape_like escapes % and _. That is not an injection, but a % typed by the user would return every record.