@lenne.tech/nest-server 11.41.9 → 11.42.1

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.
Files changed (67) hide show
  1. package/.claude/rules/configurable-features.md +4 -3
  2. package/.claude/rules/role-system.md +31 -0
  3. package/.claude/rules/testing.md +4 -4
  4. package/CLAUDE.md +6 -0
  5. package/FRAMEWORK-API.md +4 -2
  6. package/dist/core/common/helpers/graphql-ws-context.helper.js +1 -1
  7. package/dist/core/common/helpers/graphql-ws-context.helper.js.map +1 -1
  8. package/dist/core/common/helpers/outbound-url.helper.d.ts +14 -0
  9. package/dist/core/common/helpers/outbound-url.helper.js +234 -0
  10. package/dist/core/common/helpers/outbound-url.helper.js.map +1 -0
  11. package/dist/core/common/interfaces/server-options.interface.d.ts +6 -0
  12. package/dist/core/common/middleware/request-context.middleware.js +1 -0
  13. package/dist/core/common/middleware/request-context.middleware.js.map +1 -1
  14. package/dist/core/common/services/request-context.service.d.ts +1 -0
  15. package/dist/core/common/services/request-context.service.js.map +1 -1
  16. package/dist/core/modules/ai/core-ai-mcp.controller.js +5 -2
  17. package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
  18. package/dist/core/modules/ai/services/core-ai-mcp.service.d.ts +1 -1
  19. package/dist/core/modules/ai/services/core-ai-mcp.service.js +3 -2
  20. package/dist/core/modules/ai/services/core-ai-mcp.service.js.map +1 -1
  21. package/dist/core/modules/ai/tools/ai-tool.registry.js +15 -2
  22. package/dist/core/modules/ai/tools/ai-tool.registry.js.map +1 -1
  23. package/dist/core/modules/tenant/core-tenant-role.helper.d.ts +2 -0
  24. package/dist/core/modules/tenant/core-tenant-role.helper.js +36 -0
  25. package/dist/core/modules/tenant/core-tenant-role.helper.js.map +1 -0
  26. package/dist/core/modules/tenant/core-tenant.guard.d.ts +7 -1
  27. package/dist/core/modules/tenant/core-tenant.guard.js +56 -4
  28. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  29. package/dist/core/modules/tus/core-tus.service.d.ts +1 -0
  30. package/dist/core/modules/tus/core-tus.service.js +29 -0
  31. package/dist/core/modules/tus/core-tus.service.js.map +1 -1
  32. package/dist/core/modules/tus/interfaces/tus-config.interface.d.ts +2 -1
  33. package/dist/core/modules/tus/interfaces/tus-config.interface.js +1 -0
  34. package/dist/core/modules/tus/interfaces/tus-config.interface.js.map +1 -1
  35. package/dist/core/modules/user/core-user.service.d.ts +1 -0
  36. package/dist/core/modules/user/core-user.service.js +24 -0
  37. package/dist/core/modules/user/core-user.service.js.map +1 -1
  38. package/dist/index.d.ts +2 -0
  39. package/dist/index.js +2 -0
  40. package/dist/index.js.map +1 -1
  41. package/dist/tsconfig.build.tsbuildinfo +1 -1
  42. package/docs/REQUEST-LIFECYCLE.md +4 -2
  43. package/migration-guides/11.41.8-to-11.41.9.md +5 -0
  44. package/migration-guides/11.41.x-to-11.42.x.md +161 -0
  45. package/migration-guides/11.42.0-to-11.42.1.md +66 -0
  46. package/migration-guides/11.9.x-to-11.10.x.md +3 -2
  47. package/package.json +8 -7
  48. package/src/core/common/helpers/graphql-ws-context.helper.ts +1 -1
  49. package/src/core/common/helpers/outbound-url.helper.ts +310 -0
  50. package/src/core/common/interfaces/server-options.interface.ts +71 -0
  51. package/src/core/common/middleware/request-context.middleware.ts +1 -0
  52. package/src/core/common/services/request-context.service.ts +9 -0
  53. package/src/core/modules/ai/README.md +25 -0
  54. package/src/core/modules/ai/core-ai-mcp.controller.ts +22 -3
  55. package/src/core/modules/ai/services/core-ai-mcp.service.ts +9 -3
  56. package/src/core/modules/ai/tools/ai-tool.registry.ts +33 -3
  57. package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +18 -11
  58. package/src/core/modules/tenant/INTEGRATION-CHECKLIST.md +14 -0
  59. package/src/core/modules/tenant/README.md +80 -8
  60. package/src/core/modules/tenant/core-tenant-role.helper.ts +75 -0
  61. package/src/core/modules/tenant/core-tenant.guard.ts +78 -5
  62. package/src/core/modules/tus/INTEGRATION-CHECKLIST.md +3 -1
  63. package/src/core/modules/tus/README.md +20 -0
  64. package/src/core/modules/tus/core-tus.service.ts +45 -0
  65. package/src/core/modules/tus/interfaces/tus-config.interface.ts +4 -1
  66. package/src/core/modules/user/core-user.service.ts +53 -0
  67. package/src/index.ts +2 -0
@@ -107,10 +107,10 @@ JWT-based authentication for existing projects:
107
107
  | **Response Filter Interceptor** | Enforces `@Restricted()` field-level access |
