Middleware
security_headers, use_cors, rate_limit, trust_proxy 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; then it is your own nginx writing that header.
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.
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.