@lenne.tech/nest-server 11.43.2 → 11.43.4
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/role-system.md +8 -0
- package/.claude/rules/testing.md +4 -4
- package/FRAMEWORK-API.md +1 -1
- package/dist/core/common/services/core-tenant-context.registry.d.ts +4 -0
- package/dist/core/common/services/core-tenant-context.registry.js.map +1 -1
- package/dist/core/modules/ai/core-ai-mcp.controller.d.ts +5 -0
- package/dist/core/modules/ai/core-ai-mcp.controller.js +25 -2
- package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
- package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.js +1 -2
- package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.js.map +1 -1
- package/dist/core/modules/auth/services/core-auth.service.js +4 -2
- package/dist/core/modules/auth/services/core-auth.service.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.guard.d.ts +5 -0
- package/dist/core/modules/tenant/core-tenant.guard.js +26 -4
- package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +1 -1
- package/docs/security-overrides.md +5 -5
- package/migration-guides/11.43.2-to-11.43.3.md +80 -0
- package/migration-guides/11.43.3-to-11.43.4.md +119 -0
- package/package.json +4 -4
- package/src/core/common/services/core-tenant-context.registry.ts +12 -0
- package/src/core/modules/ai/README.md +6 -0
- package/src/core/modules/ai/core-ai-mcp.controller.ts +59 -3
- package/src/core/modules/auth/guards/legacy-auth-rate-limit.guard.ts +4 -3
- package/src/core/modules/auth/services/core-auth.service.ts +16 -2
- package/src/core/modules/tenant/core-tenant.guard.ts +54 -4
|
@@ -179,7 +179,7 @@ JWT-based authentication for existing projects:
|
|
|
179
179
|
| **AI Module** | Prompt orchestrator (auto + plan mode), DB-backed LLM connections, tool registry |
|
|
180
180
|
| **REST Endpoints** | `POST /ai/prompt`, `POST /ai/stream` (SSE); `GET/POST/PUT/DELETE /ai/connections*`, `/ai/connections/available`, `/ai/connections/select`, `/ai/connections/preferences*`, `/ai/conversations*`, `/ai/interactions*`, `/ai/budget-limits*`, `GET /ai/usage` |
|
|
181
181
|
| **GraphQL Endpoints** | `aiPrompt`, `aiAvailableConnections`, `aiSetUserConnection`, connection/preference/conversation/interaction/budget queries + mutations, `aiUsage` |
|
|
182
|
-
| **MCP Server** | Streamable HTTP at `POST/GET/DELETE /ai/mcp` (Bearer auth); with `ai.mcp.oauth` an OAuth 2.1 router (`/.well-known/*`, `/authorize`, `/token`, `/register`) that `CoreAiMcpOAuthInitializer` mounts during `app.init()` — an Express router behind the Nest middleware and routes, ahead of Nest's not-found handler, so guards and interceptors do not run for it; `/authorize` answers with a built-in consent page (the Better-Auth session from the middleware is the user; Allow is a CSRF-bound POST) |
|
|
182
|
+
| **MCP Server** | Streamable HTTP at `POST/GET/DELETE /ai/mcp` (Bearer auth); a user the controller resolves itself (the OAuth access token, which only this controller accepts, after the guards ran without a user) gets `req.user` and the tenant the header names from an active membership only, through `CoreTenantGuard.resolveMembershipContext()` (11.43.3); with `ai.mcp.oauth` an OAuth 2.1 router (`/.well-known/*`, `/authorize`, `/token`, `/register`) that `CoreAiMcpOAuthInitializer` mounts during `app.init()` — an Express router behind the Nest middleware and routes, ahead of Nest's not-found handler, so guards and interceptors do not run for it; `/authorize` answers with a built-in consent page (the Better-Auth session from the middleware is the user; Allow is a CSRF-bound POST) |
|
|
183
183
|
| **Permission Model** | `S_USER` for prompt/conversations/available/usage; `ADMIN` for connections/preferences/interactions/budgets; encrypted API keys never leave the server (`securityCheck` + `secretFields`) |
|
|
184
184
|
|
|
185
185
|
### Email & Templates
|
|
@@ -19,7 +19,7 @@ used to **exact-pin**, to a vulnerable version:
|
|
|
19
19
|
| `ws` | [GHSA-96hv-2xvq-fx4p](https://github.com/advisories/GHSA-96hv-2xvq-fx4p) — high: memory-exhaustion DoS + uninitialized memory disclosure. Patched `>=8.21.0` | Older `@nestjs/graphql` releases declare `"ws": "8.20.1"` — an **exact pin**, not a caret. 13.4.5, the version this framework declares, pins `8.21.3` |
|
|
20
20
|
| `js-yaml` | [GHSA-pm4m-ph32-ghv5](https://github.com/advisories/GHSA-pm4m-ph32-ghv5) — high: exponential parsing time in flow collections (DoS), patched `>=5.2.2`; [GHSA-r3ph-w7gj-g6xm](https://github.com/advisories/GHSA-r3ph-w7gj-g6xm) — moderate: `maxTotalMergeKeys` does not bound CPU use for empty merge sources, covers `<=5.4.0`, patched `>=5.4.1` (published 2026-09-29) | `@nestjs/swagger` declares js-yaml as an **exact pin**, the same shape as the `ws` case: older releases `5.2.1`, 11.4.7 — the version this framework declares, and the newest 11.x — `5.3.0`. No swagger update reaches the fix, so **this entry is load-bearing again** |
|
|
21
21
|
| `@graphql-tools/utils` | [GHSA-7mx3-vvmw-hjmv](https://github.com/advisories/GHSA-7mx3-vvmw-hjmv) — high: prototype pollution in `mergeDeep`, which runs while the GraphQL schema is assembled. Covers `<=12.0.0`, patched `>=12.0.1` (added 2026-10-06) | `@nestjs/graphql` 13.4.5 — the version this framework declares, and the newest 13.x — declares `"@graphql-tools/utils": "12.0.0"`, an **exact pin**. Only `@nestjs/graphql` 14.x moves it, so **this entry is load-bearing**. `@apollo/server` reaches it too, through `@graphql-tools/schema` / `merge` — and in a lockfile that still holds schema 10.0.x (resolved before 2026-08-12), as **utils 11.x**: vulnerable too, with no 11.x fix, and outside the override key. Lift it with `pnpm update --depth Infinity @graphql-tools/schema @graphql-tools/merge`; the current releases request utils `^12.0.3`, which needs Node >=22.15 — the framework's own floor since 11.42.10, which is why the snippet below targets 12.0.3 |
|
|
22
|
-
| `multer` | [GHSA-wc9g-mqfw-jrwm](https://github.com/advisories/GHSA-wc9g-mqfw-jrwm), [GHSA-535w-7cp7-47q4](https://github.com/advisories/GHSA-535w-7cp7-47q4), [GHSA-qfvm-cv95-jqjf](https://github.com/advisories/GHSA-qfvm-cv95-jqjf) — high: DoS via crafted multipart input; [GHSA-qvfw-j98x-7q72](https://github.com/advisories/GHSA-qvfw-j98x-7q72) — low: file size limit bypass. Patched `>=2.3.0` | `@nestjs/platform-express` up to 11.2.5 declares `"multer": "2.2.0"` — an **exact pin**. A direct `multer` dependency does not move it: you get both copies, and FileInterceptor uses the vulnerable one. 11.2.6
|
|
22
|
+
| `multer` | [GHSA-wc9g-mqfw-jrwm](https://github.com/advisories/GHSA-wc9g-mqfw-jrwm), [GHSA-535w-7cp7-47q4](https://github.com/advisories/GHSA-535w-7cp7-47q4), [GHSA-qfvm-cv95-jqjf](https://github.com/advisories/GHSA-qfvm-cv95-jqjf) — high: DoS via crafted multipart input; [GHSA-qvfw-j98x-7q72](https://github.com/advisories/GHSA-qvfw-j98x-7q72) — low: file size limit bypass. Patched `>=2.3.0`. [GHSA-q7qp-rjg7-q26m](https://github.com/advisories/GHSA-q7qp-rjg7-q26m) — high: DoS via a pending asynchronous `fileFilter` after request teardown, covers 2.4.0, patched `>=2.5.0` (published 2026-10-23) | `@nestjs/platform-express` up to 11.2.5 declares `"multer": "2.2.0"` — an **exact pin**. A direct `multer` dependency does not move it: you get both copies, and FileInterceptor uses the vulnerable one. 11.2.6 to 11.2.8, the newest 11.x, pin `2.4.0`, which GHSA-q7qp-rjg7-q26m covers. No 11.x update reaches the fix, so **this entry is load-bearing again since 11.43.4** |
|
|
23
23
|
|
|
24
24
|
**Status since 11.42.4:** `@graphql-tools/utils` joins `js-yaml` as load-bearing — no `@nestjs/graphql`
|
|
25
25
|
13.x resolves past the exact pin. The 11.42.4 release also covers `proxy-addr` (GHSA-jqcg-44mw-7w3h,
|
|
@@ -58,10 +58,10 @@ overrides:
|
|
|
58
58
|
# Remove once @nestjs/swagger pins >=5.4.1 itself.
|
|
59
59
|
'js-yaml@>=5.0.0 <5.4.2': '5.4.2'
|
|
60
60
|
|
|
61
|
-
# @nestjs/platform-express
|
|
62
|
-
#
|
|
63
|
-
#
|
|
64
|
-
'multer@>=2.0.0 <2.
|
|
61
|
+
# @nestjs/platform-express 11.x exact-pins multer (<=11.2.5: 2.2.0, 11.2.6 to 11.2.8: 2.4.0).
|
|
62
|
+
# GHSA-q7qp-rjg7-q26m (high, DoS) covers 2.4.0, patched >=2.5.0. LOAD-BEARING again since 11.43.4.
|
|
63
|
+
# Keep the target in LOCKSTEP with the multer version @lenne.tech/nest-server declares (2.5.0 since 11.43.4).
|
|
64
|
+
'multer@>=2.0.0 <2.5.0': '2.5.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
67
|
# Target the newest 12.x, so the caret requesters (@graphql-tools/schema / merge) are not held back —
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Migration Guide: 11.43.2 → 11.43.3
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
| Category | Details |
|
|
6
|
+
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
7
|
+
| **Bugfixes** | An MCP OAuth access token reaches the tenant context: tenant-scoped AI tools answered "Tenant context required" to every OAuth client, members included (§1). The OAuth user is the current user of the request, so audit fields and the role guard plugin apply to tool writes (§2) |
|
|
8
|
+
| **New Features** | None |
|
|
9
|
+
| **Framework API** | `CoreTenantGuard.resolveMembershipContext()` (new, public), the optional `resolveMembership()` on the exported `TenantContextResolver` interface, `CoreAiMcpController.resolveScopedUser()` / `scopeLateResolvedUser()` (new, protected) |
|
|
10
|
+
| **Dependencies** | Unchanged |
|
|
11
|
+
| **Breaking Changes** | None |
|
|
12
|
+
| **Migration Effort** | None. Check §2 if MCP tools of your project write data |
|
|
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.
|
|
21
|
+
|
|
22
|
+
## 1. MCP OAuth clients got no tenant context
|
|
23
|
+
|
|
24
|
+
`CoreTenantGuard` turns the tenant header (`X-Tenant-Id`) into a tenant context only for a user it sees on the
|
|
25
|
+
request. A session cookie and a Better-Auth JWT are resolved by the middleware before the guards. An MCP OAuth
|
|
26
|
+
access token is not: nothing but `CoreAiMcpController` accepts it, so it is resolved after the guards. The request
|
|
27
|
+
therefore never got a tenant, and `mongooseTenantPlugin` refused every tenant-scoped read — a generic MCP client
|
|
28
|
+
could discover, register, pass the consent page, open a session and list its tools, and then every tenant-scoped
|
|
29
|
+
tool answered `Tenant context required`, even with a header naming a tenant the user is an active member of.
|
|
30
|
+
|
|
31
|
+
The controller now asks the guard's own membership check (`CoreTenantGuard.resolveMembershipContext()`, the same
|
|
32
|
+
decision as its `@Roles(S_EVERYONE)` branch) for a user it resolved itself. The result does not depend on the
|
|
33
|
+
credential:
|
|
34
|
+
|
|
35
|
+
| Request | Session cookie / JWT | MCP OAuth token (11.43.2) | MCP OAuth token (11.43.3) |
|
|
36
|
+
| ----------------------------------------------------- | -------------------- | ------------------------- | ------------------------- |
|
|
37
|
+
| Header names a tenant the user is an active member of | that tenant | none — refused | that tenant |
|
|
38
|
+
| Header names a tenant the user is not a member of | none — refused | none — refused | none — refused |
|
|
39
|
+
| No header | none — refused | none — refused | none — refused |
|
|
40
|
+
|
|
41
|
+
The header alone never establishes a tenant, which is what keeps a freely chosen `X-Tenant-Id` harmless.
|
|
42
|
+
|
|
43
|
+
## 2. The OAuth user is the current user of the request
|
|
44
|
+
|
|
45
|
+
For the same reason `req.user` stayed unset for an OAuth request, and `RequestContext.currentUser` is read from
|
|
46
|
+
it. The Mongoose plugins read an empty current user as work the server does on its own:
|
|
47
|
+
|
|
48
|
+
- `createdBy` / `updatedBy` were not written for data an MCP tool created or changed,
|
|
49
|
+
- `mongooseRoleGuardPlugin` let a tool's write change `roles` without checking the caller,
|
|
50
|
+
- `assertTenantRole()` refused the call as anonymous.
|
|
51
|
+
|
|
52
|
+
Since 11.43.3 the controller sets `req.user` for a user it resolved itself, so all three behave as for a cookie or
|
|
53
|
+
JWT request.
|
|
54
|
+
|
|
55
|
+
**What to check:**
|
|
56
|
+
|
|
57
|
+
- **An MCP tool of your project writes `roles` and relied on the role guard plugin letting it through:** under an
|
|
58
|
+
OAuth token that write is now checked against the user, as it always was under a cookie or a JWT. Run such a
|
|
59
|
+
write inside `RequestContext.runWithBypassRoleGuard()` when the tool is meant to assign roles.
|
|
60
|
+
- **A test in your project pins the old refusal** ("Tenant context required" for an OAuth member): flip it to
|
|
61
|
+
assert the data, and keep the cases for a non-member and for a missing header refused.
|
|
62
|
+
- **You override `CoreAiMcpController.handlePost()` or `handleGet()` / `handleDelete()`** without calling
|
|
63
|
+
`super`: resolve the user through `resolveScopedUser(req)` instead of `resolveUser(req)`, or the fix does not
|
|
64
|
+
reach your controller.
|
|
65
|
+
|
|
66
|
+
## Compatibility Notes
|
|
67
|
+
|
|
68
|
+
| Pattern | Status |
|
|
69
|
+
| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
|
70
|
+
| MCP over a session cookie or a Better-Auth JWT | Unchanged — the guards saw the user before |
|
|
71
|
+
| MCP OAuth without `multiTenancy` | Unchanged apart from §2 |
|
|
72
|
+
| Your own `TenantContextResolver` registered via `setTenantContextResolver()` | Compiles unchanged — `resolveMembership()` is optional; without it an OAuth request gets no tenant, as before |
|
|
73
|
+
| An override of `resolveUser()` | Still used — `resolveScopedUser()` calls it |
|
|
74
|
+
|
|
75
|
+
## Module Documentation
|
|
76
|
+
|
|
77
|
+
- [AI module → Tool access is the user's access](../src/core/modules/ai/README.md#tool-access-is-the-users-access)
|
|
78
|
+
- [Tenant module](../src/core/modules/tenant/README.md)
|
|
79
|
+
- Implementation: `src/core/modules/ai/core-ai-mcp.controller.ts` → `scopeLateResolvedUser()`,
|
|
80
|
+
`src/core/modules/tenant/core-tenant.guard.ts` → `resolveMembershipContext()`
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Migration Guide: 11.43.3 → 11.43.4
|
|
2
|
+
|
|
3
|
+
A security patch for projects that still run on 11.x. It is published under the npm dist-tag `latest-11`, not `latest`:
|
|
4
|
+
12.x stays the version a plain install resolves to, and moving every project to 12.x remains the goal. This release
|
|
5
|
+
carries only urgent security fixes; functional fixes from 12.x are not backported.
|
|
6
|
+
|
|
7
|
+
## Overview
|
|
8
|
+
|
|
9
|
+
| Category | Details |
|
|
10
|
+
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
11
|
+
| **Bugfixes** | A legacy logout ends the device's refresh token; with `allDevices` it ends every device (§1). The legacy REST rate limit can no longer be bypassed with a query string (§1b) |
|
|
12
|
+
| **New Features** | None |
|
|
13
|
+
| **Framework API** | Unchanged |
|
|
14
|
+
| **Dependencies** | `nodemailer` 10.1.2 → 10.1.4 (§2), `multer` 2.4.0 → 2.5.0 (§3) |
|
|
15
|
+
| **Breaking Changes** | None |
|
|
16
|
+
| **Migration Effort** | Raise the version, add or raise the `multer` override (§3), refresh four transitive packages (§4) |
|
|
17
|
+
|
|
18
|
+
## Quick Migration
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
# npm mode: the version is pinned exactly, and `latest` is 12.x, so name the version
|
|
22
|
+
pnpm add @lenne.tech/nest-server@11.43.4
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Then add the `multer` override from §3 to `pnpm-workspace.yaml` (or raise the one you already have), raise the
|
|
26
|
+
`body-parser` and `fast-uri` entries from §4 if your project carries them, and refresh:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pnpm install
|
|
30
|
+
pnpm update --depth Infinity body-parser zod fast-uri @hono/node-server
|
|
31
|
+
pnpm audit --prod
|
|
32
|
+
pnpm test
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Vendor-mode projects update the core to the tag `11.43.4` with the core updater and apply §3 and §4 the same way.
|
|
36
|
+
|
|
37
|
+
## 1. A legacy logout did not end the refresh token
|
|
38
|
+
|
|
39
|
+
`CoreAuthService.logout()` removed the device from `refreshTokens` and wrote the map back through `update()`. `update()`
|
|
40
|
+
merges its input into the stored record, so a key that is merely missing from the input survives: the logged-out
|
|
41
|
+
device's refresh token kept working, and `logout(allDevices)`, which wrote an empty map, left every device signed in.
|
|
42
|
+
A user who signed out, or signed out everywhere after losing a device, stayed reachable for whoever held the refresh
|
|
43
|
+
token.
|
|
44
|
+
|
|
45
|
+
The logout now writes `null` for every device it ends, in `refreshTokens` and `tempTokens`; `validateUser()` reads a
|
|
46
|
+
`null` device as signed out. No action is needed. A project that overrides `logout()` and writes the token maps itself
|
|
47
|
+
must do the same: write `null` for an ended device, never delete its key.
|
|
48
|
+
|
|
49
|
+
This affects every project that keeps the legacy endpoints open (`auth.legacyEndpoints`, or the three-argument
|
|
50
|
+
`CoreModule.forRoot()` from before 11.38.0). IAM (Better-Auth) sessions are not affected.
|
|
51
|
+
|
|
52
|
+
## 1b. The legacy REST rate limit counted every spelling of a route separately
|
|
53
|
+
|
|
54
|
+
`LegacyAuthRateLimitGuard` keyed the REST counter on the last segment of `request.url`, query string included. Express
|
|
55
|
+
routes `/auth/signin?n=1`, `/auth/signin?n=2` and `/auth/SignIn/` to the same handler, but each got a fresh bucket, so
|
|
56
|
+
`auth.rateLimit` never stopped password guessing against `/auth/signin` (or sign-up spam against `/auth/signup`). The
|
|
57
|
+
counter is now keyed on the route handler. No action is needed; projects with `auth.rateLimit` configured and the legacy
|
|
58
|
+
REST endpoints open are the ones that were exposed.
|
|
59
|
+
|
|
60
|
+
## 2. nodemailer 10.1.4
|
|
61
|
+
|
|
62
|
+
Three advisories in nodemailer's address parser, all moderate (CVSS 6.5), all patched in 10.1.4: a nested comment
|
|
63
|
+
([GHSA-8c8m-p3m8-cjg8](https://github.com/advisories/GHSA-8c8m-p3m8-cjg8)), a `;` inside a quoted string
|
|
64
|
+
([GHSA-m943-m63m-w62w](https://github.com/advisories/GHSA-m943-m63m-w62w)) and a `>` inside a quoted local part
|
|
65
|
+
([GHSA-85x2-q946-cp5h](https://github.com/advisories/GHSA-85x2-q946-cp5h)) each turn text of an address into an
|
|
66
|
+
additional recipient. nest-server pins nodemailer itself, so raising the framework is enough. A project that declares
|
|
67
|
+
its own `nodemailer`, or carries an override for it, raises that to 10.1.4 as well.
|
|
68
|
+
|
|
69
|
+
## 3. multer 2.5.0 — the override is needed again
|
|
70
|
+
|
|
71
|
+
[GHSA-q7qp-rjg7-q26m](https://github.com/advisories/GHSA-q7qp-rjg7-q26m) (high, CVSS 7.5): a pending asynchronous
|
|
72
|
+
`fileFilter` after the request was torn down leads to a denial of service. It covers 2.4.0, patched in 2.5.0.
|
|
73
|
+
|
|
74
|
+
nest-server now declares 2.5.0, but every `@nestjs/platform-express` 11.x up to 11.2.8, the newest, pins multer
|
|
75
|
+
**exactly** (2.4.0), and `FileInterceptor` uses that copy. No 11.x update reaches the fix, so the project needs an
|
|
76
|
+
override:
|
|
77
|
+
|
|
78
|
+
```yaml
|
|
79
|
+
# pnpm-workspace.yaml of the project
|
|
80
|
+
overrides:
|
|
81
|
+
# @nestjs/platform-express 11.x exact-pins multer 2.4.0 (GHSA-q7qp-rjg7-q26m, fixed 2.5.0)
|
|
82
|
+
'multer@>=2.0.0 <2.5.0': '2.5.0'
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
A project that already mirrors the older entry (`'multer@>=2.0.0 <2.4.0': '2.4.0'`) must raise both key and target:
|
|
86
|
+
the old target is the vulnerable version.
|
|
87
|
+
|
|
88
|
+
## 4. Transitive packages: refresh the lockfile
|
|
89
|
+
|
|
90
|
+
Four advisories of the same week sit in packages nest-server does not pin; they are fixed by a lockfile refresh in the
|
|
91
|
+
project (the command in Quick Migration):
|
|
92
|
+
|
|
93
|
+
| Package | Advisory | Patched |
|
|
94
|
+
| ------- | -------- | ------- |
|
|
95
|
+
| `body-parser` (via `@apollo/server`, express) | [GHSA-27r3-jxmh-p828](https://github.com/advisories/GHSA-27r3-jxmh-p828), high | 2.4.0 |
|
|
96
|
+
| `zod` (via `@modelcontextprotocol/sdk`) | [GHSA-mrhx-c7xc-m3w5](https://github.com/advisories/GHSA-mrhx-c7xc-m3w5), high | 4.6.6 |
|
|
97
|
+
| `fast-uri` (via ajv) | four advisories up to moderate | 3.1.9 |
|
|
98
|
+
| `@hono/node-server` (via `@modelcontextprotocol/sdk`) | [GHSA-rmxm-3fg6-px4f](https://github.com/advisories/GHSA-rmxm-3fg6-px4f), moderate | 2.1.3 |
|
|
99
|
+
|
|
100
|
+
**A project that carries an override for `body-parser` or `fast-uri`** (starter-based projects do) must raise it, or
|
|
101
|
+
the old target keeps the vulnerable version in place:
|
|
102
|
+
|
|
103
|
+
```yaml
|
|
104
|
+
'body-parser@>=2.0.0 <2.4.0': '2.4.0' # was '<2.3.0': '2.3.0'
|
|
105
|
+
'fast-uri@<3.1.9': '3.1.9' # was '<3.1.8': '3.1.8'
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`pnpm run check:overrides`, where a project has it, reports each of these as `TOO LOW`.
|
|
109
|
+
|
|
110
|
+
## Not in this release
|
|
111
|
+
|
|
112
|
+
Fixes from 12.x that are not urgent security fixes stay there, among them the configuration dump of a second
|
|
113
|
+
`new ConfigService()` (12.0.0) and the fixes for `distinct()` over ObjectId fields and for names lost at an IAM sign-in
|
|
114
|
+
(12.1.0). They arrive with the move to 12: `migration-guides/11.43.x-to-12.0.x.md`.
|
|
115
|
+
|
|
116
|
+
## Module Documentation
|
|
117
|
+
|
|
118
|
+
- [Auth (Legacy)](../src/core/modules/auth/)
|
|
119
|
+
- [Security overrides](../docs/security-overrides.md)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lenne.tech/nest-server",
|
|
3
|
-
"version": "11.43.
|
|
3
|
+
"version": "11.43.4",
|
|
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",
|
|
@@ -127,9 +127,9 @@
|
|
|
127
127
|
"lodash": "4.18.1",
|
|
128
128
|
"mongodb": "7.7.0",
|
|
129
129
|
"mongoose": "9.11.2",
|
|
130
|
-
"multer": "2.
|
|
130
|
+
"multer": "2.5.0",
|
|
131
131
|
"node-mailjet": "6.0.11",
|
|
132
|
-
"nodemailer": "10.1.
|
|
132
|
+
"nodemailer": "10.1.4",
|
|
133
133
|
"passport": "0.7.0",
|
|
134
134
|
"passport-jwt": "4.0.1",
|
|
135
135
|
"reflect-metadata": "0.2.2",
|
|
@@ -213,7 +213,7 @@
|
|
|
213
213
|
"oxfmt": "0.73.0",
|
|
214
214
|
"oxlint": "1.88.0",
|
|
215
215
|
"rimraf": "6.1.3",
|
|
216
|
-
"smtp-server": "3.
|
|
216
|
+
"smtp-server": "3.21.1",
|
|
217
217
|
"ts-node": "10.9.2",
|
|
218
218
|
"tsconfig-paths": "4.2.0",
|
|
219
219
|
"tsx": "4.23.15",
|
|
@@ -28,6 +28,18 @@ export interface TenantContextResolver {
|
|
|
28
28
|
* validated against an active membership before it is returned
|
|
29
29
|
*/
|
|
30
30
|
resolve(user: { id: string; roles?: string[] } | undefined, headerTenantId?: string): Promise<ResolvedTenantContext>;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The narrower answer `CoreTenantGuard` gives an `@Roles(S_EVERYONE)` route: a tenant only from an
|
|
34
|
+
* ACTIVE membership in the tenant the header names (in an active tenant) — no admin bypass, and no
|
|
35
|
+
* fallback to every membership when no tenant is named. For an HTTP route that authenticates its
|
|
36
|
+
* caller itself, after the guards ran without a user (the MCP endpoint and its OAuth access token),
|
|
37
|
+
* so that route scopes the request exactly as the guard would have.
|
|
38
|
+
*
|
|
39
|
+
* Optional because the interface is public. A caller finding it absent establishes no tenant, which
|
|
40
|
+
* leaves the plugin's safety net to refuse tenant-scoped reads.
|
|
41
|
+
*/
|
|
42
|
+
resolveMembership?(user: { id: string; roles?: string[] }, headerTenantId?: string): Promise<ResolvedTenantContext>;
|
|
31
43
|
}
|
|
32
44
|
|
|
33
45
|
let resolver: TenantContextResolver | undefined;
|
|
@@ -619,6 +619,12 @@ them:
|
|
|
619
619
|
so `mongooseTenantPlugin` filters by the tenant the guard validated. Without a tenant header there
|
|
620
620
|
is no tenant scope on these routes and tenant-scoped reads are refused (403) — an AI client is
|
|
621
621
|
never broader than its user, at worst narrower.
|
|
622
|
+
An **MCP OAuth access token** is accepted only by the MCP controller, after the guards ran without a
|
|
623
|
+
user. Since 11.43.3 the controller gives such a request what the guard gives a user it saw: the user
|
|
624
|
+
on the request (so `RequestContext.currentUser`, the audit fields and `mongooseRoleGuardPlugin` see
|
|
625
|
+
it), and the tenant the header names — only when the user is an ACTIVE member of it, through the
|
|
626
|
+
guard's own check (`CoreTenantGuard.resolveMembershipContext()`). Before, every tenant-scoped tool
|
|
627
|
+
answered "Tenant context required" to an OAuth client, even for a member.
|
|
622
628
|
- **Service-level rules** apply on every entry point when the service enforces them itself —
|
|
623
629
|
`assertTenantRole()` (see the tenant module README) — instead of relying on a controller's
|
|
624
630
|
`@Roles()`, which a tool calling the service never passes through.
|
|
@@ -7,6 +7,7 @@ import { Roles } from '../../common/decorators/roles.decorator';
|
|
|
7
7
|
import { RoleEnum } from '../../common/enums/role.enum';
|
|
8
8
|
import { ConfigService } from '../../common/services/config.service';
|
|
9
9
|
import { CoreRedisService } from '../../common/services/core-redis.service';
|
|
10
|
+
import { getTenantContextResolver } from '../../common/services/core-tenant-context.registry';
|
|
10
11
|
import { CoreBetterAuthModule } from '../better-auth/core-better-auth.module';
|
|
11
12
|
import { ErrorCode } from '../error-code/error-codes';
|
|
12
13
|
import { getMountedMcpOAuth } from './helpers/ai-mcp-oauth.registry';
|
|
@@ -36,7 +37,9 @@ import { AiToolUser } from './tools/ai-tool.registry';
|
|
|
36
37
|
*
|
|
37
38
|
* When `ai.mcp.oauth` is enabled, the handler additionally accepts OAuth 2.1 access
|
|
38
39
|
* tokens (endpoints mounted by `CoreAiMcpOAuthInitializer`); otherwise it authenticates via
|
|
39
|
-
* Bearer/session token.
|
|
40
|
+
* Bearer/session token. A user resolved here rather than by the guards — the OAuth token always —
|
|
41
|
+
* gets the request scope the guards would have given it (`req.user`, and a tenant from an active
|
|
42
|
+
* membership in the tenant the header names): see {@link CoreAiMcpController.scopeLateResolvedUser}.
|
|
40
43
|
*
|
|
41
44
|
* ## Multi-replica
|
|
42
45
|
*
|
|
@@ -121,7 +124,7 @@ export class CoreAiMcpController implements OnModuleDestroy {
|
|
|
121
124
|
})
|
|
122
125
|
@Post()
|
|
123
126
|
async handlePost(@Req() req: Request, @Res() res: Response): Promise<void> {
|
|
124
|
-
const user = await this.
|
|
127
|
+
const user = await this.resolveScopedUser(req);
|
|
125
128
|
if (!user?.id) {
|
|
126
129
|
this.unauthorized(res);
|
|
127
130
|
return;
|
|
@@ -274,6 +277,59 @@ export class CoreAiMcpController implements OnModuleDestroy {
|
|
|
274
277
|
return null;
|
|
275
278
|
}
|
|
276
279
|
|
|
280
|
+
/**
|
|
281
|
+
* {@link resolveUser}, plus — for a user resolved HERE rather than seen by the guards — the request
|
|
282
|
+
* scope the guards give a user they saw (see {@link scopeLateResolvedUser}).
|
|
283
|
+
*
|
|
284
|
+
* Whether the guards saw the user is read BEFORE `resolveUser()` runs, so an override of it that sets
|
|
285
|
+
* `req.user` itself still counts as resolved late.
|
|
286
|
+
*/
|
|
287
|
+
protected async resolveScopedUser(req: Request): Promise<any | null> {
|
|
288
|
+
const seenByGuards = !!(req as any).user?.id;
|
|
289
|
+
const user = await this.resolveUser(req);
|
|
290
|
+
if (user?.id && !seenByGuards) {
|
|
291
|
+
await this.scopeLateResolvedUser(req, user);
|
|
292
|
+
}
|
|
293
|
+
return user;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Give a request whose user this controller resolved itself what the guards give a request whose
|
|
298
|
+
* user they saw: the user on the request, and a tenant from an active membership only.
|
|
299
|
+
*
|
|
300
|
+
* The guards run before this controller, and an MCP OAuth access token is accepted by nothing else
|
|
301
|
+
* — so `CoreTenantGuard` found no `request.user` and never turned the tenant header into a context.
|
|
302
|
+
* In 11.43.2 and earlier that left two things behind:
|
|
303
|
+
* - **No tenant scope.** Every tenant-scoped tool answered "Tenant context required", even with a
|
|
304
|
+
* tenant header naming a tenant the user is an active member of. Fail-closed, but unusable.
|
|
305
|
+
* - **No current user in `RequestContext`**, which reads `req.user` lazily. The Mongoose plugins read
|
|
306
|
+
* that as SYSTEM work: no `createdBy` / `updatedBy`, `mongooseRoleGuardPlugin` let a tool's write
|
|
307
|
+
* change roles, and `assertTenantRole()` refused the call as anonymous.
|
|
308
|
+
*
|
|
309
|
+
* The tenant comes from `CoreTenantGuard.resolveMembershipContext()`, the same decision its
|
|
310
|
+
* `@Roles(S_EVERYONE)` branch makes for a user it saw — so an OAuth token, a session cookie and a
|
|
311
|
+
* Better-Auth JWT reach the same scope. The header alone never establishes one: a tenant the user is
|
|
312
|
+
* not an active member of yields none, and the plugin's safety net refuses the read.
|
|
313
|
+
*/
|
|
314
|
+
protected async scopeLateResolvedUser(req: Request, user: { id: string; roles?: string[] }): Promise<void> {
|
|
315
|
+
(req as any).user = user;
|
|
316
|
+
|
|
317
|
+
const config = ConfigService.configFastButReadOnly?.multiTenancy;
|
|
318
|
+
if (!config || config.enabled === false || (req as any).tenantId) {
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const headerName = (config.headerName ?? 'x-tenant-id').toLowerCase();
|
|
322
|
+
const header = req.headers?.[headerName];
|
|
323
|
+
const resolved = await getTenantContextResolver()?.resolveMembership?.(
|
|
324
|
+
user,
|
|
325
|
+
typeof header === 'string' ? header : undefined,
|
|
326
|
+
);
|
|
327
|
+
if (resolved?.tenantId) {
|
|
328
|
+
(req as any).tenantId = resolved.tenantId;
|
|
329
|
+
(req as any).tenantRole = resolved.tenantRole;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
277
333
|
/**
|
|
278
334
|
* Whether the body is an `initialize` — the one request that legitimately starts a session.
|
|
279
335
|
*
|
|
@@ -298,7 +354,7 @@ export class CoreAiMcpController implements OnModuleDestroy {
|
|
|
298
354
|
* Forward a GET (SSE stream) or DELETE (close) to the session's transport.
|
|
299
355
|
*/
|
|
300
356
|
private async handleSessionRequest(req: Request, res: Response): Promise<void> {
|
|
301
|
-
const user = await this.
|
|
357
|
+
const user = await this.resolveScopedUser(req);
|
|
302
358
|
if (!user?.id) {
|
|
303
359
|
this.unauthorized(res);
|
|
304
360
|
return;
|
|
@@ -76,9 +76,10 @@ export class LegacyAuthRateLimitGuard implements CanActivate {
|
|
|
76
76
|
const request = context.switchToHttp().getRequest();
|
|
77
77
|
const ip = this.getClientIp(request);
|
|
78
78
|
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
// The handler, never the URL: the URL carries the query string and the client's spelling of the path
|
|
80
|
+
// (Express routes case-insensitively and ignores a trailing slash), so every variant was a fresh bucket and
|
|
81
|
+
// `/auth/signin?n=1`, `?n=2`, … guessed passwords without ever reaching the limit.
|
|
82
|
+
const endpoint = context.getHandler()?.name || 'unknown';
|
|
82
83
|
|
|
83
84
|
return { endpoint, ip };
|
|
84
85
|
}
|
|
@@ -130,16 +130,30 @@ export class CoreAuthService {
|
|
|
130
130
|
throw new UnauthorizedException(ErrorCode.INVALID_TOKEN);
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
// A device is ended by writing `null` for it, not by deleting its key: update() MERGES the input into the stored
|
|
134
|
+
// record, so a key that is merely missing from the input survives — and with it the refresh token this logout
|
|
135
|
+
// must end. `validateUser()` reads a `null` device as "not signed in".
|
|
136
|
+
const ended = (deviceIds: string[]) => Object.fromEntries(deviceIds.map((id) => [id, null]));
|
|
137
|
+
|
|
133
138
|
// Logout from all devices
|
|
134
139
|
if (serviceOptions.allDevices) {
|
|
140
|
+
const deviceIds = Object.keys(user.refreshTokens);
|
|
135
141
|
user.refreshTokens = {};
|
|
136
|
-
await this.userService.update(
|
|
142
|
+
await this.userService.update(
|
|
143
|
+
user.id,
|
|
144
|
+
{ refreshTokens: ended(deviceIds), tempTokens: ended(Object.keys(user.tempTokens ?? {})) },
|
|
145
|
+
serviceOptions,
|
|
146
|
+
);
|
|
137
147
|
return true;
|
|
138
148
|
}
|
|
139
149
|
|
|
140
150
|
// Logout from specific devices
|
|
141
151
|
delete user.refreshTokens[deviceId];
|
|
142
|
-
await this.userService.update(
|
|
152
|
+
await this.userService.update(
|
|
153
|
+
user.id,
|
|
154
|
+
{ refreshTokens: ended([deviceId]), tempTokens: ended([deviceId]) },
|
|
155
|
+
serviceOptions,
|
|
156
|
+
);
|
|
143
157
|
return true;
|
|
144
158
|
}
|
|
145
159
|
|
|
@@ -181,6 +181,7 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
181
181
|
// GraphQL WebSocket. See core-tenant-context.registry.ts for why this is a registry and not DI.
|
|
182
182
|
setTenantContextResolver({
|
|
183
183
|
resolve: (user, headerTenantId) => this.resolveTenantContext(user, headerTenantId),
|
|
184
|
+
resolveMembership: (user, headerTenantId) => this.resolveMembershipContext(user, headerTenantId),
|
|
184
185
|
});
|
|
185
186
|
|
|
186
187
|
// Lets the role guards delegate non-system roles to this guard — only while it exists.
|
|
@@ -248,6 +249,39 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
248
249
|
return { tenantIds: carrier.tenantIds ?? [] };
|
|
249
250
|
}
|
|
250
251
|
|
|
252
|
+
/**
|
|
253
|
+
* The tenant context `canActivate()` gives an `@Roles(S_EVERYONE)` route, for a caller it could not
|
|
254
|
+
* see: a route that authenticates its caller ITSELF, after the guards ran without a user.
|
|
255
|
+
*
|
|
256
|
+
* That is the MCP endpoint with an MCP OAuth access token — a credential nothing but its controller
|
|
257
|
+
* accepts, so the guard found no `request.user`, never checked the tenant header against a
|
|
258
|
+
* membership, and every tenant-scoped tool answered "Tenant context required" to a member of that
|
|
259
|
+
* very tenant. Answering through the same {@link memberTenantContext} as the guard's own branch is
|
|
260
|
+
* what keeps the result independent of which credential the caller used.
|
|
261
|
+
*
|
|
262
|
+
* Narrower than {@link resolveTenantContext} on purpose: that one answers for a transport whose every
|
|
263
|
+
* operation needs a scope (no admin bypass here, no list of all memberships without a header).
|
|
264
|
+
*/
|
|
265
|
+
async resolveMembershipContext(
|
|
266
|
+
user: { id: string; roles?: string[] } | undefined,
|
|
267
|
+
headerTenantId?: string,
|
|
268
|
+
): Promise<ResolvedTenantContext> {
|
|
269
|
+
const config = ConfigService.configFastButReadOnly?.multiTenancy;
|
|
270
|
+
if (!config || config.enabled === false) {
|
|
271
|
+
return {};
|
|
272
|
+
}
|
|
273
|
+
this.syncConfig(config);
|
|
274
|
+
|
|
275
|
+
const tenantId =
|
|
276
|
+
headerTenantId && typeof headerTenantId === 'string' && headerTenantId.length <= 128
|
|
277
|
+
? headerTenantId.trim()
|
|
278
|
+
: undefined;
|
|
279
|
+
if (!tenantId || !user?.id) {
|
|
280
|
+
return {};
|
|
281
|
+
}
|
|
282
|
+
return (await this.memberTenantContext(user, tenantId)) ?? {};
|
|
283
|
+
}
|
|
284
|
+
|
|
251
285
|
/**
|
|
252
286
|
* Subscribe to the cross-replica cache invalidation channel.
|
|
253
287
|
*
|
|
@@ -414,10 +448,10 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
414
448
|
// Optionally enrich with tenant context when header is present and user is an active member.
|
|
415
449
|
// Never block access — S_EVERYONE endpoints are always public.
|
|
416
450
|
if (headerTenantId && request.user?.id) {
|
|
417
|
-
const
|
|
418
|
-
if (
|
|
419
|
-
request.tenantId =
|
|
420
|
-
request.tenantRole =
|
|
451
|
+
const member = await this.memberTenantContext(request.user, headerTenantId);
|
|
452
|
+
if (member) {
|
|
453
|
+
request.tenantId = member.tenantId;
|
|
454
|
+
request.tenantRole = member.tenantRole;
|
|
421
455
|
}
|
|
422
456
|
}
|
|
423
457
|
return true;
|
|
@@ -901,6 +935,22 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
901
935
|
return active;
|
|
902
936
|
}
|
|
903
937
|
|
|
938
|
+
/**
|
|
939
|
+
* The context an ACTIVE membership in an active tenant establishes, or undefined. Never the header
|
|
940
|
+
* alone: a freely chosen tenant id only becomes a scope once it names one of the caller's own tenants.
|
|
941
|
+
* Shared by the `S_EVERYONE` branch of `canActivate()` and {@link resolveMembershipContext}.
|
|
942
|
+
*/
|
|
943
|
+
private async memberTenantContext(
|
|
944
|
+
user: { id: string; roles?: string[] },
|
|
945
|
+
tenantId: string,
|
|
946
|
+
): Promise<{ tenantId: string; tenantRole?: string } | undefined> {
|
|
947
|
+
const membership = await this.findMembershipCached(user.id, tenantId);
|
|
948
|
+
if (!membership || !(await this.isTenantActive(tenantId))) {
|
|
949
|
+
return undefined;
|
|
950
|
+
}
|
|
951
|
+
return { tenantId, tenantRole: capApiTokenTenantRole(user, membership.role as string) ?? undefined };
|
|
952
|
+
}
|
|
953
|
+
|
|
904
954
|
/** 403 for a deactivated tenant. */
|
|
905
955
|
private async assertTenantActive(tenantId: string): Promise<void> {
|
|
906
956
|
if (!(await this.isTenantActive(tenantId))) {
|