108
108
  | **Translation Interceptor** | Applies `_translations` based on `Accept-Language` |
109
109
  | **Secret Fields Removal** | Configurable fallback removal of password, tokens, etc. |
110
- | **RequestContext** | `AsyncLocalStorage`-based context for current user in Mongoose hooks |
110
+ | **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 |
111
111
  | **Query Complexity** | GraphQL query complexity analysis to prevent DoS |
112
112
  | **Tenant Isolation** | Header-based multi-tenant isolation with membership validation (opt-in) |
113
- | **Tenant Guard** | `CoreTenantGuard` validates tenant membership; system roles (`S_EVERYONE`, `S_USER`, `S_VERIFIED`) are checked as OR alternatives before real roles; hierarchy roles (`@Roles(DefaultHR.MEMBER)`), `@SkipTenantCheck()`, BetterAuth auto-skip (`betterAuth.skipTenantCheck`) |
113
+ | **Tenant Guard** | `CoreTenantGuard` validates tenant membership — and, with `multiTenancy.isTenantActive` (11.42.0), that the tenant is not deactivated; system roles (`S_EVERYONE`, `S_USER`, `S_VERIFIED`) are checked as OR alternatives before real roles; hierarchy roles (`@Roles(DefaultHR.MEMBER)`), `@SkipTenantCheck()`, BetterAuth auto-skip (`betterAuth.skipTenantCheck`) |
114
114
  | **Tenant Plugin Safety Net** | Mongoose tenant plugin throws `ForbiddenException` when tenant-schema is accessed without valid tenant context |
115
115
  | **API Tokens** | `apiTokens` config (opt-in): USER tokens act as their user without global roles; TENANT tokens (multi-tenancy only) act with the lowest tenant role in their own tenant. `CoreApiTokenMiddleware` authenticates `Authorization: Bearer` / `x-api-key`; every guard denies a token unless the route declares `@ApiTokenScopes()`. Signed short-lived assertions for embedded pages. See `src/core/modules/api-token/README.md` |
116
116
 
@@ -373,6 +373,8 @@ The following diagram shows the exact order of execution from HTTP request to re
373
373
  | - Non-admin + header + no membership = always 403 |
374
374
  | - Checks configurable roleHierarchy levels |
375
375
  | - Admin bypass: sets isAdminBypass (sees all data) |
376
+ | - multiTenancy.isTenantActive (11.42.0): deactivated |
377
+ | tenant = 403 / dropped from tenantIds (not admin) |
376
378
  | - Sets tenantId in RequestContext |
377
379
  | - @SkipTenantCheck() opts out of tenant validation |
378
380
  | - BetterAuth auto-skip: IAM handlers skip tenant |
@@ -77,6 +77,11 @@ GraphQL subscriptions use — and the finished file gets `metadata.tenantId`:
77
77
  | Header naming a tenant the caller belongs to | tenant-less | `metadata.tenantId` set |
78
78
  | Header naming a tenant the caller does NOT belong to | accepted, tenant-less | **403**, nothing stored |
79
79
 
80
+ **Send the header from the TUS client.** A tenant header added by an interceptor of the project's
81
+ generated API client does not reach `tus-js-client`. Without it the upload silently stays tenant-less,
82
+ outside tenant filtering and per-tenant quotas. Pass it explicitly, e.g.
83
+ `useLtTusUpload({ headers: { 'X-Tenant-Id': currentTenantId } })`.
84
+
80
85
  The tenant is recorded under the framework-owned metadata key `TUS_TENANT_METADATA_KEY`
81
86
  (`ltTenantId`), next to the owner key. Like the owner key it is always overwritten, so a client
82
87
  cannot put a tenant into its own `Upload-Metadata`.
