UNPKG

express-controller-sets

Version:

Unified package for Express.js controller sets, dynamic routing, and S3 file uploads.

200 lines (138 loc) 7.59 kB
# Migrating from 2.x to 3.0 3.0 is a security release. It fixes vulnerabilities that let a client read fields your API never exposed, write fields it was never meant to write, and host arbitrary HTML on your S3 bucket. **Every one of those fixes is a breaking change**, because in 2.x the insecure behaviour was the default. If you run this package on a public endpoint, treat this as an urgent upgrade. --- ## The fastest path Add `legacyMode: true` to restore 2.x behaviour, deploy, then remove it option by option: ```js createRouter({ model: Product, legacyMode: true }); ``` This is a bridge, not a destination. It re-enables every vulnerability listed below and will be removed in 4.0. --- ## What changed, and what to do ### 1. Request bodies are filtered (`allowedFields` / `blockedFields`) **Was:** the entire request body went to `Model.create()` and `$set`. Any schema field was client-writable, including `role`, `isAdmin`, and `balance`. **Now:** `_id`, `__v`, `createdAt`, `updatedAt`, `$`-prefixed keys, and dotted keys are always stripped. Anything else is still writable **until you configure a policy** — so this alone does not close the hole. Configure it: ```js createRouter({ model: User, allowedFields: ["name", "email", "avatar"], // both verbs // or per verb: // allowedFields: { create: ["name", "email"], update: ["name"] }, // or subtract instead: // blockedFields: ["role", "isAdmin"], }); ``` Until you do, the package logs a warning at startup naming the model. A request whose body contains no writable field now returns **400** instead of silently creating an empty record. ### 2. `?compareField=`, `?rangeField=` and `?sort=` are allowlisted **Was:** these accepted any field name, bypassing `query` entirely. A client could filter on `passwordResetToken` and binary-search its value from the result counts. **Now:** they resolve against `query` by default. Unknown fields return **400**. ```js createRouter({ model: Product, query: ["category"], // equality filters + default allowlist filterableFields: ["category", "price"], // ?compareField= / ?rangeField= sortableFields: ["price", "createdAt"], // ?sort= }); ``` Your configured `orderBy` is always permitted — only client-supplied `?sort=` is checked. ### 3. Search terms are escaped **Was:** `?s=` went to `$regex` raw, so `?s=(a+)+$` caused catastrophic backtracking inside mongod. **Now:** escaped and matched literally; terms over 128 characters return 400 (`maxSearchLength`). If you relied on clients passing real patterns: ```js createRouter({ model: Post, search: ["title"], allowRawRegex: true }); ``` Only do this behind authentication. ### 4. Reads are always bounded **Was:** `GET /items` without `?page` returned the entire collection. **Now:** capped at `maxLimit` (default 100), paginated or not. If you depended on unbounded reads, raise `maxLimit` explicitly or paginate. ### 5. Relational search targets the full path **Was:** `search: ["author.profile.name"]` silently searched `profile`, not `profile.name`. **Now:** the full nested path is used. Results will change where you used depth-2 paths — they were previously querying the wrong field. ### 6. `?s=` is the only search parameter **Was:** array-form `search` read `?s`/`?search`, but string-form `search: "title"` read `?title`. Two different contracts on one option. **Now:** both use `?s=` / `?search=`. ### 7. Uploads are private and content-sniffed **Was:** `ACL: "public-read"` was hardcoded, `Content-Type` came from the client's multipart header, and there was no type validation. Uploading HTML labelled `image/png` produced a world-readable page executing on your bucket's origin. **Now:** - ACL defaults to **`private`** - the stored `Content-Type` and file extension come from the **bytes**, not the client - only `image/jpeg|png|gif|webp|avif` and `application/pdf` are accepted - anything not on a strict inline allowlist is stored `Content-Disposition: attachment` ```js createRouterS3upload({ model: Document, path: "docs/", upload: { acl: "public-read", // opt back in deliberately allowedMimeTypes: ["image/jpeg", "image/png", "application/pdf"], maxFileSize: 5 * 1024 * 1024, maxFiles: 5, }, }); ``` > **Existing objects are unaffected.** Files uploaded under 2.x keep their `public-read` ACL. > Audit your bucket separately — this change only governs new uploads. If you switch to `private`, the returned URL is no longer publicly fetchable. Serve those objects through presigned URLs or a CDN origin-access identity. `file.key` is populated alongside `file.location` for this purpose. ### 8. Clients no longer choose image optimization **Was:** read from query, body, and two headers, overriding your server config. Forcing `high` on every upload is CPU amplification. **Now:** server config only. To opt back in: `upload: { allowClientImageOptions: true }` — values are allowlisted even then. ### 9. Non-optimizable formats pass through untouched **Was:** GIF and SVG fell through to the JPEG encoder, producing JPEG bytes stored under a `.gif` key with `Content-Type: image/gif`. Animation was lost and the object was corrupt. **Now:** only JPEG, PNG and WebP are re-encoded; everything else is uploaded as-is, and the extension is re-derived from the output bytes. ### 10. 5xx responses no longer echo internal messages **Was:** `err.message` was returned verbatim, leaking connection strings, hostnames, and bucket names. **Now:** any unclassified 5xx returns `"Internal Server Error"` plus a `requestId` that correlates to the server log. Deliberate 4xx messages are unchanged, and duplicate-key errors now return **409** instead of 500. ### 11. `dotenv` is no longer called, and `multer-s3` was never used The package no longer calls `dotenv.config()` — loading environment is your application's job. Ensure you load it yourself before handling requests. S3 config is now read **per request**, so env vars loaded after import work correctly. `multer-s3` was listed as a peer dependency but never imported; it has been removed. You can uninstall it. `sharp` is now an optional dependency and is loaded lazily, so CRUD-only consumers no longer pay for a native binary. --- ## Behavioural changes worth checking | Change | Impact | |---|---| | `update`/`delete` are a single atomic query | A missing record still returns 404; the previous read-then-write race is gone | | `runValidators` now passes `context: "query"` | Custom update validators using `this` behave correctly | | Reads are no longer `.lean()` by default | Schema `toJSON` transforms now apply — **if you strip fields there, they are now correctly stripped**. Set `lean: true` to restore 2.x behaviour and its performance | | `runAfterCreate` failures | Still logged and swallowed; set `strictAfterCreate: true` to propagate | The `lean` change is the one most likely to alter your response bodies. In 2.x, `getAll` used `.lean()`, which bypasses `schema.set("toJSON", { transform })`. If you relied on such a transform to remove sensitive fields, **2.x was leaking them on list endpoints** and 3.0 stops doing so. --- ## Constructor form The positional signature still works but is deprecated: ```js // still supported new ControllerSets(Model, "-createdAt", ["category"], ["name"]); // preferred — required for any 3.0 option new ControllerSets({ model: Model, orderBy: "-createdAt", query: ["category"] }); ```