Repository navigation
ExisJS Release v0.10.2
Status: Released 2026-11-03 (npm tag latest)
Type: Patch: security fixes from a full audit, route and boundary file scanning, error codes, tex.objectId(), testing kit
Affected Packages: exisjs, @exisjs/cli, create-exisjs
Upgrade if you use serveStatic, ipFilter, server: 'uws' or run on Bun. A security audit found that serveStatic served dotfiles such as .env, that ipFilter ignored IPv6 CIDR rules, and that on uWebSockets.js and Bun anyone could crash the server with one request.
A copied route file also used to register the same path twice with no warning, and which handler answered depended on load order. The server now refuses to start on these mistakes and names the files.
Behaviour changes
Each is a fix, but a running app can notice it. Check the ones that apply before upgrading.
-
A route or boundary file must be named exactly. Only
route.ts,<name>.route.ts,boundary.tsand<name>.boundary.ts(or.js) are loaded. A file that only ended the same way, such asreroute.ts,users-route.tsormyboundary.ts, used to load and no longer does, so its URLs answer404and nothing stops the start.
Migrate: runnpx exisjs doctor, which warns about these names, and rename any that are real route files to<name>.route.ts. -
A folder can hold only one route file.
health.route.tsnext toextra.route.tsstops the start, even when their paths differ.
Migrate: delete the leftover file, or move the second resource to a sub-folder. -
A folder can hold only one boundary file. Before,
boundary.tswon andadmin.boundary.tswas silently ignored, so the guards in the ignored file never ran. This includesboundary.tsnext toboundary.js.
Migrate: merge them into one file. -
Two routes can't answer the same method and path. This covers two files, two handlers in one file, and class controllers. Parameter names don't make routes different:
users/[id]andusers/[slug]collide when both handleGET /.
Migrate: keep one of them, or give each its own path. -
A route file can't repeat a route registered in code. An
app.get('/a', ...)inserver.tsand aGET /afrom a route file stop the start. Two routes that are both registered in code are still allowed. -
{ code }passed to three error classes is now a code, not details.BadRequestError,UnprocessableErrorandPayloadTooLargeError(and their factories) already tookdetailssecond. An object holding onlycodeanddetailskeys, with a stringcode, now sets those fields. Any other value is still details.
Migrate: only if you send details shaped exactly like that: wrap them,{ details: { code: 'X' } }. -
A route with a body schema reads only JSON and forms. Any other
Content-Typeanswers415 UNSUPPORTED_MEDIA_TYPE, includingtext/plain, which a browser can send cross-site with no preflight. A request with noContent-Typeis still read as JSON.
Migrate: a client callingfetch(url, { body: JSON.stringify(data) })sendstext/plain, so it must also setcontent-type: application/json. -
serveStatichides files and folders starting with a dot, except/.well-known/.
Migrate: setdotfiles: 'allow'if you really serve them. -
ipFilterstops the start on a rule it can't read, and now applies IPv6 CIDR rules, which it used to ignore. An IPv6 client an existing rule covers is now blocked or allowed by that rule. -
tex.string()refuses a null byte, which databases either reject with a server error or cut the value at. -
An error is a
400only when it is a real validation failure:ZodError,ValidationError,ValidatorError, anissuesarray, or a plainErrorwith anerrorsobject. A custom class with anerrorsproperty now answers500.
Migrate: throwHttpErroror one of its subclasses with{ details }. -
Multipart fields count towards
bodyLimit, and a field over the 1 MB field limit answers413instead of being cut short. -
Forwarded values are checked.
req.protocolreads onlyhttp,https,wsorwss,req.hostignores a value that isn't a host, andtrustProxy: 'cloudflare'readsX-Forwarded-HostandX-Forwarded-Protoonly from Cloudflare. -
On uWebSockets.js,
bodyLimitapplies to uploads too, because uWS holds the whole body in memory. -
ExisJS needs Node.js 24 or newer. Node.js 20 stopped getting fixes on 30 April 2026, and the dependencies below no longer support it.
Migrate: move to the latest Node.js 24 or 26, including your Dockerfile and CI. -
Responses now send
Cross-Origin-Resource-Policy: same-site. Another site can no longer embed them with a tag such as<img>or<script>.fetchwith CORS and subdomains of your own site still work.
Migrate: for files other sites embed, such as public images, sethelmet: { crossOriginResourcePolicy: 'cross-origin' }. -
CORS is off until you set it. It was on for every site. A browser app on a different origin from the API now gets a CORS error. Requests from servers and mobile apps aren't affected.
Migrate: list your sites:cors: { origin: ['https://app.example.com'] }, withcredentials: trueif the app sends cookies.cors: truekeeps the old behaviour of allowing any site.
Security
serveStaticserved dotfiles. A.envor.gitfolder left in the static folder could be downloaded.ipFilterignored IPv6 CIDR rules, so a deny rule such as2001:db8::/32blocked nobody.- Errors could show internal details in production. Any error with an
errorsproperty answered400with its own message, includingAggregateErrorfrom a failed connection (connect ECONNREFUSED 10.0.0.5:27017) and MongoDB bulk write errors. Content-Typewas matched as a substring, sotext/plain; x=multipart/form-datawas read as multipart, and JSON was accepted astext/plain. This is the same class of bug as Fastify CVE-2026-25223.X-Forwarded-HostandX-Forwarded-Protowere trusted from anyone undertrustProxy: 'cloudflare', so a direct client could choose the host an app builds links from. This is the same class of bug as Next.js CVE-2026-64649.- Multipart fields ignored
bodyLimit, so one request could hold about 100 MB in memory against a 1 MB limit. csrf()compared tokens in a way that leaks timing. It now uses a constant-time comparison.- Server-sent events could be forged. Text in
data(a lone\r),event,idorretrycould start a new event, so user text sent throughres.sse()could impersonate the server. - Over HTTP/2, an idle session stayed open forever, each one holding a connection. Sessions now close after 2 minutes idle, or
keepAlive.timeoutMs. - The docs page loaded CDN scripts with no integrity check, so a changed CDN file would have run on the page.
- The docs page allowed any inline script. Its policy now allows only the page's own script, by hash, and limits where forms can post.
- CORS allowed every site by default. Cookies were never allowed, but any website a visitor opened could read whatever that visitor's network could reach, such as an API limited by
ipFilteror on a VPN. helmetsent noCross-Origin-Opener-PolicyorCross-Origin-Resource-Policy, so another site could embed a signed-in user's response with ano-corsrequest. Both are now sent by default.- Only on
server: 'uws':- One large request could crash the server, with no login. Any body spanning more than one chunk read memory uWS had already released.
bodyLimitwas ignored, so a client could fill the server's memory.- A client disconnecting mid-body could crash the process through an unhandled rejection.
- Response headers weren't checked, so a CRLF in a value could inject headers.
- On Bun, which
server: 'auto'picks when an app runs under Bun:- One request for a static file crashed the server, with no login.
- Every client's address was
127.0.0.1, soipFiltercouldn't block anyone or could allow everyone, and all clients shared one rate-limit bucket.req.ipis now the real address. sslwas silently ignored, so a server configured for TLS ran unencrypted.bodyLimitwas ignored, and response headers weren't checked for CRLF.
- Publishing: the workflows install with
npm ci, pin every action to a commit, pass the tag name through an environment variable instead of into the script, and drop an unused permission.
Fixed
- On Bun: responses were held in memory until the end, so SSE events and streams didn't reach the client as they were sent; a
204threw an error; and header names were case-sensitive, so one header could be sent twice. - On
server: 'uws': multipart uploads hung forever, responses that write and then end (static files, streams) never finished, chunked request bodies arrived empty, and severalSet-Cookieheaders were merged into one. - Over HTTP/2,
req.hostwas empty, socsrf()refused every same-originPOST, and aHostheader added by a client could satisfy its Origin check. It now reads:authority. - A malformed multipart body answered
500. It now answers400. exisjs routeson a plain JavaScript entry file showed no access column.ServiceUnavailableError,GatewayTimeoutErrorand their*Exceptionaliases were documented but not exported fromexisjs/error.- Route and boundary matching was too loose: any file name ending in
route.tsorboundary.tswas loaded. The server,exisjs buildandexisjs doctornow share one rule. - Duplicate routes were silent:
exisjs routesshowed a doubled row, and one handler never answered. exisjs routeshid start-up errors. A route conflict now prints with the files involved and exits with code1, and--jsonprints nothing to stdout. Other start-up failures, such as a database that is down, still print the table.exisjs routesshowed no source file for class controllers. Their routes now record the file they came from, so a class controller that fails partway through loading in development is also removed cleanly.
Added
- Every error class and factory takes a domain code:
new ConflictError('Changed by someone else', { code: 'STALE_RECORD', details }),HttpError.notFound('Lead', { code: 'LEAD_NOT_FOUND' }). The errors page now says which status to use:400for a bad shape,422for a broken business rule,409for a state conflict,404for a record the caller may not know exists. tex.objectId()validates a MongoDB ObjectId (24 hex characters). The value stays a string, so it needs no database driver.api.browser()inexisjs/testing: a test client that keeps cookies and sends thecsrf()token, so testing cookie sessions needs no helper of your own.getRoutes(app)lists the routing table in a test, andauditRoutes(app)proves the app denies by default: open routes must be listed inroutes-audit.json, and every other route must refuse an anonymous caller.exisjs routes --auditruns the list check in CI, without calling any route.exisjs doctorchecks the project layout: it warns whensrc/domain/importsdatabase/orhttp/, when a project mixesroute.tsand<name>.route.ts, and on a service file over 400 lines.examples/06-secure-functionalandexamples/07-secure-oop: the same API in both paradigms, on the native MongoDB driver and on Mongoose, used as targets forscripts/security/attack.mjs, a suite of 389 attacks based on real vulnerabilities in other frameworks.serveStatic(root, { dotfiles }), and a security page on testing like an attacker and keeping a deployment safe.- Docs: the standard layout now covers
domain/,integrations/,jobs/and how to name feature files, and the testing page shows a database insetupFiles. exisjs buildfails on two route files or two boundary files in one folder.exisjs doctorreports those same folder problems as failures, and warns about names likereroute.ts.
Dependencies
Every update is at least about a week old, because most malicious releases are caught within days. Dependabot now waits 7 days too, and groups minor and patch updates into one pull request.
- Runtime:
dotenv18,content-disposition3. - CLI:
chokidar6. - Examples:
mongodb7,mongoose9, ESLint 10. - Kept on purpose:
dotenv-expandstays on 13: version 1000.0.0 grew from 19 KB to 297 KB, and this package reads secrets. TypeScript stays on 5.9, becausetypescript-eslintsupports only versions below 6.1. The OpenTelemetry peer ranges stay, because the new majors are untested.
Known limits
- ExisJS has no public WebSocket API, so there is nothing to attack there yet.
- Bun was tested on Windows only. HTTP/2 was attacked on Node.js only.
helmetdoesn't sendCross-Origin-Embedder-Policy, because it stops a page loading files from other sites. Scanners report this as missing. It only matters for pages, so set it yourself if a page needs it.- The docs page keeps
style-src 'unsafe-inline'andimg-src https:, which both UIs use as they render. Neither can run a script. - Bun answers a malformed chunked body with an extra
400after the route's response. A bareBun.servedoes the same. A request hidden in that body was never routed, so this isn't smuggling. - In cluster mode, rate limits and idempotency keys are per worker until a shared store is configured (planned for 0.11).
exisjs builddoesn't detect the same method and path across different folders.exisjs routesdoes, and the server refuses to start on it.- The development server loads route files on first request, so it reports a path conflict across folders once both files are loaded. Set
dev.lazyRoutes: falseto check all of them at start.
New projects
create-exisjs now sets engines.node to >=24.0.0 and asks for the current versions of its dev tools, and with no CORS_ORIGIN a new project leaves CORS off.
Upgrade
npm install exisjs@0.10.2
npm install -D @exisjs/cli@0.10.2@exisjs/fetch stays at 0.9.2: its code didn't change.
Then run npx exisjs doctor and npx exisjs routes. A project they pass will start.
Checked
- 1090 tests (1089 pass, 1 Linux-only skipped); build, typecheck and lint.
verify:exampleswith MongoDB: every example, including02-bookstore, and scaffolded--functionaland--oopprojects.scripts/security/attack.mjs: 389 checks, 0 holes, against both new examples on Node.js, uWebSockets.js and Bun, and against a 2-worker cluster, in a production build.- OWASP ZAP 2.17, signed in, against both examples at medium and at high strength with a low alert threshold (about 15,000 requests in all), with no server error logged. Its two High alerts on the high-strength scan were false alarms, reproduced by hand: "SQL injection" on
/auth/registerwas the 10-character password minimum, and these apps have no SQL database; "reflected XSS" on/hellowas atext/plainresponse withnosniff. - nuclei 3.11, signed in, 10,739 HTTP templates and its DAST templates: nothing above
info. The twoinforesults are the docs page, which the scan switched on, andCross-Origin-Embedder-Policy. - The docs page in headless Edge renders Swagger UI under its new policy, with no violations.
- ffuf: about 700 payloads against both examples, with no
5xxand no server error logged. Trivy: no vulnerabilities in production dependencies. Gitleaks: 164 commits scanned, and the findings were placeholders in tests and docs. scripts/security/attack-extra.mjs --slow: 93 checks on Node.js and uWebSockets.js and 27 on Bun, 0 holes, covering HTTP/2 (rapid reset, header, PING and SETTINGS floods), slow connections, metrics growth, the docs page and server-sent events.npm audit --omit=dev: 0 vulnerabilities. A search of tracked files and history for common secret formats found nothing.
What's Changed
- fix(routes): enforce single route and boundary file per folder by @prasanth-t0205 in #96
- Feat/error codes and validators by @prasanth-t0205 in #97
- Feat/error codes and validators by @prasanth-t0205 in #98
- fix(security): harden the framework for 0.10.2 by @prasanth-t0205 in #99
- chore(release): 0.10.2 by @prasanth-t0205 in #100
- feat(create-exis): update package.json template to set Node.js engine… by @prasanth-t0205 in #101
Full Changelog: v0.10.1...v0.10.2