@@ -0,0 +1,161 @@
1
+ # Migration Guide: 11.41.x → 11.42.x
2
+
3
+ ## Why this is a MINOR
4
+
5
+ Per `.claude/rules/versioning.md` the MAJOR mirrors NestJS, so **MINOR is where breaking changes
6
+ live**. Two behaviour changes hit multi-tenant projects only: with `multiTenancy.adminBypass: false`
7
+ a platform administrator can no longer set a tenant account's e-mail address or password (§4), and
8
+ the AI tool registry resolves tool roles by the membership role of the request's tenant (§3). A call
9
+ that used to succeed can now be refused, so this is `11.42.0` rather than a patch.
10
+
11
+ **Projects without `multiTenancy` see no behaviour change.** Everything else in this release is
12
+ opt-in.
13
+
14
+ ## Overview
15
+
16
+ | Category | Details |
17
+ | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
18
+ | **New (opt-in)** | `outboundFetch()` and friends — an SSRF guard for URLs a user entered (§1) |
19
+ | **New (opt-in)** | `multiTenancy.isTenantActive` — a deactivated tenant stops working (§2) |
20
+ | **New** | `assertTenantRole()` / `hasTenantRole()` — tenant roles enforced in services, on every entry point (§3) |
21
+ | **Behaviour change** (multi-tenancy) | AI tool roles (chat and MCP) resolve like `@Roles()` in the tenant guard (§3) |
22
+ | **Behaviour change** (`adminBypass: false`) | No credential changes of other accounts through `updateUser`; no ADMIN bypass in the AI tool registry (§4) |
23
+ | **Dependency** | `undici` 7.30.0 is a runtime dependency (§1) — nothing to do |
24
+ | **Migration Effort** | None without multi-tenancy. With it: check §3 and §4; adopt §1–§3 where they apply |
25
+
26
+ ## Quick Migration
27
+
28
+ ```bash
29
+ pnpm update @lenne.tech/nest-server
30
+ pnpm run build && pnpm test
31
+ ```
32
+
33
+ Vendor-mode projects pick up the core update; `undici` reaches them through `dependencies`.
34
+
35
+ ## What's New in 11.42.0
36
+
37
+ ### 1. SSRF guard for user-entered URLs
38
+
39
+ When the server calls a URL a USER entered — a webhook, an export target, an ERP address — it calls it
40
+ from inside its own network. Without a check, a user points it at the database, other containers or
41
+ the cloud metadata service (`169.254.169.254`) and reads the answer back.
42
+
43
+ ```typescript
44
+ import { outboundFetch, OutboundUrlBlockedError } from '@lenne.tech/nest-server';
45
+
46
+ const response = await outboundFetch(webhook.url, { body, headers, method: 'POST' });
47
+ // an ERP in the same network, freed explicitly:
48
+ await outboundFetch(target.url, init, { allowedHosts: ['erp.internal:8443'] });
49
+ ```
50
+
51
+ - **Only for user-entered URLs.** Configured targets (AI providers, SMTP, S3, Redis, Brevo) do not use
52
+ it; nothing is applied globally.
53
+ - Checked twice: on the URL before sending (`OutboundUrlBlockedError`, readable) and on every
54
+ connection — the only place that sees a redirect's next hop and DNS rebinding. A refusal on a later
55
+ hop surfaces as undici's `TypeError: fetch failed` with the `OutboundUrlBlockedError` as `cause`.
56
+ - Internal targets are allowed in `local`, `e2e` and `ci`, refused elsewhere — including the deployed `test` stage.
57
+ `security.allowPrivateOutboundTargets` (`NSC__SECURITY__ALLOW_PRIVATE_OUTBOUND_TARGETS=true|false`)
58
+ decides explicitly.
59
+ - `assertOutboundUrlAllowed(url)` checks without sending; `createOutboundDispatcher()` returns the
60
+ guarded undici `Agent` for code that uses undici directly. `outboundFetch()` uses undici's own
61
+ `fetch`: Node 22's built-in `fetch` bundles a different undici major than the agent.
62
+
63
+ ### 2. Deactivated tenants stop working
64
+
65
+ `CoreTenantGuard` validated the membership only, so a tenant a project switched off kept working for
66
+ its members, its API tokens and every request without a tenant header. Tell the guard:
67
+
68
+ ```typescript
69
+ // config.env.ts
70
+ multiTenancy: {
71
+ isTenantActive: async (tenantId, { connection }) =>
72
+ (await connection.collection('tenants').findOne({ _id: new Types.ObjectId(tenantId) }))?.active !== false,
73
+ }
74
+ ```
75
+
76
+ Then call `tenantGuard.invalidateTenant(tenantId)` right after switching a tenant on or off (answers are
77
+ cached for `cacheTtlMs`; the call broadcasts to every replica with Redis). Effects: 403 on header paths
78
+ and for tenant tokens, no tenant context on public routes, the tenant dropped from the header-less
79
+ tenant list, no tenant for a new WebSocket subscription (one already open keeps its tenant until the client reconnects — the same holds today for a removed membership). A platform administrator under `adminBypass` still gets in.
80
+ Asked after the membership check, so a non-member learns nothing. A function cannot come through
81
+ `NSC__*` — set it in `config.env.ts`.
82
+
83
+ **If your project already has its own "tenant active" guard**, it can stay; the hook additionally
84
+ covers the header-less tenant list, which such a guard usually misses.
85
+
86
+ ### 3. Tenant roles on every entry point — services, MCP and AI chat
87
+
88
+ `@Roles()` protects a route. MCP tools, AI tools, queue processors and other services call a service
89
+ directly and never pass it. Enforce a tenant role in the service:
90
+
91
+ ```typescript
92
+ import { assertTenantRole, DefaultHR } from '@lenne.tech/nest-server';
93
+
94
+ async updateSettings(input: SettingsInput, serviceOptions?: ServiceOptions) {
95
+ assertTenantRole(DefaultHR.OWNER, 'Only owners may change the settings');
96
+ // …
97
+ }
98
+ ```
99
+
100
+ Signed in without the role → 403; anonymous client request → 401; system work (cron, migration, seed —
101
+ no client request) → passes. The last two are told apart by the new `RequestContext.fromRequest`,
102
+ which `RequestContextMiddleware` and the GraphQL WebSocket context set. **If you build a
103
+ `RequestContext` yourself for a client operation** (a custom transport), set `fromRequest: true`.
104
+
105
+ **Behaviour change, multi-tenancy only:** the AI tool registry (chat and `/ai/mcp`) now resolves a
106
+ tool's `roles` like the tenant guard resolves `@Roles()` — a global role against `user.roles`, a tenant
107
+ role against the membership role in the request's tenant (by hierarchy), and no ADMIN bypass where
108
+ `adminBypass: false`. Before, every role was compared with the global `user.roles`. If a tool lists a
109
+ TENANT role and a user relied on holding that name globally, the tool now follows the membership
110
+ instead. An AI client acts within the signed-in user's rights and tenant scope — never beyond.
111
+
112
+ **MCP sessions follow the current user.** A session used to keep the user object from its `initialize`
113
+ request for its whole life, so a role revoked mid-session kept working over MCP until the client
114
+ reconnected. The controller now refreshes the session's user on every request, and
115
+ `CoreAiMcpService.createServer()` receives a **getter** (`() => AiToolUser`) instead of the user object.
116
+ A project that overrides `createServer(user)` must accept the getter (a plain object is still accepted
117
+ when you call it yourself) and read it per request, not once.
118
+
119
+ ### 4. `adminBypass: false` holds for credentials
120
+
121
+ With multi-tenancy and `adminBypass: false`, `PATCH /users/:id` / `updateUser` now refuse (403) a change
122
+ of another account's e-mail address or password — whoever asks, including the account's creator
123
+ (`S_CREATOR` may update, and the e-mail field is open to every updater). Changing the address and
124
+ requesting a reset was a way to take over a tenant account that `adminBypass: false` exists to rule
125
+ out.
126
+
127
+ Unaffected: the account itself, profile fields, an unchanged address sent along, deletion, and
128
+ system work without a current user (invitation, password reset). Administrator accounts are NOT
129
+ exempt as targets — an administrator may write `roles`, so "promote to ADMIN, then change the address"
130
+ would otherwise open every account in two requests. Support for a
131
+ tenant user goes through the password-reset flow. A project that needs a different rule overrides
132
+ `CoreUserService.assertCredentialChangeAllowed()`.
133
+
134
+ ## Compatibility Notes
135
+
136
+ | Pattern | Status |
137
+ | --------------------------------------------------- | --------------------------------------------------------------------- |
138
+ | Project without `multiTenancy` | Unchanged |
139
+ | `multiTenancy` with `adminBypass` on (default) | Unchanged except §3 (AI tool roles follow the membership) |
140
+ | `multiTenancy` with `adminBypass: false` | §3 and §4 apply |
141
+ | Project-own tenant-active guard | Keeps working; the hook adds the header-less path |
142
+ | Project-own SSRF check | Keeps working; consider `outboundFetch()` for redirects and rebinding |
143
+ | Custom `RequestContext.run()` for client operations | Set `fromRequest: true` (§3) |
144
+ | Override of `CoreAiMcpService.createServer(user)` | Receives a getter now — call it per request (§3) |
145
+
146
+ ## Troubleshooting
147
+
148
+ | Symptom | Cause | Fix |
149
+ | -------------------------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------- |
150
+ | `403` when an admin changes a tenant user's e-mail or password | `adminBypass: false` (§4) | Let the user change it, or use the reset flow |
151
+ | An AI tool disappeared for some users | Tool roles follow the membership under multi-tenancy (§3) | List the tenant role the users hold in the tenant, or a global role |
152
+ | `Tenant is inactive` (403) | `isTenantActive` returned `false` | Reactivate, then `invalidateTenant(tenantId)` |
153
+ | A reactivated tenant is still refused | Cached answer | `tenantGuard.invalidateTenant(tenantId)` |
154
+ | `OutboundUrlBlockedError` locally | `security.allowPrivateOutboundTargets: false` set, or a non-local `env` | Unset it, or list the host in `allowedHosts` |
155
+ | `TypeError: fetch failed` from `outboundFetch()` | A redirect or DNS answer pointed inside | Inspect `error.cause` |
156
+
157
+ ## Module Documentation
158
+
159
+ - [Tenant README](../src/core/modules/tenant/README.md) — Admin Bypass, Deactivated Tenants, Service-Level Role Checks
160
+ - [Tenant INTEGRATION-CHECKLIST](../src/core/modules/tenant/INTEGRATION-CHECKLIST.md) — step 11
161
+ - [AI README](../src/core/modules/ai/README.md) — Tool access is the user's access
@@ -0,0 +1,66 @@
1
+ # Migration Guide: 11.42.0 → 11.42.1
2
+
3
+ ## Overview
4
+
5
+ | Category | Details |
6
+ | -------------------- | --------------------------------------------------------------------------------------------- |
7
+ | **Bugfix** | Browser uploads through TUS work with cookie authentication and separate app/API origins (§1) |
8
+ | **New option** | `tus.allowedOrigins` overrides the derived origins (§1) |
9
+ | **Migration Effort** | None — `pnpm update` |
10
+
11
+ ## Quick Migration
12
+
13
+ ```bash
14
+ pnpm update @lenne.tech/nest-server
15
+ ```
16
+
17
+ Vendor-mode projects pick up the core update; nothing else to change.
18
+
19
+ ## 1. TUS answered a credentialed request with `Access-Control-Allow-Origin: *`
20
+
21
+ The tus handler writes its responses itself, past the API's CORS layer, and `@tus/server` answers
22
+ `Access-Control-Allow-Origin: *` unless it is given `allowedOrigins`. The core gave it none. With cookie
23
+ authentication and separate app and API origins (`lt dev up`, or `app.example.com` /
24
+ `api.example.com`), the request is credentialed and the API adds
25
+ `Access-Control-Allow-Credentials: true` — a combination every browser refuses:
26
+
27
+ > The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*'
28
+ > when the request's credentials mode is 'include'.
29
+
30
+ So every browser upload failed, while the server answered `201`. Preflights and unauthenticated
31
+ requests were fine, which is why no test noticed — only an **authenticated** request with an `Origin`
32
+ reaches the header.
33
+
34
+ Since 11.42.1 the tus server takes its origins from `buildCorsConfig()`, the decision REST, GraphQL and
35
+ Better-Auth already share (`appUrl`, `baseUrl`, `cors.allowedOrigins`, `cors.allowAll`), and mirrors an
36
+ allowed `Origin`. A foreign origin gets no `Access-Control-Allow-Origin` at all. To use different
37
+ origins for uploads:
38
+
39
+ ```typescript
40
+ tus: {
41
+ allowedOrigins: ['https://uploads.example.com'];
42
+ }
43
+ ```
44
+
45
+ Without credentialed CORS configured — cookies off, or no origin resolvable — nothing changes; the tus
46
+ default `*` stays.
47
+
48
+ ## 2. Dependencies
49
+
50
+ Updated within their majors: `@modelcontextprotocol/sdk` 1.32.0, `mongoose` 9.10.4, `supertest` 7.3.1, and
51
+ the `@aws-sdk/*` S3 packages 3.1146.0 (dev). NestJS stays on 11.
52
+
53
+ `@modelcontextprotocol/sdk` 1.32.0 lets its CLIENT transports follow redirects only within the same
54
+ origin. The framework uses the server side only, so this matters only to a project that builds its own MCP
55
+ client transport and relied on a cross-origin redirect.
56
+
57
+ ## Troubleshooting
58
+
59
+ | Symptom | Cause | Fix |
60
+ | -------------------------------------------------------------------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------ |
61
+ | Browser: "must not be the wildcard '*' when the request's credentials mode is 'include'" on `/tus` | 11.42.0 or older | Update to 11.42.1 |
62
+ | Upload from an origin that is not the app refused by CORS | Origin not in `appUrl` / `cors.allowedOrigins` | Add it to `cors.allowedOrigins`, or set `tus.allowedOrigins` |
63
+
64
+ ## Module Documentation
65
+
66
+ - [TUS README](../src/core/modules/tus/README.md) — CORS Headers
@@ -124,7 +124,8 @@ New `CoreBetterAuthChallengeService` enables Passkey authentication in **cookiel
124
124
  **Client-side usage (JWT mode):**
