@lenne.tech/nest-server 11.32.2 → 11.32.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/configurable-features.md +1 -1
- package/FRAMEWORK-API.md +3 -1
- package/bin/migrate.js +13 -3
- package/dist/core/common/helpers/file.helper.d.ts +14 -2
- package/dist/core/common/helpers/file.helper.js +48 -9
- package/dist/core/common/helpers/file.helper.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +2 -0
- package/dist/core/modules/ai/inputs/core-ai-connection.input.js +2 -0
- package/dist/core/modules/ai/inputs/core-ai-connection.input.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai-connection.service.d.ts +1 -0
- package/dist/core/modules/ai/services/core-ai-connection.service.js +68 -0
- package/dist/core/modules/ai/services/core-ai-connection.service.js.map +1 -1
- package/dist/core/modules/file/core-file.controller.d.ts +4 -1
- package/dist/core/modules/file/core-file.controller.js +39 -6
- package/dist/core/modules/file/core-file.controller.js.map +1 -1
- package/dist/core/modules/migrate/cli/migrate-cli.d.ts +3 -1
- package/dist/core/modules/migrate/cli/migrate-cli.js +29 -4
- package/dist/core/modules/migrate/cli/migrate-cli.js.map +1 -1
- package/dist/core/modules/migrate/helpers/migration.helper.d.ts +1 -0
- package/dist/core/modules/migrate/helpers/migration.helper.js +51 -4
- package/dist/core/modules/migrate/helpers/migration.helper.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/security-overrides.md +9 -2
- package/migration-guides/11.32.2-to-11.32.3.md +129 -0
- package/migration-guides/11.32.3-to-11.32.4.md +323 -0
- package/package.json +1 -1
- package/src/core/common/helpers/file.helper.spec.ts +145 -0
- package/src/core/common/helpers/file.helper.ts +148 -10
- package/src/core/common/interfaces/server-options.interface.ts +23 -0
- package/src/core/modules/ai/README.md +6 -0
- package/src/core/modules/ai/inputs/core-ai-connection.input.ts +2 -0
- package/src/core/modules/ai/interfaces/ai-tool.interface.ts +18 -3
- package/src/core/modules/ai/services/core-ai-connection.service.ts +135 -0
- package/src/core/modules/file/README.md +59 -0
- package/src/core/modules/file/core-file.controller.spec.ts +164 -0
- package/src/core/modules/file/core-file.controller.ts +100 -8
- package/src/core/modules/migrate/README.md +35 -0
- package/src/core/modules/migrate/cli/migrate-cli.ts +69 -6
- package/src/core/modules/migrate/helpers/migration.helper.spec.ts +85 -0
- package/src/core/modules/migrate/helpers/migration.helper.ts +131 -4
|
@@ -11,16 +11,18 @@ A green `pnpm audit` inside the framework repo says nothing about your tree.
|
|
|
11
11
|
|
|
12
12
|
## What this concretely means for you
|
|
13
13
|
|
|
14
|
-
The framework pulls in
|
|
14
|
+
The framework pulls in three transitive packages that resolve to a **vulnerable** version unless you
|
|
15
15
|
override them yourself:
|
|
16
16
|
|
|
17
17
|
| Package | Advisory | Why it cannot resolve forward on its own |
|
|
18
18
|
|---------|----------|------------------------------------------|
|
|
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` | `@nestjs/graphql` declares `"ws": "8.20.1"` — an **exact pin**, not a caret. No amount of updating moves it |
|
|
20
20
|
| `@hono/node-server` | [GHSA-frvp-7c67-39w9](https://github.com/advisories/GHSA-frvp-7c67-39w9) (static-file path traversal) + [GHSA-9mqv-5hh9-4cgg](https://github.com/advisories/GHSA-9mqv-5hh9-4cgg) (unauthenticated memory leak). Patched `>=2.0.10` | `@modelcontextprotocol/sdk` declares `^1.19.9` and ships **no 1.x fix line**, so the fix is only available across a major |
|
|
21
|
+
| `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` | `@nestjs/swagger` declares `"js-yaml": "5.2.1"` — an **exact pin**, the same shape as the `ws` case. It cannot resolve forward |
|
|
21
22
|
|
|
22
23
|
`@nestjs/graphql` is a plain `dependencies` entry, so `ws` is installed even when you run with
|
|
23
|
-
`graphQl: false`.
|
|
24
|
+
`graphQl: false`. `@nestjs/swagger` is likewise a plain `dependencies` entry. None of the three is
|
|
25
|
+
optional in practice.
|
|
24
26
|
|
|
25
27
|
## The fix
|
|
26
28
|
|
|
@@ -40,6 +42,11 @@ overrides:
|
|
|
40
42
|
# `(fetchCallback, options?)` is unchanged in 2.x. Engines >=20 and peer hono@^4 both fit.
|
|
41
43
|
# Remove once @modelcontextprotocol/sdk moves its own range to ^2.
|
|
42
44
|
'@hono/node-server@<2.0.10': '2.0.11'
|
|
45
|
+
|
|
46
|
+
# @nestjs/swagger exact-pins js-yaml@5.2.1 (GHSA-pm4m-ph32-ghv5, high, patched >=5.2.2).
|
|
47
|
+
# Same shape as the ws entry: an exact pin cannot resolve forward.
|
|
48
|
+
# Remove once @nestjs/swagger stops pinning it.
|
|
49
|
+
'js-yaml@>=5.0.0 <5.2.2': '5.2.2'
|
|
43
50
|
```
|
|
44
51
|
|
|
45
52
|
Then:
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Migration Guide: 11.32.2 → 11.32.3
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
| Category | Details |
|
|
6
|
+
|----------|---------|
|
|
7
|
+
| **Breaking Changes** | None |
|
|
8
|
+
| **New Features** | `ai.capabilityDriftCheck` — opt-in boot warning when an explicit connection capability contradicts the endpoint; `ai.defaultConnection.contextWindow` — seed a connection's context window from config |
|
|
9
|
+
| **Bugfixes** | The AI connection `contextWindow` admin input now rejects non-positive / non-integer values |
|
|
10
|
+
| **Migration Effort** | ~2 minutes — everything is opt-in; read §3 only if your admin tooling writes `contextWindow` |
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Quick Migration
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pnpm update @lenne.tech/nest-server@11.32.3
|
|
18
|
+
pnpm run build
|
|
19
|
+
pnpm test
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
No configuration change is required. Everything below is opt-in or a
|
|
23
|
+
tightening you inherit automatically.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## 1. New: `ai.capabilityDriftCheck` (opt-in, default `false`)
|
|
28
|
+
|
|
29
|
+
A connection's `supportsNativeTools` / `supportsJsonResponse` flags are
|
|
30
|
+
auto-detected while they are left **undefined**, but an **explicit** value is
|
|
31
|
+
authoritative and is never re-probed. A wrong explicit flag therefore degrades the
|
|
32
|
+
assistant silently — e.g. `supportsNativeTools: false` on a backend that *does*
|
|
33
|
+
support native function calling forces fragile emulated tool-calling.
|
|
34
|
+
|
|
35
|
+
Enable this opt-in boot self-check to surface such a mismatch:
|
|
36
|
+
|
|
37
|
+
```typescript
|
|
38
|
+
ai: {
|
|
39
|
+
capabilityDriftCheck: true, // default false
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
When enabled, at startup the module probes each enabled connection that declares an
|
|
44
|
+
explicit flag and logs a **warning** on mismatch. It never changes the stored value
|
|
45
|
+
(your explicit choice stays authoritative — clear the flag in the admin UI to
|
|
46
|
+
re-enable auto-detection).
|
|
47
|
+
|
|
48
|
+
**It is off by default because it makes outbound calls to the LLM endpoints on
|
|
49
|
+
every boot** (and is additionally skipped in the `ci`/`e2e` runners). Note this is a
|
|
50
|
+
diagnostic log only: if you deliberately override a flag against what the endpoint
|
|
51
|
+
reports (e.g. native tools are advertised but unreliable on your model), the warning
|
|
52
|
+
is expected and can be ignored.
|
|
53
|
+
|
|
54
|
+
**Action: none** unless you want the diagnostic — then set the flag.
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## 2. New: `ai.defaultConnection.contextWindow`
|
|
59
|
+
|
|
60
|
+
The one-time `ai.defaultConnection` seed now accepts `contextWindow`, so a
|
|
61
|
+
config-seeded connection can carry its context window from the start instead of
|
|
62
|
+
relying on auto-detection:
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
ai: {
|
|
66
|
+
defaultConnection: {
|
|
67
|
+
name: 'default',
|
|
68
|
+
baseUrl: '...',
|
|
69
|
+
model: '...',
|
|
70
|
+
contextWindow: 32768, // optional — omit to auto-detect
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Omit it to keep the existing behaviour (auto-detect by probing the endpoint /
|
|
76
|
+
`knownContextWindow()`, falling back to the global `ai.contextWindow` default of
|
|
77
|
+
8192).
|
|
78
|
+
|
|
79
|
+
**Action: none** — additive and optional.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## 3. Tightened: `contextWindow` admin input validation
|
|
84
|
+
|
|
85
|
+
The `contextWindow` field on the AI connection create/update input is now validated
|
|
86
|
+
as a **positive integer** (`@IsInt` + `@Min(1)`). Previously any number (including
|
|
87
|
+
`0`, a negative, or a float) was accepted verbatim, and an explicit `0` is not
|
|
88
|
+
"unset" — it would have fed the orchestrator's context budget as a real (broken)
|
|
89
|
+
value instead of falling back to the safe default.
|
|
90
|
+
|
|
91
|
+
**Action required if** your admin tooling or tests send a non-integer or
|
|
92
|
+
`< 1` `contextWindow` to `createAiConnection` / `updateAiConnection` — those calls
|
|
93
|
+
now return a validation error. Send a positive integer, or omit the field to
|
|
94
|
+
auto-detect.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## 4. Documentation: the MCP confirmation-gate boundary
|
|
99
|
+
|
|
100
|
+
No code change — a clarification of the `IAiTool.destructive` / `.mutating` JSDoc
|
|
101
|
+
(reinforcing §5 of the 11.32.2 guide). Over `/ai/mcp` there is **no confirmation
|
|
102
|
+
gate**: `mcpCallTool` consults neither flag, so a destructive tool executes on the
|
|
103
|
+
first call. The barriers that *do* hold on every path are the registry **role
|
|
104
|
+
filter** (`forUser()`, applied before `execute()`) and the authorization inside
|
|
105
|
+
`execute()` itself — so a destructive tool restricted to a real role stays
|
|
106
|
+
unreachable by lesser-privileged MCP clients; MCP only skips the extra confirmation
|
|
107
|
+
step for clients that may already see the tool.
|
|
108
|
+
|
|
109
|
+
**Action:** expose `/ai/mcp` only to clients you trust to obtain user consent
|
|
110
|
+
themselves, and keep data-level authorization inside `execute()` (not only in the
|
|
111
|
+
plan-mode `authorize()`).
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## Module Documentation
|
|
116
|
+
|
|
117
|
+
- [AI module README](../src/core/modules/ai/README.md) — see "Capability auto-detection"
|
|
118
|
+
- [AI INTEGRATION-CHECKLIST](../src/core/modules/ai/INTEGRATION-CHECKLIST.md) — advanced configuration
|
|
119
|
+
- [Configurable features](../.claude/rules/configurable-features.md) — the AI Assistant row
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## Troubleshooting
|
|
124
|
+
|
|
125
|
+
| Symptom | Cause | Fix |
|
|
126
|
+
|---------|-------|-----|
|
|
127
|
+
| Boot logs "capability drift" for a connection | §1 — an explicit flag disagrees with the endpoint | Correct the flag in the admin UI, or clear it to auto-detect; ignore if the override is deliberate |
|
|
128
|
+
| `createAiConnection` rejects `contextWindow` | §3 — it must be a positive integer now | Send an integer `>= 1`, or omit to auto-detect |
|
|
129
|
+
| A destructive tool ran over `/ai/mcp` without confirmation | §4 — MCP has no confirmation gate by design | Only expose MCP to trusted clients; enforce data-level checks in `execute()` |
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
# Migration Guide: 11.32.3 → 11.32.4
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
| Category | Details |
|
|
6
|
+
|----------|---------|
|
|
7
|
+
| **Breaking Changes** | None in signatures. Six **runtime behaviour** changes you inherit automatically — see §1, §2, §4, §5, §6, §7 |
|
|
8
|
+
| **New Features** | `assertGridFsFileComplete()` — verify a stored GridFS file's chunk completeness; `UploadAllowList` — exact-matching upload filters, with `SCRIPTABLE_UPLOAD_MIME_TYPES` / `SCRIPTABLE_UPLOAD_EXTENSIONS` and the `allowScriptableTypes` opt-out |
|
|
9
|
+
| **Bugfixes** | GridFS uploads no longer report success for incomplete files, no longer hang on an unreadable source, and no longer leak their connection; file downloads answer an honest 404 instead of dropping the socket; the migrate CLI now actually terminates and no longer truncates its own output; the upload filter no longer matches mimetypes and extensions as SUBSTRINGS (`te?xt` accepted `text/html`), no longer reports rejections as a bare string, and `multerOptionsForImageUpload` no longer silently disables all filtering when `fileTypeRegex` is `undefined` |
|
|
10
|
+
| **Migration Effort** | No code changes for most projects, and nothing to configure. Read §1 if you have seed migrations that upload assets, §4 if any test asserts on download error messages, §5–§7 if your project accepts uploads |
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Quick Migration
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pnpm update @lenne.tech/nest-server@11.32.4
|
|
18
|
+
pnpm run build
|
|
19
|
+
pnpm test
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
No configuration change is required. Existing `fileTypeRegex` arguments keep working.
|
|
23
|
+
|
|
24
|
+
**Vendor-mode projects:** four modified files under `src/core/`
|
|
25
|
+
(`common/helpers/file.helper.ts`, `modules/file/core-file.controller.ts`,
|
|
26
|
+
`modules/migrate/cli/migrate-cli.ts`, `modules/migrate/helpers/migration.helper.ts`) plus
|
|
27
|
+
`bin/migrate.js`. No moved files — there is no atomic file-set hazard for a partial sync.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## 1. `uploadFileToGridFS()` now fails loudly instead of quietly
|
|
32
|
+
|
|
33
|
+
Previously the helper resolved as soon as the write stream emitted `'finish'`. That event says the
|
|
34
|
+
stream ended — it does **not** prove every chunk document is durably stored. A connection lost at the
|
|
35
|
+
wrong moment left a files document promising more bytes than existed, the migration reported success,
|
|
36
|
+
and the defect surfaced much later as a broken download from a record that looked perfectly healthy.
|
|
37
|
+
|
|
38
|
+
It now verifies chunk completeness before resolving, and **rejects** when the file is incomplete
|
|
39
|
+
(removing the incomplete file so retries do not accumulate orphans).
|
|
40
|
+
|
|
41
|
+
**What you may notice:** a seed migration that previously "succeeded" while silently storing a broken
|
|
42
|
+
asset now fails. Since `docker-entrypoint.sh` defaults to `MIGRATE_FAILURE_POLICY=abort`, that failure
|
|
43
|
+
keeps the container from starting.
|
|
44
|
+
|
|
45
|
+
**This is intentional** — finding exactly this is why migrations run before the server. To recover:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# 1. Identify the incomplete file from the migration error, which names it:
|
|
49
|
+
# GridFS file 'logo.png' is incomplete: 1 of 3 chunks stored (id 6a6b…)
|
|
50
|
+
# 2. Verify the source asset is actually present and readable in the image/checkout.
|
|
51
|
+
# 3. Re-run. The helper deletes its own incomplete upload, but a file left behind by
|
|
52
|
+
# an OLDER version of the helper must be removed manually:
|
|
53
|
+
# db.getCollection('<bucket>.files').deleteOne({ _id: ObjectId('…') })
|
|
54
|
+
# db.getCollection('<bucket>.chunks').deleteMany({ files_id: ObjectId('…') })
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Two more changes to the same helper, both strictly better and requiring no action:
|
|
58
|
+
|
|
59
|
+
- **An unreadable source rejects instead of hanging.** `pipe()` does not forward read-stream errors,
|
|
60
|
+
so a missing file used to leave the promise pending forever — a migration that never returned.
|
|
61
|
+
It now rejects with the underlying `ENOENT`.
|
|
62
|
+
- **The connection is always closed.** The client it opens is registered and closed on every path.
|
|
63
|
+
A leaked client keeps an SDAM monitor timer alive, which kept the whole CLI from exiting.
|
|
64
|
+
|
|
65
|
+
**Note on empty files:** GridFS stores a zero-byte file with **no** chunk documents at all, and the
|
|
66
|
+
completeness check accounts for that. Uploading an empty placeholder asset is valid and passes.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## 2. `migrate up` now terminates explicitly
|
|
71
|
+
|
|
72
|
+
The CLI drains stdout/stderr and then exits, instead of waiting for the event loop to empty. A single
|
|
73
|
+
handle left behind by MongoDB, GridFS or the state store used to keep the process alive after the work
|
|
74
|
+
was done: the CLI printed "All migrations completed successfully" and never returned. On a developer
|
|
75
|
+
machine that is invisible; in CI the job blocks until its timeout, and a container that runs
|
|
76
|
+
migrations before starting the server never reaches the server at all.
|
|
77
|
+
|
|
78
|
+
Exit codes are unchanged (`0` success, `1` failure), and the drain happens **before** the exit, so no
|
|
79
|
+
output is lost — `process.exit()` does not flush an asynchronous pipe, which is exactly what Docker's
|
|
80
|
+
log driver and CI log collectors are.
|
|
81
|
+
|
|
82
|
+
**Action required: none**, unless you invoke the CLI in a non-standard way. If you call the CLI
|
|
83
|
+
module directly rather than through the `migrate` / `nest-migrate` bin, use the new exported
|
|
84
|
+
`runCli()` (which drains and exits) instead of `main()` (which merely resolves):
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
// Before — resolves, but relies on the event loop draining by itself
|
|
88
|
+
const { main } = require('@lenne.tech/nest-server/dist/core/modules/migrate/cli/migrate-cli');
|
|
89
|
+
main();
|
|
90
|
+
|
|
91
|
+
// After
|
|
92
|
+
const { runCli } = require('@lenne.tech/nest-server/dist/core/modules/migrate/cli/migrate-cli');
|
|
93
|
+
void runCli();
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The shipped `bin/migrate.js` already prefers `runCli()` and falls back to `main()`, so a mixed
|
|
97
|
+
version pair keeps working.
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## 3. New: `assertGridFsFileComplete()`
|
|
102
|
+
|
|
103
|
+
Exported for direct use when you need to check a file that something else wrote — a restored dump,
|
|
104
|
+
another service, a manual upload:
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
import { assertGridFsFileComplete, getDb } from '@lenne.tech/nest-server';
|
|
108
|
+
|
|
109
|
+
const db = await getDb(process.env.MONGODB_URL);
|
|
110
|
+
await assertGridFsFileComplete(db, 'images', fileId, 'logo.png'); // throws if incomplete
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
It counts chunk documents rather than reading bytes back, so a chunk that was written but truncated
|
|
114
|
+
is not detected. See `src/core/modules/migrate/README.md`.
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## 4. File downloads: error responses changed
|
|
119
|
+
|
|
120
|
+
Two changes on `GET /files/id/:id` and `GET /files/:filename`.
|
|
121
|
+
|
|
122
|
+
**(a) A GridFS read error is now a 404, not a dropped socket.** The stream error previously went
|
|
123
|
+
unhandled, Node destroyed the socket mid-response, and a reverse proxy turned that into
|
|
124
|
+
**502 Bad Gateway** — reading as "the server is down" while every other route answered normally.
|
|
125
|
+
The response is now:
|
|
126
|
+
|
|
127
|
+
```json
|
|
128
|
+
{ "error": "Not Found", "message": "#LTNS_0500: File not found", "statusCode": 404 }
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Once bytes are already on the wire there is no status left to send, so the connection is still closed
|
|
132
|
+
— at that point it genuinely is a truncated transfer.
|
|
133
|
+
|
|
134
|
+
**(b) Exception messages now carry their ErrorCode.** The controller uses the framework registry
|
|
135
|
+
instead of raw strings, matching every other core module:
|
|
136
|
+
|
|
137
|
+
| Before | After |
|
|
138
|
+
|--------|-------|
|
|
139
|
+
| `'File not found'` | `ErrorCode.FILE_NOT_FOUND` → `'#LTNS_0500: File not found'` |
|
|
140
|
+
| `'Missing file ID for download'` | `ErrorCode.REQUIRED_FIELD_MISSING` → `'#LTNS_0301: Required field missing'` |
|
|
141
|
+
|
|
142
|
+
**Action required:** if a test asserts on the exact message, loosen it to a substring match — the old
|
|
143
|
+
text is still contained in the new one. Frontends using `useLtErrorTranslation()` gain a translated
|
|
144
|
+
message where they previously showed raw English.
|
|
145
|
+
|
|
146
|
+
**Also:** a project that restricted downloads by overriding `CoreFileService.checkRights()` used to
|
|
147
|
+
get a **500** (the refusal produced `null.pipe(res)` → `TypeError`). It is now a **404** —
|
|
148
|
+
deliberately the same answer as an unknown id, so the endpoint cannot be used to probe which files
|
|
149
|
+
exist.
|
|
150
|
+
|
|
151
|
+
To customise the status, body or logging of the streaming error path, override the new
|
|
152
|
+
`protected pipeFileToResponse()` method on your controller:
|
|
153
|
+
|
|
154
|
+
```typescript
|
|
155
|
+
export class FileController extends CoreFileController {
|
|
156
|
+
protected override pipeFileToResponse(stream: Readable, res: Response): Response {
|
|
157
|
+
// e.g. report a different status, or add your own telemetry
|
|
158
|
+
return super.pipeFileToResponse(stream, res);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## 5. The upload filter matched substrings, not values
|
|
166
|
+
|
|
167
|
+
**What changed:** `multerFileFilter` compared the mimetype and the extension by
|
|
168
|
+
`RegExp.test()` — a substring search. Every alternative therefore matched
|
|
169
|
+
anywhere inside either value.
|
|
170
|
+
|
|
171
|
+
**Why it matters:** an allow-list containing `text` or `txt` also accepted
|
|
172
|
+
`text/html` and `text/xml`. A file named `x.txt` and sent as `text/html` passed
|
|
173
|
+
both halves of a filter whose own comment said "no html". `md` matched every
|
|
174
|
+
mimetype containing "md", `zip` every one containing "zip". The single
|
|
175
|
+
alternative was never the bug — the substring semantics were.
|
|
176
|
+
|
|
177
|
+
Anchoring the expression was not an option: the SAME expression was tested
|
|
178
|
+
against two different value spaces, so it had to carry mimetype FRAGMENTS
|
|
179
|
+
(`wordprocessingml`, `ms-excel`) next to bare extensions, and no `^…$` satisfies
|
|
180
|
+
both at once.
|
|
181
|
+
|
|
182
|
+
**What to do:** nothing, if your filter only ever listed image formats — those
|
|
183
|
+
never matched markup. If your filter lists text-ish or document formats, move to
|
|
184
|
+
the new exact-matching form:
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
// Before — substring matching
|
|
188
|
+
multerOptionsForImageUpload({ fileTypeRegex: /jpeg|jpg|png|pdf|te?xt|csv/ });
|
|
189
|
+
|
|
190
|
+
// After — whole-value matching
|
|
191
|
+
multerOptionsForImageUpload({
|
|
192
|
+
allowList: {
|
|
193
|
+
extensions: ['.csv', '.jpeg', '.jpg', '.pdf', '.png', '.txt'],
|
|
194
|
+
mimeTypes: ['application/pdf', 'image/jpeg', 'image/png', 'text/csv', 'text/plain'],
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
The two conditions stay **independent**: either one alone rejects the file, but a
|
|
200
|
+
pair that is odd yet individually allowed (`report.txt` announced as
|
|
201
|
+
`application/pdf`) passes. An extension→mimetype MAPPING is deliberately not
|
|
202
|
+
enforced — user agents genuinely disagree about office and audio types (macOS
|
|
203
|
+
reports `.csv` as `text/plain`), so a mapping rejects legitimate uploads.
|
|
204
|
+
|
|
205
|
+
`fileTypeRegex` still works and is not removed; it is marked deprecated.
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
## 6. Scriptable types are now rejected on BOTH forms
|
|
210
|
+
|
|
211
|
+
**What changed:** `text/html`, `text/xml`, `application/xhtml+xml`,
|
|
212
|
+
`image/svg+xml`, JavaScript types and the matching extensions (`.html`, `.svg`,
|
|
213
|
+
`.js`, `.xml`, …) are rejected before the allow-list is consulted — including
|
|
214
|
+
when a legacy `fileTypeRegex` would have matched them.
|
|
215
|
+
|
|
216
|
+
**Why it matters:** this is what closes §5 for expressions that already exist in
|
|
217
|
+
consumer projects, without anyone having to rewrite them. The danger does not
|
|
218
|
+
depend on what an endpoint meant to accept: a stored upload served back from the
|
|
219
|
+
API origin with one of these content types executes in that origin, with the
|
|
220
|
+
victim's session.
|
|
221
|
+
|
|
222
|
+
**What to do:** if you deliberately accept SVG logos or HTML fragments, opt out
|
|
223
|
+
explicitly:
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
multerFileFilter(
|
|
227
|
+
{ extensions: ['.svg'], mimeTypes: ['image/svg+xml'] },
|
|
228
|
+
{ allowScriptableTypes: true },
|
|
229
|
+
);
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Only do that when the file is never served from an origin that carries a session
|
|
233
|
+
— e.g. a separate download host, or a route that always answers with
|
|
234
|
+
`Content-Disposition: attachment` **and** `X-Content-Type-Options: nosniff`.
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
## 7. `fileTypeRegex: undefined` no longer disables filtering
|
|
239
|
+
|
|
240
|
+
**What changed:** `multerOptionsForImageUpload` built its config as
|
|
241
|
+
`{ fileTypeRegex: /jpeg|jpg|png/, ...options }` and then installed a filter only
|
|
242
|
+
`if (config.fileTypeRegex)`. Passing the key explicitly as `undefined`
|
|
243
|
+
overwrote the default, so **no filter was installed at all** and every file type
|
|
244
|
+
was accepted — on a helper named "ImageUpload".
|
|
245
|
+
|
|
246
|
+
**Why it matters:** this is easy to trigger by accident rather than intent:
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
// The author meant "then just the standard image types".
|
|
250
|
+
// Before: they got "then everything".
|
|
251
|
+
multerOptionsForImageUpload({ fileTypeRegex: allowSvg ? /jpeg|jpg|png|svg/ : undefined });
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Any optional variable threaded into that option had the same effect.
|
|
255
|
+
|
|
256
|
+
**What to do:** if a project relied on this to accept arbitrary types, it now
|
|
257
|
+
gets JPEG/PNG only and those uploads start failing. Pass an explicit `allowList`
|
|
258
|
+
naming what the endpoint really accepts. There is intentionally no "accept
|
|
259
|
+
everything" switch — an upload endpoint that takes any type should not be built
|
|
260
|
+
on `multerOptionsForImageUpload`.
|
|
261
|
+
|
|
262
|
+
---
|
|
263
|
+
|
|
264
|
+
## 8. Upload rejections are a real `Error`
|
|
265
|
+
|
|
266
|
+
**What changed:** the filter called `cb('Error: File upload only supports …')`
|
|
267
|
+
with a bare **string**. It now passes an `Error`.
|
|
268
|
+
|
|
269
|
+
**Why it matters:** a string has no `message`, so NestJS's `transformException`
|
|
270
|
+
could not map it and the request surfaced as a 500 instead of a 4xx.
|
|
271
|
+
|
|
272
|
+
**What to do:** nothing, unless a test asserts on the exact error value. Assert
|
|
273
|
+
on `error.message` instead.
|
|
274
|
+
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
## 9. Dependency housekeeping (no action required)
|
|
278
|
+
|
|
279
|
+
- `js-yaml` gained an override for [GHSA-pm4m-ph32-ghv5](https://github.com/advisories/GHSA-pm4m-ph32-ghv5)
|
|
280
|
+
(high). **`@nestjs/swagger` exact-pins `js-yaml@5.2.1`, so your project needs this override too** —
|
|
281
|
+
a framework override does not reach consumer trees. See
|
|
282
|
+
[`docs/security-overrides.md`](../docs/security-overrides.md), which now lists all three affected
|
|
283
|
+
packages.
|
|
284
|
+
- The `minimatch` override range was narrowed to `>=9.0.0`; majors 5–8 export a callable function and
|
|
285
|
+
would break under a forced lift to 10.
|
|
286
|
+
|
|
287
|
+
---
|
|
288
|
+
|
|
289
|
+
## Troubleshooting
|
|
290
|
+
|
|
291
|
+
| Symptom | Cause | Fix |
|
|
292
|
+
|---------|-------|-----|
|
|
293
|
+
| Migration now fails with `is incomplete: N of M chunks stored` | The asset was already broken in the database; the old helper never checked | See §1 — verify the source asset, remove the orphan, re-run |
|
|
294
|
+
| Migration now fails with `ENOENT` | The source path was always wrong; it used to hang instead of failing | Fix the path. It is resolved against the **helper module's** directory |
|
|
295
|
+
| A test asserting `'File not found'` fails | The message now carries its ErrorCode prefix | Match on a substring, or on `ErrorCode.FILE_NOT_FOUND` |
|
|
296
|
+
| A download that returned 500 now returns 404 | `checkRights()` refusal is handled properly instead of crashing | Intended — see §4 |
|
|
297
|
+
| An upload fails with `may execute as script` | The type is markup or script and is now rejected regardless of the filter | Intended — see §6, and opt out only under the conditions named there |
|
|
298
|
+
| An upload of a document type that used to pass now fails | The filter matched it as a substring before | Move to `allowList` naming the type exactly — see §5 |
|
|
299
|
+
| Uploads that accepted every type now accept JPEG/PNG only | `fileTypeRegex: undefined` no longer disables the filter | Pass an explicit `allowList` — see §7 |
|
|
300
|
+
| A test asserting on the filter's rejection string fails | Rejections are an `Error` now, not a string | Assert on `error.message` — see §8 |
|
|
301
|
+
|
|
302
|
+
---
|
|
303
|
+
|
|
304
|
+
## Verification
|
|
305
|
+
|
|
306
|
+
```bash
|
|
307
|
+
pnpm test
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
`src/core/common/helpers/file.helper.spec.ts` covers the substring class (values that merely CONTAIN
|
|
311
|
+
an allowed token), the scriptable-type rejection on both forms, the opt-out, and that the legacy
|
|
312
|
+
`RegExp` form still accepts what it accepted before.
|
|
313
|
+
`src/core/modules/migrate/helpers/migration.helper.spec.ts` and
|
|
314
|
+
`tests/migrate/upload-file-to-gridfs.e2e-spec.ts` cover the completeness check against a real GridFS
|
|
315
|
+
bucket; `src/core/modules/file/core-file.controller.spec.ts` covers the download error paths.
|
|
316
|
+
|
|
317
|
+
---
|
|
318
|
+
|
|
319
|
+
## Related Documentation
|
|
320
|
+
|
|
321
|
+
- [`src/core/modules/migrate/README.md`](../src/core/modules/migrate/README.md) — `uploadFileToGridFS()`, `assertGridFsFileComplete()`
|
|
322
|
+
- [`src/core/modules/file/README.md`](../src/core/modules/file/README.md) — download endpoints, error responses
|
|
323
|
+
- [`docs/security-overrides.md`](../docs/security-overrides.md) — overrides consumers must replicate
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lenne.tech/nest-server",
|
|
3
|
-
"version": "11.32.
|
|
3
|
+
"version": "11.32.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",
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { IMAGE_UPLOAD_ALLOW_LIST, multerFileFilter, multerOptionsForImageUpload, UploadAllowList } from './file.helper';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The filter used to be ONE unanchored `RegExp`, `.test()`ed against the
|
|
7
|
+
* mimetype AND the extension. `.test()` searches for a substring, so every
|
|
8
|
+
* alternative matched anywhere inside either value — `te?xt` matched the "text"
|
|
9
|
+
* in `text/html`, and a file named `x.txt` sent as `text/html` passed both
|
|
10
|
+
* halves of a filter documented as "no html".
|
|
11
|
+
*
|
|
12
|
+
* Consumer projects hit this for real. The tests below therefore cover three
|
|
13
|
+
* things: the exact-matching allow-list, that the legacy `RegExp` form still
|
|
14
|
+
* works, and — the point of the fix — that scriptable types are rejected on
|
|
15
|
+
* BOTH paths, so an expression that already exists in a consumer project is
|
|
16
|
+
* safe without being rewritten.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Run a filter the way multer does and report its verdict. */
|
|
20
|
+
function verdict(
|
|
21
|
+
filter: ReturnType<typeof multerFileFilter>,
|
|
22
|
+
mimetype: string,
|
|
23
|
+
originalname: string,
|
|
24
|
+
): { accepted: boolean; error?: string } {
|
|
25
|
+
let accepted = false;
|
|
26
|
+
let error: string | undefined;
|
|
27
|
+
filter({}, { mimetype, originalname }, (err: any, result?: boolean) => {
|
|
28
|
+
if (err) {
|
|
29
|
+
error = err instanceof Error ? err.message : String(err);
|
|
30
|
+
}
|
|
31
|
+
accepted = result === true;
|
|
32
|
+
});
|
|
33
|
+
return { accepted, error };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DOCUMENTS: UploadAllowList = {
|
|
37
|
+
extensions: ['.csv', '.pdf', '.txt'],
|
|
38
|
+
mimeTypes: ['application/pdf', 'text/csv', 'text/plain'],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
describe('multerFileFilter', () => {
|
|
42
|
+
describe('allow-list form', () => {
|
|
43
|
+
it('accepts a mimetype and extension that are both on the list', () => {
|
|
44
|
+
expect(verdict(multerFileFilter(DOCUMENTS), 'text/plain', 'notes.txt').accepted).toBe(true);
|
|
45
|
+
expect(verdict(multerFileFilter(DOCUMENTS), 'application/pdf', 'report.pdf').accepted).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('rejects as soon as one half is missing from the list', () => {
|
|
49
|
+
expect(verdict(multerFileFilter(DOCUMENTS), 'application/x-msdownload', 'setup.pdf').accepted).toBe(false);
|
|
50
|
+
expect(verdict(multerFileFilter(DOCUMENTS), 'application/pdf', 'setup.exe').accepted).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('matches whole values, never substrings', () => {
|
|
54
|
+
// Only an unanchored matcher would let these through.
|
|
55
|
+
expect(verdict(multerFileFilter(DOCUMENTS), 'evil/text-plain', 'notes.txt').accepted).toBe(false);
|
|
56
|
+
expect(verdict(multerFileFilter(DOCUMENTS), 'text/plain', 'notes.txtx').accepted).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('ignores mimetype parameters and casing', () => {
|
|
60
|
+
expect(verdict(multerFileFilter(DOCUMENTS), 'text/plain; charset=utf-8', 'notes.txt').accepted).toBe(true);
|
|
61
|
+
expect(verdict(multerFileFilter(IMAGE_UPLOAD_ALLOW_LIST), 'IMAGE/PNG', 'PHOTO.PNG').accepted).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('rejects a file without an extension', () => {
|
|
65
|
+
expect(verdict(multerFileFilter(DOCUMENTS), 'text/plain', 'README').accepted).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('scriptable types are rejected on BOTH forms', () => {
|
|
70
|
+
// This is what makes an ALREADY EXISTING consumer expression safe.
|
|
71
|
+
const legacy = /jpeg|jpg|png|te?xt|csv/;
|
|
72
|
+
|
|
73
|
+
it('rejects text/html even when the expression matches it as a substring', () => {
|
|
74
|
+
const result = verdict(multerFileFilter(legacy), 'text/html', 'invoice.txt');
|
|
75
|
+
expect(result.accepted).toBe(false);
|
|
76
|
+
expect(result.error).toMatch(/may execute as script/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('rejects text/xml and application/xhtml+xml', () => {
|
|
80
|
+
expect(verdict(multerFileFilter(legacy), 'text/xml', 'notes.txt').accepted).toBe(false);
|
|
81
|
+
expect(verdict(multerFileFilter(legacy), 'application/xhtml+xml', 'notes.txt').accepted).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('rejects SVG on the image default', () => {
|
|
85
|
+
expect(verdict(multerFileFilter(), 'image/svg+xml', 'logo.svg').accepted).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('rejects a scriptable EXTENSION even under a harmless mimetype', () => {
|
|
89
|
+
expect(verdict(multerFileFilter(legacy), 'text/plain', 'payload.html').accepted).toBe(false);
|
|
90
|
+
expect(verdict(multerFileFilter(legacy), 'image/png', 'payload.svg').accepted).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('allows them only when the caller opts in explicitly', () => {
|
|
94
|
+
const filter = multerFileFilter(
|
|
95
|
+
{ extensions: ['.svg'], mimeTypes: ['image/svg+xml'] },
|
|
96
|
+
{
|
|
97
|
+
allowScriptableTypes: true,
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
expect(verdict(filter, 'image/svg+xml', 'logo.svg').accepted).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('legacy RegExp form stays usable', () => {
|
|
105
|
+
it('still accepts what it accepted before', () => {
|
|
106
|
+
const filter = multerFileFilter(/jpeg|jpg|png/);
|
|
107
|
+
expect(verdict(filter, 'image/jpeg', 'photo.jpg').accepted).toBe(true);
|
|
108
|
+
expect(verdict(filter, 'image/png', 'photo.png').accepted).toBe(true);
|
|
109
|
+
expect(verdict(filter, 'application/pdf', 'report.pdf').accepted).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('rejection reporting', () => {
|
|
114
|
+
it('reports a real Error, not a bare string', () => {
|
|
115
|
+
let received: unknown;
|
|
116
|
+
multerFileFilter(DOCUMENTS)({}, { mimetype: 'application/x-foo', originalname: 'x.foo' }, (err: unknown) => {
|
|
117
|
+
received = err;
|
|
118
|
+
});
|
|
119
|
+
// A bare string leaves multer with an "error" that has no `message`,
|
|
120
|
+
// which NestJS's transformException cannot map to a 4xx.
|
|
121
|
+
expect(received).toBeInstanceOf(Error);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('multerOptionsForImageUpload', () => {
|
|
127
|
+
it('applies the image allow-list by default', () => {
|
|
128
|
+
const filter = multerOptionsForImageUpload({}).fileFilter!;
|
|
129
|
+
expect(verdict(filter as any, 'image/png', 'a.png').accepted).toBe(true);
|
|
130
|
+
expect(verdict(filter as any, 'image/svg+xml', 'a.svg').accepted).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('still installs a filter when fileTypeRegex is explicitly undefined', () => {
|
|
134
|
+
// Previously this disabled filtering entirely — on a helper named
|
|
135
|
+
// "ImageUpload". Safe-by-default instead; see the migration guide.
|
|
136
|
+
const filter = multerOptionsForImageUpload({ fileTypeRegex: undefined }).fileFilter;
|
|
137
|
+
expect(filter).toBeDefined();
|
|
138
|
+
expect(verdict(filter as any, 'text/html', 'a.txt').accepted).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('honours an explicit allowList', () => {
|
|
142
|
+
const filter = multerOptionsForImageUpload({ allowList: DOCUMENTS }).fileFilter!;
|
|
143
|
+
expect(verdict(filter as any, 'application/pdf', 'report.pdf').accepted).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
});
|