Secure by Default: The Defaults DaloyJS Ships So You Don't Have To Remember Them
A tour of the always-on defenses in the DaloyJS request path — bounded body reads, prototype-pollution-safe JSON, CRLF sanitization, path-traversal rejection, request timeouts, problem+json with prod redaction — plus the opt-in upgrades worth turning on today.
Hi, I'm Devlin. Ten years of fullstack work, half of those spent reading pentest reports and quietly thinking, "we already knewthat one". Most security incidents I've had a hand in cleaning up were not exotic. They were not a 0-day in a cryptography library. They were a body limit that nobody set, a JSON parser that happily accepted __proto__, a response timeout that didn't exist, or a stack trace cheerfully being shipped to an attacker as application/json.
So when we sat down to design DaloyJS, we made a rule and stuck to it: the boring, well-understood defenses must be on by default. You should be able to type new App()with empty arguments and already be in a place where most of the OWASP "low effort, high impact" checklist is satisfied before you write a single route.
This post is the tour. Part one is the always-on stuff — the defenses the framework enforces whether you remembered to ask for them or not. Part two is the opt-in upgrades that are worth turning on today, in five lines each. Coffee in Oslo is expensive, so I'll be quick.
The empty constructor is already a security policy
Before we even tour the defenses individually, look at the smallest possible app and notice what's implicit:
Two named arguments. The rest is invisible — and intentional. The framework is, at this point, already enforcing six different things for you. Let's walk them.
Part 1 — Always on, no flag required
1. Bounded body reads
Default 1 MiB cap. Checked against Content-Length first (fail-fast), then enforced again while streaming bytes (defense-in-depth). No, you cannot trick it with a missing or lying Content-Length.
Body size limits are the most boring of all the boring defenses, which is exactly why so many production apps forget them. The classic version of this bug is "an attacker uploads a multi-gigabyte JSON body and your event loop falls over while V8 tries to parse it". Or even simpler: your memory bill goes up, silently, because nobody capped it.
DaloyJS caps every body at bodyLimitBytes (default 1 MiB), and the response when you go over is an application/problem+json document — not a stack trace, not an HTML page, not a string with the literal word undefined in it:
2. Prototype-pollution-safe JSON
A JSON reviver removes __proto__, constructor, and prototype from every nested object before your handler runs. Yes, even when the attacker hides it under five levels of arrays.
Prototype pollution is the bug that keeps making serious headlines, mostly because the JSON parser in most apps is the absolutely unmodified browser one, and the browser one does not care that __proto__is a magical key in JavaScript. DaloyJS installs a reviver in front of every JSON parse, and it strips the three keys you don't want walking into your object graph:
If the body is malformed JSON, you get a generic 400 Bad Request with the message "Invalid JSON". You do not get a parser error message that describes your internal parser's mood. We don't give attackers free oracles.
3. CRLF and header sanitization
CR, LF, and NUL bytes cannot enter a response header. The check runs when you build middleware like basicAuth(), csrf(), or session() — so injection attempts fail loudly at boot, not silently in prod.
Response-splitting attacks aren't the front page of OWASP anymore because frameworks finally started sanitizing headers — but only some frameworks, and only on some paths. We do it everywhere a header is constructed from configuration: cookie names, realms, paths, domains. If your config contains \\r\\nby accident — say, because a yaml file was pasted weird — your app refuses to start. That's a feature.
4. Path-traversal rejection in the router
Paths containing /../, trailing /.., or // are rejected before any handler runs. Static-file middlewares built on top of the router inherit this for free.
You can argue all day whether your framework should be normalizing paths or whether your reverse proxy should — but in practice both of you should, because you don't know which one of you is going to be misconfigured next quarter. We reject the obvious traversal shapes at routing time. It costs you nothing and removes a whole category of "oops, we served /etc/passwd" stories.
5. Request timeouts
Default 30s. Set 0 to disable (please don't). Handlers can read ctx.request.signal — a real AbortSignal — to cancel downstream fetches and DB queries cleanly.
The vast majority of slow-loris-shaped attacks aren't even attacks. They're a buggy mobile client on a 2G connection in a tunnel, holding open one of your sockets for nine minutes. A per-request timeout is a load-shedding tool first and a security tool second, and either way you want it on. Default is 30 seconds, which is generous enough for real work and tight enough that you won't accidentally exhaust a connection pool.
6. problem+json with production redaction
All errors serialize to a stable, machine-readable shape: { type, title, status, detail?, instance? }. In production, the detail field is stripped from 5xx responses so stack traces do not leak.
Here's the same 500 from the same handler, dev versus prod, side by side. The difference is one environment variable:
The instancefield is a request URN, which means your on-call engineer can grep for it in logs without the user ever being shown anything sensitive. The dev version keeps the stack because dev you wants to know. Prod you doesn't leak.
Part 2 — Opt-in upgrades worth turning on today
These don't live in the default constructor because they are policy decisions, not safety nets. But every one of them is a single import and three lines of configuration. There's no reason not to.
secureHeaders() — CSP nonce + Trusted Types
Default CSP is default-src 'self'; frame-ancestors 'none'. Pass nonce: true to mint a per-request nonce, exposed as ctx.state.cspNonce. Trusted Types is one flag.
If your app renders any HTML at all — even one server-rendered template, even one error page — you want CSP, and you want it with a real nonce, not the unsafe-inlineescape hatch that half the internet runs on. Here's how you get the strict version, with Trusted Types on top to harden DOM sinks against XSS:
The nonce is generated using WebCrypto on every request, so the same code works on Node, Bun, Deno, Workers, and Vercel Edge without a polyfill. The first time I turned this on in a real app I found four scripts I didn't know were inline. That is what CSP is for.
csrf() — double-submit and Fetch-Metadata, together
Three strategies: double-submit (cookie + header, timing-safe compared), fetch-metadata (Sec-Fetch-Site with Origin/Referer fallback), or both. Cookie defaults to __Host-daloy.csrf with SameSite=Lax + Secure.
CSRF is one of those topics where the "right" answer keeps moving. Five years ago it was "double-submit, please". Today modern browsers send Sec-Fetch-Site which lets you reject cross-origin writes without any token at all. The reasonable production answer is: do both, because legacy clients exist and defense-in-depth is free:
The cookie name uses the __Host- prefix on purpose. It forces Secure, no Domain=, and Path=/ — three rules that the browser enforces for you instead of trusting you to remember. We like making the browser do our job.
basicAuth() — when you just need a wall in front of /admin
Bring your own verify() and use timingSafeEqual on both fields. Returning an object stamps ctx.state.user; returning false yields 401.
Not every internal endpoint deserves a full OAuth pipeline. Some of them just need a wall, the kind your reverse proxy used to provide. basicAuth() is for those:
The two important details are the order of operations and the comparison function. Always run both comparisons, and always use timingSafeEqual — not because someone is going to time-attack your admin panel from across the planet, but because writing security code with === is how you develop unfortunate habits that follow you into other systems.
session() — signed cookies, key rotation, GDPR-friendly defaults
HMAC-SHA256 signed. Pass an array of secrets to rotate signing keys without logging users out. saveUninitialized defaults to false so no cookie is set until the session actually has data.
The session middleware has one parameter you must provide — a signing secret — and it accepts an array so you can rotate without a flag day. The default cookie shape is opinionated, in the way I wish more frameworks were:
Notice state.session.regenerate() on login. Rotating the session ID at any privilege boundary kills session fixation outright. The default in-memory store is fine for development; for production, swap in your Redis/KV store of choice through the same SessionStore interface.
rateLimit() — with a real Redis store for multi-instance apps
Standard X-RateLimit-* headers on every response. The Redis store uses an atomic Lua script for INCR + PEXPIRE so two instances cannot race past the limit. Fail-open by default, fail-closed if you prefer.
In-memory rate limits are a lie the moment you have two app instances behind a load balancer. The right move is a shared store, and the right shared store for "count things in a window" is Redis with atomic operations:
Two details worth pausing on. First, trustProxyHeaders is false by default — because if you turn it on without a sanitizing proxy in front, an attacker can spoof x-forwarded-for and rate-limit themselves into invisibility. Second, the Redis adapter is fail-openby default: if Redis is down, requests are allowed through. That's the right choice for most apps (you don't want a Redis blip to take you offline), but you can flip it to fail-closed with one option if you'd rather block on uncertainty.
The defaults table, for the busy reader
If you only remember one section of this post, remember this one.
| Defense | State | Default | Failure |
|---|---|---|---|
| bodyLimitBytes | always on | 1 MiB | 413 |
| safeJsonParse | always on | strips __proto__/constructor/prototype | 400 |
| header sanitization | always on | no CR/LF/NUL in headers | throws at boot |
| path traversal | always on | rejects /../, /.., // | 404 |
| requestTimeoutMs | always on | 30s | 408 |
| problem+json | always on | 5xx detail redacted in prod | RFC 9457 |
| secureHeaders() | opt-in | strict CSP + HSTS + COOP/CORP | — |
| csrf() | opt-in | double-submit, fetch-metadata, or both | 403 |
| basicAuth() | opt-in | timing-safe verify callback | 401 |
| session() | opt-in | __Host- cookie, HMAC-SHA256, key rotation | — |
| rateLimit() + Redis | opt-in | atomic Lua, fail-open | 429 |
The honest part
None of this makes your app un-hackable. There is no constructor argument for "please make my business logic correct", and if there was, I'd have shipped one to my younger self by registered mail. What these defaults do get you is the comfort of knowing that the boringbugs — the ones we have collectively known about for fifteen years, the ones that show up on every pentest report under "Medium" because the auditor is tired — those are already handled. Your brain is free to spend its limited budget on the actually-hard parts of your product.
If you want to go deeper, the security docs have the full surface area and the threat-model notes. And if you find a gap, please tell us — SECURITY.md in the repo has a real disclosure address, not a contact form that forwards to /dev/null.
Thanks for reading. Now go set NODE_ENV=production in staging and watch your error responses get politely quiet.
— Devlin