125
125
  ```typescript
126
126
  // Use composable methods which handle challengeId automatically
127
- const { authenticateWithPasskey, registerPasskey } = useBetterAuth();
127
+ // useLtAuth() from @lenne.tech/nuxt-extensions; starters before nuxt-extensions called it useBetterAuth()
128
+ const { authenticateWithPasskey, registerPasskey } = useLtAuth();
128
129
 
129
130
  // Authentication
130
131
  const result = await authenticateWithPasskey();
@@ -495,7 +496,7 @@ betterAuth: {
495
496
  **Solution:** Ensure you're using the composable methods that handle `challengeId`:
496
497
  ```typescript
497
498
  // CORRECT: Use composable methods
498
- const { authenticateWithPasskey } = useBetterAuth();
499
+ const { authenticateWithPasskey } = useLtAuth(); // formerly useBetterAuth() in the starter
499
500
  await authenticateWithPasskey();
500
501
 
501
502
  // WRONG: Direct client call (doesn't handle challengeId in JWT mode)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.41.9",
3
+ "version": "11.42.1",
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",
@@ -92,7 +92,7 @@
92
92
  "@apollo/server": "5.5.1",
93
93
  "@as-integrations/express5": "1.1.2",
94
94
  "@getbrevo/brevo": "6.0.3",
