Skip to content

v0.10.2

Latest

Choose a tag to compare

@github-actions github-actions released this 03 Nov 09:08
f1b6d50

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.

  1. A route or boundary file must be named exactly. Only route.ts, <name>.route.ts, boundary.ts and <name>.boundary.ts (or .js) are loaded. A file that only ended the same way, such as reroute.ts, users-route.ts or myboundary.ts, used to load and no longer does, so its URLs answer 404 and nothing stops the start.
    Migrate: run npx exisjs doctor, which warns about these names, and rename any that are real route files to <name>.route.ts.

  2. A folder can hold only one route file. health.route.ts next to extra.route.ts stops the start, even when their paths differ.
    Migrate: delete the leftover file, or move the second resource to a sub-folder.

  3. A folder can hold only one boundary file. Before, boundary.ts won and admin.boundary.ts was silently ignored, so the guards in the ignored file never ran. This includes boundary.ts next to boundary.js.
    Migrate: merge them into one file.

  4. 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] and users/[slug] collide when both handle GET /.
    Migrate: keep one of them, or give each its own path.

  5. A route file can't repeat a route registered in code. An app.get('/a', ...) in server.ts and a GET /a from a route file stop the start. Two routes that are both registered in code are still allowed.

  6. { code } passed to three error classes is now a code, not details. BadRequestError, UnprocessableError and PayloadTooLargeError (and their factories) already took details second. An object holding only code and details keys, with a string code, 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' } }.

  7. A route with a body schema reads only JSON and forms. Any other Content-Type answers 415 UNSUPPORTED_MEDIA_TYPE, including text/plain, which a browser can send cross-site with no preflight. A request with no Content-Type is still read as JSON.
    Migrate: a client calling fetch(url, { body: JSON.stringify(data) }) sends text/plain, so it must also set content-type: application/json.

  8. serveStatic hides files and folders starting with a dot, except /.well-known/.
    Migrate: set dotfiles: 'allow' if you really serve them.

  9. ipFilter stops 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.

  10. tex.string() refuses a null byte, which databases either reject with a server error or cut the value at.

  11. An error is a 400 only when it is a real validation failure: ZodError, ValidationError, ValidatorError, an issues array, or a plain Error with an errors object. A custom class with an errors property now answers 500.
    Migrate: throw HttpError or one of its subclasses with { details }.

  12. Multipart fields count towards bodyLimit, and a field over the 1 MB field limit answers 413 instead of being cut short.

  13. Forwarded values are checked. req.protocol reads only http, https, ws or wss, req.host ignores a value that isn't a host, and trustProxy: 'cloudflare' reads X-Forwarded-Host and X-Forwarded-Proto only from Cloudflare.

  14. On uWebSockets.js, bodyLimit applies to uploads too, because uWS holds the whole body in memory.

  15. 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.

  16. Responses now send Cross-Origin-Resource-Policy: same-site. Another site can no longer embed them with a tag such as <img> or <script>. fetch with CORS and subdomains of your own site still work.
    Migrate: for files other sites embed, such as public images, set helmet: { crossOriginResourcePolicy: 'cross-origin' }.

  17. 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'] }, with credentials: true if the app sends cookies. cors: true keeps the old behaviour of allowing any site.

Security

  • serveStatic served dotfiles. A .env or .git folder left in the static folder could be downloaded.
  • ipFilter ignored IPv6 CIDR rules, so a deny rule such as 2001:db8::/32 blocked nobody.
  • Errors could show internal details in production. Any error with an errors property answered 400 with its own message, including AggregateError from a failed connection (connect ECONNREFUSED 10.0.0.5:27017) and MongoDB bulk write errors.
  • Content-Type was matched as a substring, so text/plain; x=multipart/form-data was read as multipart, and JSON was accepted as text/plain. This is the same class of bug as Fastify CVE-2026-25223.
  • X-Forwarded-Host and X-Forwarded-Proto were trusted from anyone under trustProxy: '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, id or retry could start a new event, so user text sent through res.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 ipFilter or on a VPN.
  • helmet sent no Cross-Origin-Opener-Policy or Cross-Origin-Resource-Policy, so another site could embed a signed-in user's response with a no-cors request. 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.
    • bodyLimit was 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, so ipFilter couldn't block anyone or could allow everyone, and all clients shared one rate-limit bucket. req.ip is now the real address.
    • ssl was silently ignored, so a server configured for TLS ran unencrypted.
    • bodyLimit was 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 204 threw 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 several Set-Cookie headers were merged into one.
  • Over HTTP/2, req.host was empty, so csrf() refused every same-origin POST, and a Host header added by a client could satisfy its Origin check. It now reads :authority.
  • A malformed multipart body answered 500. It now answers 400.
  • exisjs routes on a plain JavaScript entry file showed no access column.
  • ServiceUnavailableError, GatewayTimeoutError and their *Exception aliases were documented but not exported from exisjs/error.
  • Route and boundary matching was too loose: any file name ending in route.ts or boundary.ts was loaded. The server, exisjs build and exisjs doctor now share one rule.
  • Duplicate routes were silent: exisjs routes showed a doubled row, and one handler never answered.
  • exisjs routes hid start-up errors. A route conflict now prints with the files involved and exits with code 1, and --json prints nothing to stdout. Other start-up failures, such as a database that is down, still print the table.
  • exisjs routes showed 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: 400 for a bad shape, 422 for a broken business rule, 409 for a state conflict, 404 for 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() in exisjs/testing: a test client that keeps cookies and sends the csrf() token, so testing cookie sessions needs no helper of your own.
  • getRoutes(app) lists the routing table in a test, and auditRoutes(app) proves the app denies by default: open routes must be listed in routes-audit.json, and every other route must refuse an anonymous caller.
  • exisjs routes --audit runs the list check in CI, without calling any route.
  • exisjs doctor checks the project layout: it warns when src/domain/ imports database/ or http/, when a project mixes route.ts and <name>.route.ts, and on a service file over 400 lines.
  • examples/06-secure-functional and examples/07-secure-oop: the same API in both paradigms, on the native MongoDB driver and on Mongoose, used as targets for scripts/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 in setupFiles.
  • exisjs build fails on two route files or two boundary files in one folder.
  • exisjs doctor reports those same folder problems as failures, and warns about names like reroute.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: dotenv 18, content-disposition 3.
  • CLI: chokidar 6.
  • Examples: mongodb 7, mongoose 9, ESLint 10.
  • Kept on purpose: dotenv-expand stays on 13: version 1000.0.0 grew from 19 KB to 297 KB, and this package reads secrets. TypeScript stays on 5.9, because typescript-eslint supports 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.
  • helmet doesn't send Cross-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' and img-src https:, which both UIs use as they render. Neither can run a script.
  • Bun answers a malformed chunked body with an extra 400 after the route's response. A bare Bun.serve does 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 build doesn't detect the same method and path across different folders. exisjs routes does, 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: false to 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:examples with MongoDB: every example, including 02-bookstore, and scaffolded --functional and --oop projects.
  • 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/register was the 10-character password minimum, and these apps have no SQL database; "reflected XSS" on /hello was a text/plain response with nosniff.
  • nuclei 3.11, signed in, 10,739 HTTP templates and its DAST templates: nothing above info. The two info results are the docs page, which the scan switched on, and Cross-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 5xx and 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

Full Changelog: v0.10.1...v0.10.2