@lenne.tech/nest-server 11.42.15 → 11.42.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/rules/configurable-features.md +1 -0
- package/.claude/rules/testing.md +4 -4
- package/FRAMEWORK-API.md +1 -1
- package/dist/core/common/filters/mongoose-cast-error.filter.d.ts +17 -0
- package/dist/core/common/filters/mongoose-cast-error.filter.js +171 -0
- package/dist/core/common/filters/mongoose-cast-error.filter.js.map +1 -0
- package/dist/core/common/interfaces/server-options.interface.d.ts +1 -0
- package/dist/core.module.js +7 -0
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +29 -0
- package/docs/security-overrides.md +3 -2
- package/migration-guides/11.42.15-to-11.42.16.md +114 -0
- package/package.json +4 -4
- package/src/core/common/filters/mongoose-cast-error.filter.ts +310 -0
- package/src/core/common/interfaces/server-options.interface.ts +22 -0
- package/src/core.module.ts +13 -1
- package/src/index.ts +1 -0
|
@@ -107,6 +107,7 @@ JWT-based authentication for existing projects:
|
|
|
107
107
|
| **Security Check Interceptor** | Calls `securityCheck()` + removes secret fields |
|
|
108
108
|
| **Response Filter Interceptor** | Enforces `@Restricted()` field-level access |
|
|
109
109
|
| **Translation Interceptor** | Applies `_translations` based on `Accept-Language` |
|
|
110
|
+
| **CastError Filter** | `MongooseCastErrorFilter` (`APP_FILTER`, 11.42.16): a malformed id from a route parameter answers 404, one from the query string or an update/filter body 400, instead of an unhandled 500 (a create body raises `ValidationError` and stays a 500); a cast failure on a value the request does not contain stays a 500. See "Malformed ids: 404 / 400, not 500" below |
|
|
110
111
|
| **Secret Fields Removal** | Configurable fallback removal of password, tokens, etc. |
|
|
111
112
|
| **RequestContext** | `AsyncLocalStorage`-based context for current user in Mongoose hooks. `fromRequest` (11.42.0) marks a context created for a CLIENT operation (HTTP middleware, GraphQL WebSocket), so service-level checks such as `assertTenantRole()` tell an anonymous request apart from system work |
|
|
112
113
|
| **Query Complexity** | GraphQL query complexity analysis to prevent DoS |
|
|
@@ -1215,6 +1216,33 @@ throw accessDeniedException(currentUser);
|
|
|
1215
1216
|
See `src/core/common/exceptions/access-denied.exception.ts` and
|
|
1216
1217
|
`migration-guides/11.27.7-to-11.28.0.md`.
|
|
1217
1218
|
|
|
1219
|
+
### Malformed ids: 404 / 400, not 500 (v11.42.16+)
|
|
1220
|
+
|
|
1221
|
+
A malformed id reaches Mongoose, which throws `mongoose.Error.CastError`. That is not an
|
|
1222
|
+
`HttpException`, so it escapes `HttpExceptionLogFilter` (`@Catch(HttpException)`), and Nest used to
|
|
1223
|
+
answer **500** for what is simply "this resource does not exist". `CoreModule` registers
|
|
1224
|
+
`MongooseCastErrorFilter` as `APP_FILTER`; it decides by where the failing value came from:
|
|
1225
|
+
|
|
1226
|
+
| Value of the failed cast | REST | GraphQL |
|
|
1227
|
+
|--------------------------|------|---------|
|
|
1228
|
+
| a route parameter (`GET /users/not-an-id`) / a top-level argument (`getUser(id: …)`) | **404** `ErrorCode.RESOURCE_NOT_FOUND` | same |
|
|
1229
|
+
| the query string or the body / nested in an input object | **400** `ErrorCode.INVALID_FIELD_FORMAT [<path>]` | same |
|
|
1230
|
+
| not in the request — a broken stored id, a value computed by server code | **500**, Nest's own body and log line | unchanged (Nest rethrows) |
|
|
1231
|
+
|
|
1232
|
+
The third row is deliberate: that cast failure is a data or code defect, and answering it as a client
|
|
1233
|
+
error would hide it from monitoring. The Mongoose message never reaches the client — it names the
|
|
1234
|
+
model. Opt out with `security.mongooseCastErrorFilter: false`.
|
|
1235
|
+
|
|
1236
|
+
Mongoose throws `CastError` only while casting a **query or an update** — `findById`, filters,
|
|
1237
|
+
`findByIdAndUpdate` / `updateOne` (the path `CrudService.update()` takes). Two neighbours are not
|
|
1238
|
+
covered and stay a 500:
|
|
1239
|
+
|
|
1240
|
+
- a malformed id in a document being **created or saved** (`save()`, `Model.create()`, `insertMany()`
|
|
1241
|
+
— the path `CrudService.create()` takes) arrives as a `ValidationError` wrapping the CastError;
|
|
1242
|
+
validate reference ids in the input (`validator: () => [IsMongoId()]` on the `@UnifiedField`);
|
|
1243
|
+
- a `BSONError` from `new Types.ObjectId(bad)` in service code carries no value to classify; validate
|
|
1244
|
+
such ids in the service, as `CoreFileService.isMalformedFileId()` does.
|
|
1245
|
+
|
|
1218
1246
|
### Depth-Based Optimization (v11.23.0+)
|
|
1219
1247
|
|
|
1220
1248
|
When `process()` is called from within another `process()` call (service cascades like A.create → B.create → C.create), steps 4–6 are **conditionally skipped** on inner calls to avoid redundant work:
|
|
@@ -1643,6 +1671,7 @@ All security features are configured in `config.env.ts` under the `security` key
|
|
|
1643
1671
|
| `security.checkSecurityInterceptor` | `boolean \| object` | `true` | Enable securityCheck() calls |
|
|
1644
1672
|
| `security.mapAndValidatePipe` | `boolean \| object` | `true` | Enable input validation |
|
|
1645
1673
|
| `security.mapAndValidatePipe.nonWhitelistedFields` | `'strip' \| 'error' \| false` | `'strip'` | Whitelist behavior |
|
|
1674
|
+
| `security.mongooseCastErrorFilter` | `boolean` | `true` | Malformed request ids answer 404 / 400 instead of 500 |
|
|
1646
1675
|
|
|
1647
1676
|
### Safety Net — Mongoose Plugins
|
|
1648
1677
|
|
|
@@ -64,13 +64,14 @@ overrides:
|
|
|
64
64
|
'multer@>=2.0.0 <2.4.0': '2.4.0'
|
|
65
65
|
|
|
66
66
|
# @nestjs/graphql 13.4.5 exact-pins @graphql-tools/utils@12.0.0 — GHSA-7mx3-vvmw-hjmv (high, patched >=12.0.1).
|
|
67
|
-
# Target the newest 12.x, so the caret requesters (@graphql-tools/schema / merge) are not held back
|
|
67
|
+
# Target the newest 12.x, so the caret requesters (@graphql-tools/schema / merge) are not held back —
|
|
68
|
+
# @graphql-tools/schema 10.1.5 requests ^12.1.0, so a lower target leaves a second copy in the tree.
|
|
68
69
|
# 12.0.2+ requires Node >=22.15 via @whatwg-node/promise-helpers@2 — the floor this framework declares
|
|
69
70
|
# since 11.42.10. On an older Node, take 12.0.1 instead.
|
|
70
71
|
# Does NOT reach a utils 11.x pulled in by an older @graphql-tools/schema 10.0.x — refresh that one instead:
|
|
71
72
|
# pnpm update --depth Infinity @graphql-tools/schema @graphql-tools/merge
|
|
72
73
|
# Remove once @nestjs/graphql pins >=12.0.1 in the major you use.
|
|
73
|
-
'@graphql-tools/utils@>=12.0.0 <12.0
|
|
74
|
+
'@graphql-tools/utils@>=12.0.0 <12.1.0': '12.1.0'
|
|
74
75
|
```
|
|
75
76
|
|
|
76
77
|
### Retired: `@hono/node-server` (removed 2026-08-22, nest-server 11.36.1)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# Migration Guide: 11.42.15 → 11.42.16
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
| Category | Details |
|
|
6
|
+
| -------------------- | -------------------------------------------------------------------------------------------------------- |
|
|
7
|
+
| **Bugfixes** | A malformed id in a route parameter answers **404** instead of **500**; one in the query string or an update body **400** (§1) |
|
|
8
|
+
| **New Features** | `MongooseCastErrorFilter`, registered automatically; opt-out `security.mongooseCastErrorFilter: false` (§1) |
|
|
9
|
+
| **Framework API** | New exports: `MongooseCastErrorFilter`, `classifyHttpCastError()`, `classifyGraphQlCastError()`, `castErrorCandidates()`, `containsCastValue()`, type `CastErrorOrigin`. No changed signatures |
|
|
10
|
+
| **Dependencies** | Development tooling only: `vitest` / `@vitest/coverage-v8` 4.1.11 → 4.1.12 (security, §2), `oxfmt` 0.72.0 → 0.73.0; override `@graphql-tools/utils` → 12.1.0 (§2). Runtime dependencies and peer ranges unchanged |
|
|
11
|
+
| **Breaking Changes** | None. Status codes change from 500 to 404 / 400 for requests that were already failing (§1) |
|
|
12
|
+
| **Migration Effort** | None for most projects. Check tests or clients that expected a 500 for a malformed id (§1) |
|
|
13
|
+
|
|
14
|
+
## Quick Migration
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pnpm update @lenne.tech/nest-server
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Vendor-mode projects pick it up with the core update. No code or configuration change is required — the filter is
|
|
21
|
+
registered by `CoreModule`, so neither `main.ts` nor test setups built with `Test.createTestingModule()` need a new
|
|
22
|
+
line.
|
|
23
|
+
|
|
24
|
+
## 1. Malformed ids answer 404 / 400 instead of 500
|
|
25
|
+
|
|
26
|
+
A malformed id in a route parameter (`GET /users/not-an-id`, `/ai/connections/abc`) reached Mongoose, which throws
|
|
27
|
+
`mongoose.Error.CastError`. That is not an `HttpException`, so it escaped `HttpExceptionLogFilter`
|
|
28
|
+
(`@Catch(HttpException)`) and Nest answered **500 Internal server error** for what is simply "this resource does not
|
|
29
|
+
exist". In a typical project that affected every `:id` route, including the framework's own `/users/:id` and the AI
|
|
30
|
+
module's routes.
|
|
31
|
+
|
|
32
|
+
`CoreModule` now registers `MongooseCastErrorFilter` as `APP_FILTER`. It decides by where the value that failed to
|
|
33
|
+
cast came from:
|
|
34
|
+
|
|
35
|
+
| Value of the failed cast | REST | GraphQL |
|
|
36
|
+
| ------------------------ | ---- | ------- |
|
|
37
|
+
| a route parameter / a top-level argument (`getUser(id: …)`) | **404** `#LTNS_0400: Resource not found` | same |
|
|
38
|
+
| the query string or an update / filter body / a value nested in an input | **400** `#LTNS_0302: Invalid field format [<path>]` | same |
|
|
39
|
+
| not in the request (a broken stored id, a value computed by server code) | **500**, unchanged — Nest's body, logged as an error | unchanged |
|
|
40
|
+
|
|
41
|
+
The third row is deliberate: a cast failure on a value the request does not contain is a data or code defect, and
|
|
42
|
+
answering it as a client error would hide it from monitoring. The Mongoose message is never sent to the client — it
|
|
43
|
+
names the model. REST bodies have the same shape as every other error `HttpExceptionLogFilter` answers.
|
|
44
|
+
|
|
45
|
+
**What to check:**
|
|
46
|
+
|
|
47
|
+
- **A test or client expected 500 for a malformed id:** it now gets 404 (route parameter) or 400 (query / body).
|
|
48
|
+
A well-formed id that matches nothing keeps answering 404 as before, so 404 now means the same for both.
|
|
49
|
+
- **A project filter already handled `CastError`:** a catch-all filter registered with `app.useGlobalFilters()` is
|
|
50
|
+
consulted before this one and keeps winning. One registered as `APP_FILTER` in the project's root module is
|
|
51
|
+
consulted after it — set `security.mongooseCastErrorFilter: false` if your own filter must see every `CastError`
|
|
52
|
+
(for example a Sentry filter that reports them).
|
|
53
|
+
- **You want the previous behaviour:** `security.mongooseCastErrorFilter: false` restores the 500.
|
|
54
|
+
|
|
55
|
+
**Not covered — both stay a 500:**
|
|
56
|
+
|
|
57
|
+
- A malformed id in a document being **created** (`save()`, `Model.create()`, `insertMany()` — the path
|
|
58
|
+
`CrudService.create()` takes). Mongoose reports that as a `ValidationError` wrapping the `CastError`. Validate
|
|
59
|
+
reference ids in the input instead:
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
import { IsMongoId } from 'class-validator';
|
|
63
|
+
|
|
64
|
+
@UnifiedField({ isOptional: true, type: () => String, validator: () => [IsMongoId()] })
|
|
65
|
+
owner?: string = undefined;
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
- A `BSONError` from `new Types.ObjectId(bad)` in service code. It carries no value to classify; check the id
|
|
69
|
+
before converting it, as `CoreFileService.isMalformedFileId()` does.
|
|
70
|
+
|
|
71
|
+
## 2. Development tooling and the `@graphql-tools/utils` override
|
|
72
|
+
|
|
73
|
+
Nothing here reaches the published package's runtime dependencies. Worth mirroring in your own project:
|
|
74
|
+
|
|
75
|
+
- **`vitest` and `@vitest/coverage-v8` 4.1.12** fix two `@vitest/mocker` advisories published on 2026-10-16
|
|
76
|
+
(GHSA-qxg6-89mg-rqv8, GHSA-xp26-3454-mjgp, both medium). Development tooling only. `pnpm audit` may not list them
|
|
77
|
+
yet, so raise both together without waiting for it: `@vitest/coverage-v8` peer-requires the exact vitest version.
|
|
78
|
+
- **`@graphql-tools/utils` override:** if your project carries its own entry for GHSA-7mx3-vvmw-hjmv (overrides do
|
|
79
|
+
not reach consumers, so many do), raise it:
|
|
80
|
+
|
|
81
|
+
```yaml
|
|
82
|
+
# pnpm-workspace.yaml
|
|
83
|
+
overrides:
|
|
84
|
+
'@graphql-tools/utils@>=12.0.0 <12.1.0': '12.1.0' # was: '<12.0.3': '12.0.3' / '<12.0.4': '12.0.4'
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The old target still clears the advisory, but `@graphql-tools/schema` 10.1.5 (pulled in by `@apollo/server`)
|
|
88
|
+
requests `^12.1.0`, so a lower target leaves two copies of `@graphql-tools/utils` in the tree.
|
|
89
|
+
See [`docs/security-overrides.md`](../docs/security-overrides.md).
|
|
90
|
+
|
|
91
|
+
## Compatibility Notes
|
|
92
|
+
|
|
93
|
+
| Pattern | Status |
|
|
94
|
+
| ------- | ------ |
|
|
95
|
+
| `main.ts` with `app.useGlobalFilters(new HttpExceptionLogFilter())` | Unchanged; both filters coexist (they catch different classes) |
|
|
96
|
+
| A project catch-all filter via `app.useGlobalFilters()` | Unchanged; it still sees every `CastError` |
|
|
97
|
+
| A project catch-all filter as `APP_FILTER` in the root module | No longer sees `CastError` — opt out if it must (§1) |
|
|
98
|
+
| Tests built with `Test.createTestingModule({ imports: [ServerModule] })` | Get the filter automatically |
|
|
99
|
+
| `security.mongooseCastErrorFilter: false` | Previous behaviour (500) |
|
|
100
|
+
|
|
101
|
+
## Troubleshooting
|
|
102
|
+
|
|
103
|
+
**A malformed id still answers 500**
|
|
104
|
+
Check where it fails. A create path raises `ValidationError` (see "Not covered" above); a `new Types.ObjectId(bad)` in
|
|
105
|
+
service code raises `BSONError`; a value from a header or from stored data is not in the request and stays a 500 on
|
|
106
|
+
purpose. The server log names the cast at debug level (`MongooseCastErrorFilter`) when the filter answered 404 / 400.
|
|
107
|
+
|
|
108
|
+
**A 400 names a field the client did not send**
|
|
109
|
+
The path in brackets is the schema path Mongoose failed to cast. For a filter on `id` it reads `[_id]`.
|
|
110
|
+
|
|
111
|
+
## Module Documentation
|
|
112
|
+
|
|
113
|
+
- [Request lifecycle](../docs/REQUEST-LIFECYCLE.md) — "Malformed ids: 404 / 400, not 500"
|
|
114
|
+
- Implementation: `src/core/common/filters/mongoose-cast-error.filter.ts`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lenne.tech/nest-server",
|
|
3
|
-
"version": "11.42.
|
|
3
|
+
"version": "11.42.16",
|
|
4
4
|
"description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"node",
|
|
@@ -202,7 +202,7 @@
|
|
|
202
202
|
"@types/multer": "2.3.0",
|
|
203
203
|
"@types/node": "26.6.5",
|
|
204
204
|
"@types/passport": "1.0.17",
|
|
205
|
-
"@vitest/coverage-v8": "4.1.
|
|
205
|
+
"@vitest/coverage-v8": "4.1.12",
|
|
206
206
|
"better-auth": "1.7.7",
|
|
207
207
|
"bullmq": "6.3.15",
|
|
208
208
|
"cross-env": "10.1.0",
|
|
@@ -210,7 +210,7 @@
|
|
|
210
210
|
"nodemon": "3.1.14",
|
|
211
211
|
"npm-watch": "0.13.0",
|
|
212
212
|
"otpauth": "9.5.2",
|
|
213
|
-
"oxfmt": "0.
|
|
213
|
+
"oxfmt": "0.73.0",
|
|
214
214
|
"oxlint": "1.88.0",
|
|
215
215
|
"rimraf": "6.1.3",
|
|
216
216
|
"smtp-server": "3.19.23",
|
|
@@ -222,7 +222,7 @@
|
|
|
222
222
|
"unplugin-swc": "2.0.0",
|
|
223
223
|
"vite": "8.3.4",
|
|
224
224
|
"vite-plugin-node": "8.0.0",
|
|
225
|
-
"vitest": "4.1.
|
|
225
|
+
"vitest": "4.1.12"
|
|
226
226
|
},
|
|
227
227
|
"main": "dist/index.js",
|
|
228
228
|
"types": "dist/index.d.ts",
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ArgumentsHost,
|
|
3
|
+
BadRequestException,
|
|
4
|
+
Catch,
|
|
5
|
+
ExceptionFilter,
|
|
6
|
+
HttpException,
|
|
7
|
+
HttpStatus,
|
|
8
|
+
Logger,
|
|
9
|
+
NotFoundException,
|
|
10
|
+
} from '@nestjs/common';
|
|
11
|
+
import { Error as MongooseError } from 'mongoose';
|
|
12
|
+
|
|
13
|
+
import { ErrorCode } from '../../modules/error-code/error-codes';
|
|
14
|
+
|
|
15
|
+
import type { GqlContextType } from '@nestjs/graphql';
|
|
16
|
+
import type { Request, Response } from 'express';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Where the value of a failed Mongoose cast came from — and therefore who is at fault.
|
|
20
|
+
*
|
|
21
|
+
* - `identifier`: it names the resource the request addresses — a route parameter, or a top-level
|
|
22
|
+
* GraphQL argument. A malformed identifier names nothing, so it is answered like an unknown one: 404.
|
|
23
|
+
* - `input`: the client sent it as data — query string, body, or nested inside a GraphQL input: 400.
|
|
24
|
+
* - `server`: the request does not contain it. It came from stored data or from server code, which is
|
|
25
|
+
* a data or code defect and stays a 500, so monitoring keeps seeing it.
|
|
26
|
+
*/
|
|
27
|
+
export type CastErrorOrigin = 'identifier' | 'input' | 'server';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Bounds for walking request data. They keep error handling cheap and terminating for a deeply nested,
|
|
31
|
+
* very wide or self-referencing structure — the body parser's size limit alone does not: a project may
|
|
32
|
+
* raise it, and a few megabytes of `[0,0,0,…]` are a million nodes. What the budget cannot reach is not
|
|
33
|
+
* matched, which answers `server` (a 500), the status quo — never a client error.
|
|
34
|
+
*/
|
|
35
|
+
const MAX_WALK_DEPTH = 32;
|
|
36
|
+
const MAX_WALK_NODES = 10_000;
|
|
37
|
+
|
|
38
|
+
type Primitive = bigint | boolean | number | string;
|
|
39
|
+
|
|
40
|
+
function isPrimitive(value: unknown): value is Primitive {
|
|
41
|
+
return (
|
|
42
|
+
typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint'
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The strings a CastError's `value` stands for.
|
|
48
|
+
*
|
|
49
|
+
* A primitive stands for itself, an array (the `$in` shape) for each of its primitive elements.
|
|
50
|
+
* Anything else yields nothing: the strings inside an object cast as a whole (a subdocument, a whole
|
|
51
|
+
* array of them) are not what failed, and matching them against the request would relabel a server
|
|
52
|
+
* defect as a client error whenever one of them happens to appear in the body.
|
|
53
|
+
*/
|
|
54
|
+
export function castErrorCandidates(value: unknown): string[] {
|
|
55
|
+
if (isPrimitive(value)) {
|
|
56
|
+
return [String(value)];
|
|
57
|
+
}
|
|
58
|
+
if (Array.isArray(value)) {
|
|
59
|
+
return value.filter(isPrimitive).map((item) => String(item));
|
|
60
|
+
}
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The children of an array or object, or `undefined` when there are more than `room` of them.
|
|
66
|
+
*
|
|
67
|
+
* Never collects more than `room` entries: an object is read key by key rather than through
|
|
68
|
+
* `Object.values()`, which would first copy every value of a million-key object.
|
|
69
|
+
*/
|
|
70
|
+
function boundedChildren(value: object, room: number): readonly unknown[] | undefined {
|
|
71
|
+
if (Array.isArray(value)) {
|
|
72
|
+
return value.length > room ? undefined : value;
|
|
73
|
+
}
|
|
74
|
+
const children: unknown[] = [];
|
|
75
|
+
// Own enumerable keys only, as Object.values — also for the null-prototype objects Express's query
|
|
76
|
+
// parser and graphql-js produce, which is why hasOwnProperty is borrowed rather than called
|
|
77
|
+
for (const key in value) {
|
|
78
|
+
if (!Object.prototype.hasOwnProperty.call(value, key)) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (children.length >= room) {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
children.push((value as Record<string, unknown>)[key]);
|
|
85
|
+
}
|
|
86
|
+
return children;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Whether one of `candidates` occurs as a primitive anywhere in `source` (compared as strings, so a
|
|
91
|
+
* number in a JSON body matches the number Mongoose failed to cast). Depth- and size-bounded, and
|
|
92
|
+
* safe against cycles.
|
|
93
|
+
*
|
|
94
|
+
* The node budget is checked BEFORE a container's children are queued, not when they are visited: a
|
|
95
|
+
* container the remaining budget cannot hold is skipped as a whole. Queuing first and counting later
|
|
96
|
+
* let `{ ids: ['bad'], pad: [<a million zeros>] }` allocate a million stack entries and then give up
|
|
97
|
+
* before reaching `ids` — a 400 answered as a 500 after half a second of blocked event loop. Skipping
|
|
98
|
+
* (rather than queuing the first few) also leaves the budget to the oversized container's siblings.
|
|
99
|
+
*/
|
|
100
|
+
export function containsCastValue(source: unknown, candidates: readonly string[]): boolean {
|
|
101
|
+
if (!candidates.length) {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
const wanted = new Set(candidates);
|
|
105
|
+
const seen = new Set<object>();
|
|
106
|
+
const stack: { depth: number; value: unknown }[] = [{ depth: 0, value: source }];
|
|
107
|
+
let nodes = 0;
|
|
108
|
+
for (let item = stack.pop(); item; item = stack.pop()) {
|
|
109
|
+
const { depth, value } = item;
|
|
110
|
+
// Unreachable while boundedChildren() keeps the invariant below; kept so that a regression there
|
|
111
|
+
// degrades to a 500 instead of an unbounded walk
|
|
112
|
+
if (++nodes > MAX_WALK_NODES) {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
if (isPrimitive(value)) {
|
|
116
|
+
if (wanted.has(String(value))) {
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (!value || typeof value !== 'object' || depth >= MAX_WALK_DEPTH || ArrayBuffer.isView(value)) {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (seen.has(value)) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
seen.add(value);
|
|
128
|
+
// nodes + stack.length never exceeds MAX_WALK_NODES: only what the budget can still hold is queued
|
|
129
|
+
const children = boundedChildren(value, MAX_WALK_NODES - nodes - stack.length);
|
|
130
|
+
for (const child of children ?? []) {
|
|
131
|
+
stack.push({ depth: depth + 1, value: child });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Whether one of `candidates` is one of `values` itself, or an element of an array among them — the
|
|
139
|
+
* shape of route parameters (Express 5 wildcards are arrays) and of top-level GraphQL arguments.
|
|
140
|
+
*/
|
|
141
|
+
function isDirectValue(values: readonly unknown[], candidates: readonly string[]): boolean {
|
|
142
|
+
const wanted = new Set(candidates);
|
|
143
|
+
const matches = (value: unknown) => isPrimitive(value) && wanted.has(String(value));
|
|
144
|
+
// A list beyond the walk budget is not scanned here either — a GraphQL list argument is client-sized
|
|
145
|
+
return values.some((value) =>
|
|
146
|
+
Array.isArray(value) ? value.length <= MAX_WALK_NODES && value.some(matches) : matches(value),
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Classify a CastError raised while handling a REST request.
|
|
152
|
+
*
|
|
153
|
+
* A value that is a route parameter is an `identifier`; one found anywhere in the query string or the
|
|
154
|
+
* body is `input`; anything else is `server`. The parameter check runs first, so `PATCH /x/:id` with the
|
|
155
|
+
* same malformed id in its body is still a 404.
|
|
156
|
+
*/
|
|
157
|
+
export function classifyHttpCastError(
|
|
158
|
+
value: unknown,
|
|
159
|
+
request: Partial<Pick<Request, 'body' | 'params' | 'query'>> | undefined,
|
|
160
|
+
): CastErrorOrigin {
|
|
161
|
+
const candidates = castErrorCandidates(value);
|
|
162
|
+
if (!candidates.length || !request) {
|
|
163
|
+
return 'server';
|
|
164
|
+
}
|
|
165
|
+
if (isDirectValue(Object.values(request.params ?? {}), candidates)) {
|
|
166
|
+
return 'identifier';
|
|
167
|
+
}
|
|
168
|
+
if (containsCastValue(request.query, candidates) || containsCastValue(request.body, candidates)) {
|
|
169
|
+
return 'input';
|
|
170
|
+
}
|
|
171
|
+
return 'server';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Classify a CastError raised while executing a GraphQL field.
|
|
176
|
+
*
|
|
177
|
+
* The GraphQL counterpart of a route parameter is a top-level scalar argument (`getUser(id: …)`), and
|
|
178
|
+
* of a body a value nested inside an input object (`findUsers(filter: …)`, `updateUser(input: …)`).
|
|
179
|
+
*/
|
|
180
|
+
export function classifyGraphQlCastError(value: unknown, args: unknown): CastErrorOrigin {
|
|
181
|
+
const candidates = castErrorCandidates(value);
|
|
182
|
+
if (!candidates.length || !args || typeof args !== 'object') {
|
|
183
|
+
return 'server';
|
|
184
|
+
}
|
|
185
|
+
if (isDirectValue(Object.values(args), candidates)) {
|
|
186
|
+
return 'identifier';
|
|
187
|
+
}
|
|
188
|
+
if (containsCastValue(args, candidates)) {
|
|
189
|
+
return 'input';
|
|
190
|
+
}
|
|
191
|
+
return 'server';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Answers a Mongoose `CastError` caused by the CLIENT as a client error instead of a 500.
|
|
196
|
+
*
|
|
197
|
+
* A malformed id in a route parameter (`GET /users/not-an-id`) reaches Mongoose, which throws
|
|
198
|
+
* `CastError`. That is not an `HttpException`, so it escaped every `@Catch(HttpException)` filter and Nest
|
|
199
|
+
* answered **500 Internal server error** for what is simply "this resource does not exist".
|
|
200
|
+
*
|
|
201
|
+
* | Value of the failed cast | REST | GraphQL |
|
|
202
|
+
* |--------------------------|------|---------|
|
|
203
|
+
* | a route parameter / a top-level argument | 404 `ErrorCode.RESOURCE_NOT_FOUND` | same |
|
|
204
|
+
* | in the query string or the body / nested in an input | 400 `ErrorCode.INVALID_FIELD_FORMAT [path]` | same |
|
|
205
|
+
* | not in the request (stored data, server code) | 500, exactly as before, logged as an error | unchanged |
|
|
206
|
+
*
|
|
207
|
+
* The third row is deliberate: a broken id in stored data or a cast on a server-computed value is a
|
|
208
|
+
* defect, and relabelling it as a client error would hide it from monitoring.
|
|
209
|
+
*
|
|
210
|
+
* The Mongoose message is never sent: it names the model. Registered by `CoreModule` as `APP_FILTER`
|
|
211
|
+
* (opt out with `security.mongooseCastErrorFilter: false`), so `main.ts` and test setups need no change.
|
|
212
|
+
* A project's own catch-all filter registered via `app.useGlobalFilters()` is consulted first and wins.
|
|
213
|
+
*
|
|
214
|
+
* Only `CastError` is handled, and Mongoose raises it only while casting a QUERY or an UPDATE
|
|
215
|
+
* (filters, `findById`, `findByIdAndUpdate` / `updateOne` — the path `CrudService.update()` takes). Two
|
|
216
|
+
* neighbours stay a 500:
|
|
217
|
+
* - a malformed id in a document being CREATED or SAVED (`new Model().save()`, `Model.create()`,
|
|
218
|
+
* `insertMany()` — the path `CrudService.create()` takes) arrives as a `ValidationError` wrapping the
|
|
219
|
+
* CastError. Validate reference ids in the input instead (`validator: () => [IsMongoId()]` on the
|
|
220
|
+
* `@UnifiedField`);
|
|
221
|
+
* - a `BSONError` from `new Types.ObjectId(bad)` in service code carries no value to classify; such code
|
|
222
|
+
* should check the id itself (see `CoreFileService.isMalformedFileId()`).
|
|
223
|
+
*/
|
|
224
|
+
@Catch(MongooseError.CastError)
|
|
225
|
+
export class MongooseCastErrorFilter implements ExceptionFilter {
|
|
226
|
+
protected static readonly logger = new Logger('MongooseCastErrorFilter');
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The context Nest's own handler logs an unknown error under — so a cast failure that stays a 500 is
|
|
230
|
+
* logged exactly as before this filter existed, and monitoring matching that line keeps matching.
|
|
231
|
+
*/
|
|
232
|
+
protected static readonly unknownErrorLogger = new Logger('ExceptionsHandler');
|
|
233
|
+
|
|
234
|
+
catch(exception: MongooseError.CastError, host: ArgumentsHost): HttpException | undefined {
|
|
235
|
+
const type = host.getType<GqlContextType>();
|
|
236
|
+
if (type === 'graphql') {
|
|
237
|
+
return this.catchGraphQl(exception, host);
|
|
238
|
+
}
|
|
239
|
+
if (type === 'http') {
|
|
240
|
+
this.catchHttp(exception, host);
|
|
241
|
+
}
|
|
242
|
+
// Any other transport: returning nothing hands the error back to its own exception handler
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* GraphQL: the returned exception becomes the field error. Returning nothing hands the error back to
|
|
248
|
+
* Nest's external exception handler, which logs and rethrows it — what happened before this filter.
|
|
249
|
+
*/
|
|
250
|
+
protected catchGraphQl(exception: MongooseError.CastError, host: ArgumentsHost): HttpException | undefined {
|
|
251
|
+
const origin = classifyGraphQlCastError(exception.value, host.getArgByIndex(1));
|
|
252
|
+
if (origin === 'server') {
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
return this.toHttpException(exception, origin);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* REST: a filter that matched must answer the request itself — Nest does not fall back to its own
|
|
260
|
+
* handler afterwards.
|
|
261
|
+
*/
|
|
262
|
+
protected catchHttp(exception: MongooseError.CastError, host: ArgumentsHost): void {
|
|
263
|
+
const ctx = host.switchToHttp();
|
|
264
|
+
const response = ctx.getResponse<Response>();
|
|
265
|
+
const origin = classifyHttpCastError(exception.value, ctx.getRequest<Request>());
|
|
266
|
+
if (origin === 'server') {
|
|
267
|
+
this.replyAsUnknownError(exception, response);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const httpException = this.toHttpException(exception, origin);
|
|
271
|
+
if (response.headersSent) {
|
|
272
|
+
response.end();
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
// Same wire shape as HttpExceptionLogFilter, so a client sees one error format for every 4xx
|
|
276
|
+
response.status(httpException.getStatus()).json({ ...httpException });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The client error a request-supplied cast failure is answered with. The Mongoose message stays in
|
|
281
|
+
* the server log (debug level): it names the model, which is none of the client's business.
|
|
282
|
+
*/
|
|
283
|
+
protected toHttpException(exception: MongooseError.CastError, origin: 'identifier' | 'input'): HttpException {
|
|
284
|
+
MongooseCastErrorFilter.logger.debug(
|
|
285
|
+
`Cast failed on a value from the request (${origin === 'identifier' ? 404 : 400}): ${exception.message}`,
|
|
286
|
+
);
|
|
287
|
+
if (origin === 'identifier') {
|
|
288
|
+
return new NotFoundException(ErrorCode.RESOURCE_NOT_FOUND);
|
|
289
|
+
}
|
|
290
|
+
return new BadRequestException(
|
|
291
|
+
exception.path ? `${ErrorCode.INVALID_FIELD_FORMAT} [${exception.path}]` : ErrorCode.INVALID_FIELD_FORMAT,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* What Nest's `BaseExceptionFilter` does with an error it does not know: the generic 500 body, and
|
|
297
|
+
* the error object itself in the log — `logger.error(exception)`, the same call Nest 11 makes, so the
|
|
298
|
+
* line (and its JSON form under a JSON logger) is the one monitoring already sees.
|
|
299
|
+
*/
|
|
300
|
+
protected replyAsUnknownError(exception: Error, response: Response): void {
|
|
301
|
+
if (response.headersSent) {
|
|
302
|
+
response.end();
|
|
303
|
+
} else {
|
|
304
|
+
response
|
|
305
|
+
.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
|
306
|
+
.json({ statusCode: HttpStatus.INTERNAL_SERVER_ERROR, message: 'Internal server error' });
|
|
307
|
+
}
|
|
308
|
+
MongooseCastErrorFilter.unknownErrorLogger.error(exception);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
@@ -3154,6 +3154,28 @@ export interface IServerOptions {
|
|
|
3154
3154
|
nonWhitelistedFields?: 'strip' | 'error' | false;
|
|
3155
3155
|
};
|
|
3156
3156
|
|
|
3157
|
+
/**
|
|
3158
|
+
* Answer a Mongoose `CastError` caused by the client as a client error instead of a 500.
|
|
3159
|
+
* See @lenne.tech/nest-server/src/core/common/filters/mongoose-cast-error.filter.ts
|
|
3160
|
+
*
|
|
3161
|
+
* A malformed id in a route parameter (`GET /users/not-an-id`) answers 404
|
|
3162
|
+
* (`ErrorCode.RESOURCE_NOT_FOUND`), one in the query string or the body 400
|
|
3163
|
+
* (`ErrorCode.INVALID_FIELD_FORMAT`); GraphQL likewise for a top-level argument and a nested input.
|
|
3164
|
+
* A failed cast on a value the request does not contain (stored data, server code) stays a 500.
|
|
3165
|
+
*
|
|
3166
|
+
* Covers casts Mongoose performs while running a query or an update (`findById`, filters,
|
|
3167
|
+
* `findByIdAndUpdate`). A malformed id in a document being created (`save()`, `create()`,
|
|
3168
|
+
* `insertMany()`) is a Mongoose `ValidationError` instead and stays a 500 — validate such ids in the
|
|
3169
|
+
* input (`validator: () => [IsMongoId()]`).
|
|
3170
|
+
*
|
|
3171
|
+
* Registered by `CoreModule` as `APP_FILTER`. `false` restores the previous behaviour, in which every
|
|
3172
|
+
* `CastError` answered 500.
|
|
3173
|
+
*
|
|
3174
|
+
* @default true
|
|
3175
|
+
* @since 11.42.16
|
|
3176
|
+
*/
|
|
3177
|
+
mongooseCastErrorFilter?: boolean;
|
|
3178
|
+
|
|
3157
3179
|
/**
|
|
3158
3180
|
* Mongoose password hashing plugin - automatically hashes passwords on save/update
|
|
3159
3181
|
* (save, findOneAndUpdate, updateOne, updateMany).
|
package/src/core.module.ts
CHANGED
|
@@ -8,13 +8,14 @@ import {
|
|
|
8
8
|
NestModule,
|
|
9
9
|
UnauthorizedException,
|
|
10
10
|
} from '@nestjs/common';
|
|
11
|
-
import { APP_INTERCEPTOR, APP_PIPE, DiscoveryModule } from '@nestjs/core';
|
|
11
|
+
import { APP_FILTER, APP_INTERCEPTOR, APP_PIPE, DiscoveryModule } from '@nestjs/core';
|
|
12
12
|
import { GraphQLModule } from '@nestjs/graphql';
|
|
13
13
|
import { MongooseModule } from '@nestjs/mongoose';
|
|
14
14
|
import type { Context } from 'graphql-ws';
|
|
15
15
|
import graphqlUploadExpress = require('graphql-upload/graphqlUploadExpress.js');
|
|
16
16
|
import mongoose from 'mongoose';
|
|
17
17
|
|
|
18
|
+
import { MongooseCastErrorFilter } from './core/common/filters/mongoose-cast-error.filter';
|
|
18
19
|
import { merge } from './core/common/helpers/config.helper';
|
|
19
20
|
import { registerWsConnection } from './core/common/helpers/graphql-ws-connection.helper';
|
|
20
21
|
import {
|
|
@@ -391,6 +392,17 @@ export class CoreModule implements NestModule {
|
|
|
391
392
|
});
|
|
392
393
|
}
|
|
393
394
|
|
|
395
|
+
// MongooseCastErrorFilter: a malformed id from the request answers 404 (route parameter) or 400
|
|
396
|
+
// (query/body) instead of escaping every @Catch(HttpException) filter as a 500. A cast failure on a
|
|
397
|
+
// value the request does not contain stays a 500. Registered here rather than in main.ts so that
|
|
398
|
+
// test setups built with Test.createTestingModule() get it too.
|
|
399
|
+
if (config.security?.mongooseCastErrorFilter !== false) {
|
|
400
|
+
providers.push({
|
|
401
|
+
provide: APP_FILTER,
|
|
402
|
+
useClass: MongooseCastErrorFilter,
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
394
406
|
if (config.mongoose?.modelDocumentation) {
|
|
395
407
|
providers.push(ModelDocService);
|
|
396
408
|
}
|
package/src/index.ts
CHANGED
|
@@ -29,6 +29,7 @@ export * from './core/common/enums/role.enum';
|
|
|
29
29
|
export * from './core/common/enums/sort-order.emum';
|
|
30
30
|
export * from './core/common/exceptions/access-denied.exception';
|
|
31
31
|
export * from './core/common/filters/http-exception-log.filter';
|
|
32
|
+
export * from './core/common/filters/mongoose-cast-error.filter';
|
|
32
33
|
export * from './core/common/helpers/common.helper';
|
|
33
34
|
export * from './core/common/helpers/config.helper';
|
|
34
35
|
export * from './core/common/helpers/content-disposition.helper';
|