95
- "@modelcontextprotocol/sdk": "1.31.0",
95
+ "@modelcontextprotocol/sdk": "1.32.0",
96
96
  "@nestjs/apollo": "13.4.5",
97
97
  "@nestjs/common": "11.2.7",
98
98
  "@nestjs/core": "11.2.7",
@@ -126,7 +126,7 @@
126
126
  "json-to-graphql-query": "2.3.0",
127
127
  "lodash": "4.18.1",
128
128
  "mongodb": "7.6.0",
129
- "mongoose": "9.10.3",
129
+ "mongoose": "9.10.4",
130
130
  "multer": "2.4.0",
131
131
  "node-mailjet": "6.0.11",
132
132
  "nodemailer": "10.0.13",
@@ -135,8 +135,9 @@
135
135
  "reflect-metadata": "0.2.2",
136
136
  "rfdc": "1.4.1",
137
137
  "rxjs": "7.8.2",
138
- "supertest": "7.3.0",
138
+ "supertest": "7.3.1",
139
139
  "ts-morph": "28.0.0",
140
+ "undici": "7.30.0",
140
141
  "ws": "8.21.3",
141
142
  "yuml-diagram": "1.2.0"
142
143
  },
@@ -181,9 +182,9 @@
181
182
  }
182
183
  },
183
184
  "devDependencies": {
184
- "@aws-sdk/client-s3": "3.1145.0",
185
- "@aws-sdk/lib-storage": "3.1145.0",
186
- "@aws-sdk/s3-request-presigner": "3.1145.0",
185
+ "@aws-sdk/client-s3": "3.1146.0",
186
+ "@aws-sdk/lib-storage": "3.1146.0",
187
+ "@aws-sdk/s3-request-presigner": "3.1146.0",
187
188
  "@better-auth/core": "1.7.7",
188
189
  "@better-auth/passkey": "1.7.7",
189
190
  "@compodoc/compodoc": "2.0.0",
@@ -93,7 +93,7 @@ export async function buildWsRequestContext(contextValue: any): Promise<IRequest
93
93
  }
94
94
 
95
95
  const user = readUser(contextValue);
96
- const context: IRequestContext = { currentUser: user };
96
+ const context: IRequestContext = { currentUser: user, fromRequest: true };
97
97
 
98
98
  // Language: same source as the HTTP path, when the transport carried it.
99
99
  const acceptLanguage =
@@ -0,0 +1,310 @@
1
+ import { lookup as dnsLookup, type LookupAddress } from 'node:dns';
2
+ import { BlockList, isIP } from 'node:net';
3
+ import { Agent, buildConnector, fetch as undiciFetch } from 'undici';
4
+
5
+ import { ConfigService } from '../services/config.service';
6
+
7
+ /**
8
+ * Requests to a URL a USER entered — a webhook, an export target, an ERP address.
9
+ *
10
+ * The server calls such a URL from inside its own network. Without a check, a user points it at the
11
+ * neighbourhood — the database, other containers, the cloud metadata service at 169.254.169.254 — and,
12
+ * wherever the response is stored or shown, reads the answer back (SSRF).
13
+ *
14
+ * **Only for user-entered URLs.** Targets the operator configured (AI providers, SMTP, S3, Redis,
15
+ * Brevo) do not go through this; nothing here is applied globally.
16
+ *
17
+ * The check runs twice on purpose:
18
+ * 1. {@link assertOutboundUrlAllowed} on the URL before anything is sent — a readable error, and
19
+ * 2. on every connection through {@link createOutboundDispatcher} — the only place that sees a
20
+ * redirect's next hop and the address a DNS name resolves to at connect time (rebinding).
21
+ *
22
+ * {@link outboundFetch} does both.
23
+ *
24
+ * Internal targets are refused on deployed stages (including `test`) and allowed locally, in e2e and CI,
25
+ * where the counterpart often runs in the same network. `security.allowPrivateOutboundTargets`
26
+ * (`NSC__SECURITY__ALLOW_PRIVATE_OUTBOUND_TARGETS=true|false`) decides it explicitly either way;
27
+ * `allowedHosts` frees individual internal hosts without opening all of them.
28
+ */
29
+
30
+ const blocked = new BlockList();
31
+ for (const [network, prefix] of [
32
+ ['0.0.0.0', 8], // "this network"
33
+ ['10.0.0.0', 8], // RFC 1918
34
+ ['100.64.0.0', 10], // carrier-grade NAT
35
+ ['127.0.0.0', 8], // loopback
36
+ ['169.254.0.0', 16], // link-local, incl. the cloud metadata service
37
+ ['172.16.0.0', 12], // RFC 1918
38
+ ['192.0.0.0', 24], // IETF protocol assignments
39
+ ['192.168.0.0', 16], // RFC 1918
40
+ ['198.18.0.0', 15], // benchmarking
41
+ ['224.0.0.0', 4], // multicast
42
+ ['240.0.0.0', 4], // reserved, incl. broadcast
43
+ ] as const) {
44
+ blocked.addSubnet(network, prefix, 'ipv4');
45
+ }
46
+ for (const [network, prefix] of [
47
+ ['::', 96], // unspecified, loopback and IPv4-compatible (::a.b.c.d)
48
+ ['64:ff9b::', 96], // NAT64 — embeds an IPv4 address
49
+ ['fc00::', 7], // unique local
50
+ ['fe80::', 10], // link-local
51
+ ['ff00::', 8], // multicast
52
+ ] as const) {
53
+ blocked.addSubnet(network, prefix, 'ipv6');
54
+ }
55
+
56
+ /**
57
+ * Environments in which internal targets are allowed unless configured otherwise. Deliberately NOT
58
+ * `test`: in projects built from nest-server-starter that is a DEPLOYED stage.
59
+ */
60
+ const LOCAL_ENVIRONMENTS = new Set(['ci', 'e2e', 'local']);
61
+
62
+ /** Thrown for a target that is, or resolves to, an internal address. */
63
+ export class OutboundUrlBlockedError extends Error {
64
+ constructor(
65
+ readonly host: string,
66
+ readonly address: string,
67
+ ) {
68
+ super(
69
+ `Outbound target not allowed: "${host}" points to the internal address ${address}. ` +
70
+ 'Use a public address, list the host in allowedHosts, or set security.allowPrivateOutboundTargets.',
71
+ );
72
+ this.name = 'OutboundUrlBlockedError';
73
+ }
74
+ }
75
+
76
+ export interface IOutboundUrlOptions {
77
+ /**
78
+ * Internal hosts that may be reached anyway, e.g. an ERP in the same network. `'erp.local:8443'`
79
+ * matches that port only, `'erp.local'` and `'10.0.0.5'` any port. Matched against the host as
80
+ * written in the URL — and on every connection, so a redirect does not inherit the exemption.
81
+ */
82
+ allowedHosts?: string[];
83
+ }
84
+
85
+ /** Whether internal targets are allowed at all — explicit config first, then the environment. */
86
+ export function privateOutboundTargetsAllowed(): boolean {
87
+ const config = ConfigService.configFastButReadOnly;
88
+ // Read loosely: a value from NSC__* or NEST_SERVER_CONFIG may arrive as a string.
89
+ const flag = config?.security?.allowPrivateOutboundTargets as unknown;
90
+ if (flag === true || flag === 'true') {
91
+ return true;
92
+ }
93
+ if (flag === false || flag === 'false') {
94
+ return false;
95
+ }
96
+ const env = String(config?.env ?? process.env.NODE_ENV ?? '')
97
+ .trim()
98
+ .toLowerCase();
99
+ return LOCAL_ENVIRONMENTS.has(env);
100
+ }
101
+
102
+ /** Whether an IP address is internal. A hostname (not an IP) answers false — resolve it first. */
103
+ export function isBlockedOutboundAddress(address: string): boolean {
104
+ const plain = stripBrackets(address);
105
+ const family = isIP(plain);
106
+ if (family === 4) {
107
+ return blocked.check(plain, 'ipv4');
108
+ }
109
+ if (family === 6) {
110
+ const mapped = mappedIpv4(plain);
111
+ return mapped ? blocked.check(mapped, 'ipv4') : blocked.check(plain, 'ipv6');
112
+ }
113
+ return false;
114
+ }
115
+
116
+ /**
117
+ * Refuses a URL whose scheme is not http(s) or whose host is, or resolves to, an internal address.
118
+ * Returns the parsed URL. A redirect is NOT covered here — send the request through
119
+ * {@link outboundFetch} or {@link createOutboundDispatcher}.
120
+ */
121
+ export async function assertOutboundUrlAllowed(url: string, options: IOutboundUrlOptions = {}): Promise<URL> {
122
+ let parsed: URL;
123
+ try {
124
+ parsed = new URL(url);
125
+ } catch {
126
+ throw new Error(`Invalid outbound URL: "${url}"`);
127
+ }
128
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
129
+ throw new Error(`Outbound URL must use http:// or https://: "${url}"`);
130
+ }
131
+ const host = stripBrackets(parsed.hostname);
132
+ if (privateOutboundTargetsAllowed() || isAllowedHost(host, effectivePort(parsed.port, parsed.protocol), options)) {
133
+ return parsed;
134
+ }
135
+ const addresses = isIP(host) ? [host] : await resolveAll(host);
136
+ const internal = addresses.find(isBlockedOutboundAddress);
137
+ if (internal) {
138
+ throw new OutboundUrlBlockedError(host, internal);
139
+ }
140
+ return parsed;
141
+ }
142
+
143
+ const plainConnect = buildConnector({});
144
+ const guardedConnect = buildConnector({ lookup: guardedLookup as never });
145
+
146
+ /**
147
+ * An undici dispatcher that refuses every connection to an internal address — whether the host came
148
+ * as a name or as a literal, and whether it is the first URL or a redirect's next hop. Use it with
149
+ * undici's own `fetch`/`request`; {@link outboundFetch} does that for you.
150
+ */
151
+ export function createOutboundDispatcher(options: IOutboundUrlOptions = {}): Agent {
152
+ return new Agent({
153
+ connect: (connectOptions, callback) => {
154
+ const host = stripBrackets(String(connectOptions.hostname ?? ''));
155
+ const port = effectivePort(connectOptions.port, connectOptions.protocol);
156
+ if (privateOutboundTargetsAllowed() || isAllowedHost(host, port, options)) {
157
+ plainConnect(connectOptions, callback);
158
+ return;
159
+ }
160
+ if (isIP(host) && isBlockedOutboundAddress(host)) {
161
+ callback(new OutboundUrlBlockedError(host, host), null);
162
+ return;
163
+ }
164
+ guardedConnect(connectOptions, callback);
165
+ },
166
+ });
167
+ }
168
+
169
+ let defaultDispatcher: Agent | undefined;
170
+ const allowListDispatchers = new Map<string, Agent>();
171
+ const MAX_ALLOW_LIST_DISPATCHERS = 50;
172
+
173
+ /**
174
+ * `fetch` for a user-entered URL: the URL check first (a readable {@link OutboundUrlBlockedError}),
175
+ * then the request through a guarded dispatcher. Uses undici's own `fetch`, so the dispatcher always
176
+ * matches the client — Node's built-in `fetch` bundles a different undici major on Node 22.
177
+ *
178
+ * A refusal on a later hop (redirect, rebinding) surfaces as undici's `TypeError: fetch failed` with
179
+ * the {@link OutboundUrlBlockedError} as its `cause`.
180
+ */
181
+ export async function outboundFetch(
182
+ url: string,
183
+ init: Parameters<typeof undiciFetch>[1] = {},
184
+ options: IOutboundUrlOptions = {},
185
+ ): ReturnType<typeof undiciFetch> {
186
+ await assertOutboundUrlAllowed(url, options);
187
+ return undiciFetch(url, { ...init, dispatcher: dispatcherFor(options) });
188
+ }
189
+
190
+ function dispatcherFor(options: IOutboundUrlOptions): Agent {
191
+ const hosts = (options.allowedHosts ?? []).map(normalizeHost).filter(Boolean).sort();
192
+ if (!hosts.length) {
193
+ defaultDispatcher ??= createOutboundDispatcher();
194
+ return defaultDispatcher;
195
+ }
196
+ const key = hosts.join(',');
197
+ let dispatcher = allowListDispatchers.get(key);
198
+ if (!dispatcher) {
199
+ if (allowListDispatchers.size >= MAX_ALLOW_LIST_DISPATCHERS) {
200
+ const [oldestKey, oldest] = allowListDispatchers.entries().next().value as [string, Agent];
201
+ allowListDispatchers.delete(oldestKey);
202
+ void oldest.close().catch(() => undefined);
203
+ }
204
+ dispatcher = createOutboundDispatcher({ allowedHosts: hosts });
205
+ allowListDispatchers.set(key, dispatcher);
206
+ }
207
+ return dispatcher;
208
+ }
209
+
210
+ function guardedLookup(
211
+ hostname: string,
212
+ options: { all?: boolean; family?: number },
213
+ callback: (err: Error | null, address?: LookupAddress[] | string, family?: number) => void,
214
+ ): void {
215
+ dnsLookup(hostname, { ...options, all: true }, (err, addresses) => {
216
+ if (err) {
217
+ callback(err);
218
+ return;
219
+ }
220
+ const list = addresses as LookupAddress[];
221
+ const internal = list.find((entry) => isBlockedOutboundAddress(entry.address));
222
+ if (internal) {
223
+ callback(new OutboundUrlBlockedError(hostname, internal.address));
224
+ return;
225
+ }
226
+ if (options.all) {
227
+ callback(null, list);
228
+ return;
229
+ }
230
+ callback(null, list[0]?.address, list[0]?.family);
231
+ });
232
+ }
233
+
234
+ function resolveAll(host: string): Promise<string[]> {
235
+ return new Promise((resolve, reject) => {
236
+ dnsLookup(host, { all: true }, (err, addresses) => {
237
+ if (err) {
238
+ reject(new Error(`Outbound target cannot be resolved: "${host}" (${err.message})`));
239
+ return;
240
+ }
241
+ resolve(addresses.map((entry) => entry.address));
242
+ });
243
+ });
244
+ }
245
+
246
+ function isAllowedHost(host: string, port: string, options: IOutboundUrlOptions): boolean {
247
+ const wanted = host.toLowerCase();
248
+ return (options.allowedHosts ?? []).some((entry) => {
249
+ const normalized = normalizeHost(entry);
250
+ if (!normalized) {
251
+ return false;
252
+ }
253
+ const [entryHost, entryPort] = splitHostPort(normalized);
254
+ return entryHost === wanted && (!entryPort || entryPort === port);
255
+ });
256
+ }
257
+
258
+ /** `Host`, `host:port`, `[v6]` and `[v6]:port`, lowercased, IPv6 canonicalised; '' when unusable. */
259
+ function normalizeHost(entry: string): string {
260
+ if (typeof entry !== 'string' || !entry.trim()) {
261
+ return '';
262
+ }
263
+ try {
264
+ const url = new URL(`http://${entry.trim()}`);
265
+ const host = stripBrackets(url.hostname).toLowerCase();
266
+ return url.port ? `${host.includes(':') ? `[${host}]` : host}:${url.port}` : host;
267
+ } catch {
268
+ return '';
269
+ }
270
+ }
271
+
272
+ function splitHostPort(normalized: string): [string, string] {
273
+ const v6 = /^\[(.+)\]:(\d+)$/.exec(normalized);
274
+ if (v6) {
275
+ return [v6[1], v6[2]];
276
+ }
277
+ if (isIP(normalized) === 6) {
278
+ return [normalized, ''];
279
+ }
280
+ const index = normalized.lastIndexOf(':');
281
+ return index === -1 ? [normalized, ''] : [normalized.slice(0, index), normalized.slice(index + 1)];
282
+ }
283
+
284
+ function effectivePort(port: number | string | null | undefined, protocol: string | null | undefined): string {
285
+ if (port !== undefined && port !== null && String(port) !== '') {
286
+ return String(port);
287
+ }
288
+ return protocol === 'https:' ? '443' : '80';
289
+ }
290
+
291
+ function stripBrackets(value: string): string {
292
+ return value.replace(/^\[|\]$/g, '');
293
+ }
294
+
295
+ /** The IPv4 address inside an IPv4-mapped IPv6 address, in any notation. */
296
+ function mappedIpv4(address: string): string | undefined {
297
+ let canonical: string;
298
+ try {
299
+ canonical = stripBrackets(new URL(`http://[${address}]`).hostname);
300
+ } catch {
301
+ return undefined;
302
+ }
303
+ const match = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(canonical);
304
+ if (!match) {
305
+ return undefined;
306
+ }
307
+ const high = parseInt(match[1], 16);
308
+ const low = parseInt(match[2], 16);
309
+ return `${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`;
310
+ }