@lenne.tech/nest-server 11.39.0 → 11.40.0

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.
@@ -0,0 +1,186 @@
1
+ # Migration Guide: 11.39.0 → 11.40.0
2
+
3
+ > **Why a MINOR for what looks like a patch.** In this package the MAJOR digit tracks the NestJS
4
+ > major (11.x = NestJS 11), so it is not ours to spend — and every breaking change of our own ships
5
+ > as a MINOR instead. This release contains one: a security control that was silently inert starts
6
+ > being enforced, and a deployment that relied on the inert behaviour can stop working. The number
7
+ > of affected projects is small; the rule does not ask how many, it asks whether a working
8
+ > deployment can break.
9
+
10
+ ## Overview
11
+
12
+ | Category | Effort | Applies to |
13
+ |----------|--------|-----------|
14
+ | **Breaking (behaviour)** | 5 minutes | Projects that set `ai.allowedBaseUrlHosts` **as a string** (i.e. via `NSC__AI__ALLOWED_BASE_URL_HOSTS`) |
15
+ | Bugfix | none | Everyone using the AI module |
16
+ | Internal tooling | none | Nobody — `scripts/` does not ship |
17
+
18
+ Almost every project can update with `pnpm update @lenne.tech/nest-server` and read no further.
19
+ **One group cannot**, and for them the change is the uncomfortable kind: a security control that was
20
+ silently inert starts working, and a working deployment can stop working as a result.
21
+
22
+ ## Quick Migration
23
+
24
+ ```bash
25
+ # Does this affect you? If both come back empty, you are done.
26
+ grep -r "NSC__AI__ALLOWED_BASE_URL_HOSTS" . --include="*.env*" --include="*.yml" --include="*.yaml" 2>/dev/null
27
+ grep -rn "allowedBaseUrlHosts" src/ 2>/dev/null
28
+ ```
29
+
30
+ ## Breaking Change: `ai.allowedBaseUrlHosts` set as a string was never enforced
31
+
32
+ ### What was wrong
33
+
34
+ `ai.allowedBaseUrlHosts` is the SSRF egress allowlist for AI connection base URLs. It is reachable
35
+ through the framework's own environment mapping:
36
+
37
+ ```bash
38
+ NSC__AI__ALLOWED_BASE_URL_HOSTS=llm.example.com,api.openai.com
39
+ ```
40
+
41
+ `getEnvironmentObject()` turns that into `{ ai: { allowedBaseUrlHosts: '<string>' } }`, and lodash
42
+ `merge` assigns the scalar straight over the configured array.
43
+
44
+ **And there is no way to avoid that from the environment.** The `NSC__*` reader coerces exactly
45
+ three things — `'true'`, `'false'`, and anything `Number()` accepts — and leaves everything else a
46
+ string. No CSV, JSON array literal or other notation produces an array:
47
+
48
+ ```bash
49
+ NSC__AI__ALLOWED_BASE_URL_HOSTS='["a.example.com"]' # -> the string '["a.example.com"]'
50
+ NSC__AI__MAX_ITERATIONS=7 # -> the number 7
51
+ ```
52
+
53
+ So this is not an edge case for people who happened to pick the wrong notation. **If you configured
54
+ the allowlist through the environment at all, it was off — completely, on every deployment, for its
55
+ whole life.** Verified empirically against the built `config.helper.js`, not inferred from the code.
56
+ Reported by the nest-server-starter session, which went looking for why a string arrives in the
57
+ first place.
58
+
59
+ That gives you a clean dividing line, and it is the only thing you need to check:
60
+
61
+ | How you set `ai.allowedBaseUrlHosts` | Were you affected? |
62
+ |---|---|
63
+ | `NSC__AI__ALLOWED_BASE_URL_HOSTS` (or any env route) | **Yes — the control was inert** |
64
+ | A real array in `config.env.ts` | No — it worked as documented |
65
+ | Not set at all | No — no restriction was intended |
66
+
67
+ The guard then did:
68
+
69
+ ```typescript
70
+ if (!Array.isArray(allowedHosts) || !allowedHosts.length) {
71
+ return; // read as "no allowlist configured"
72
+ }
73
+ ```
74
+
75
+ So a string was read as **"not configured"** and the check was skipped entirely — no log line, no
76
+ error. An operator who used the canonical `NSC__` spelling (the documented form for every other
77
+ setting) had **no egress restriction at all** while believing the control was on.
78
+
79
+ ### What changes in 11.40.0
80
+
81
+ A string is now parsed as a comma-separated list, so the setting does what it says.
82
+
83
+ **This is a behaviour change in the restrictive direction.** If you set it as a string, your
84
+ deployment went from *no restriction* to *enforced*. Any AI connection whose host is not in that
85
+ list now fails with `ServiceUnavailableException` and a WARN naming the host. There is no
86
+ deprecation window, because leaving an SSRF control off for a release cycle is worse than the
87
+ breakage.
88
+
89
+ ### Before upgrading
90
+
91
+ 1. Find the configured value:
92
+ ```bash
93
+ grep -r "NSC__AI__ALLOWED_BASE_URL_HOSTS" . --include="*.env*" --include="*.yml" 2>/dev/null
94
+ ```
95
+ 2. List every `baseUrl` your connections actually use — including any seeded via
96
+ `ai.defaultConnection`, and any added at runtime through `aiConnections`:
97
+ ```
98
+ query { aiConnections { name baseUrl enabled } }
99
+ ```
100
+ 3. Confirm every one of those hosts appears in the list. Add the missing ones, or clear the setting
101
+ entirely if you did not mean to restrict egress:
102
+ ```bash
103
+ NSC__AI__ALLOWED_BASE_URL_HOSTS=llm.example.com,api.openai.com,localhost:11434
104
+ ```
105
+
106
+ **Unset is still permissive**, unchanged — a local Ollama works out of the box.
107
+
108
+ ### Matching rules
109
+
110
+ Worth reading once, because they are the likely cause of a surprising refusal:
111
+
112
+ | Entry | Matches | Does not match |
113
+ |-------|---------|----------------|
114
+ | `llm.example.com` | any port on that host | `llm.example.com.evil.test` |
115
+ | `llm.internal:8080` | exactly that port | `llm.internal:9200` |
116
+ | `example.com:443` | `https://example.com/` (default port) | `http://example.com/` |
117
+ | `LLM.Example.com` | `https://llm.example.com/` | — |
118
+
119
+ Entries and URLs are both trimmed, lowercased and stripped of a fully-qualifying trailing dot, so
120
+ neither side can win by spelling one DNS name differently.
121
+
122
+ ## Bugfix: the allowlist now covers every outbound path
123
+
124
+ `probeContextWindow()` (the Ollama `/api/show` probe behind `detectContextWindow()`) reached the
125
+ network **without consulting the allowlist**. It is not an admin-only path: `CoreAiService` calls
126
+ `detectAndPersistCapabilities()` on an ordinary user prompt whenever `contextWindow` is undefined,
127
+ and it runs *before* the AI rate limit.
128
+
129
+ All three outbound paths — chat completions, the capability probe, and the context-window probe —
130
+ now go through the same check. A refusal there degrades to "context window unknown" and falls back
131
+ to the built-in model table, so nothing breaks for an allowed host.
132
+
133
+ **No action required**, unless you were relying on the context-window probe reaching a host your
134
+ allowlist excludes — in which case add that host.
135
+
136
+ ## Bugfix: a malformed value is now reported
137
+
138
+ A value that is neither a list nor a string (a number, a boolean, an object — all reachable through
139
+ `NEST_SERVER_CONFIG`) carries no hostnames, so the allowlist cannot be applied. That was silent.
140
+ It is now logged as an error:
141
+
142
+ ```
143
+ ai.allowedBaseUrlHosts is a number and carries no hostnames — the SSRF egress allowlist is
144
+ NOT active. Use an array or a comma-separated string.
145
+ ```
146
+
147
+ The behaviour is unchanged (permissive); only the silence is gone. From the outside, "misconfigured"
148
+ and "deliberately unset" looked identical, which is what let the original defect survive.
149
+
150
+ ## Not consumer-facing
151
+
152
+ The rest of this release is repository tooling and does not ship: a new `check:overrides` guard for
153
+ stale pnpm overrides and audit suppressions, a `pnpm peers check` step, audit-count accounting in
154
+ `scripts/check.mjs`, and test-evidence work. `package.json` `files` ships `dist` plus docs, and the
155
+ CLI's vendor transformation copies `src/core/` — neither includes `scripts/`.
156
+
157
+ Four further tooling fixes landed late in the release, all in the same area and all of the same
158
+ kind — a gate that reported safety it had not established:
159
+
160
+ | Fix | What it was |
161
+ |-----|-------------|
162
+ | Audit hang guard (`CHECK_AUDIT_TIMEOUT`, default 600s) | `pnpm audit` emits no intermediate output, so the existing idle watchdog structurally could not tell a hang from a healthy slow run. A second, absolute cap now kills it with its own cause |
163
+ | `'unreadable'` degradation | An audit exiting **0** with no parseable tally printed a GREEN tick and a literal `0`, having assessed nothing. Reachable in practice: a pnpm version collision writes its error to stderr and exits 0 |
164
+ | 5xx read from the code field only | The signature matched `\b5\d\d\b` against the whole envelope, so pnpm's own `audited 503 packages` degraded a run that had to block |
165
+ | Reachability probe asked the wrong registry | Both check scripts hardcoded `registry.npmjs.org` while pnpm audits against the **configured** registry. Behind a private registry or proxy that reproduces the very false-green the probe removes: the real registry is unreachable, npmjs.org answers, the run reports "clean" |
166
+ | Steps list contradicted the warning | A degraded audit printed a yellow warning and "NOT CHECKED" — and a **green tick** in the Steps list, which hard-coded one per step |
167
+ | JSONC comment stripping | A regex stripper ate glob patterns out of `tsconfig.tests.json` and `.oxlintrc.json` (2783 bytes, and one whole `overrides` entry). Both files still parsed, so the assertions above them ran green against a mutilated config |
168
+
169
+ None of them changes shipped behaviour; they are recorded because each one made a **check** claim
170
+ something it had not verified, and that is the class of defect a consumer inherits indirectly — via
171
+ a release that passed a gate which was not looking.
172
+
173
+ ## Troubleshooting
174
+
175
+ | Symptom | Cause | Fix |
176
+ |---------|-------|-----|
177
+ | AI stopped working after the update; log shows `host "…" is not in ai.allowedBaseUrlHosts` | The allowlist is now enforced where it previously was not | Add the host, or clear the setting |
178
+ | `ServiceUnavailableException` on one connection only | That connection's `baseUrl` host is missing from the list | Add it — check `aiConnections`, not just `defaultConnection` |
179
+ | Error log says the allowlist is `NOT active` | The value is not a list or a string | Use an array, or a comma-separated string |
180
+ | An entry with `:443` stopped matching an `http://` URL | `:443` is the https default, not http's | Write the host without a port, or with the right one |
181
+
182
+ ## Module Documentation
183
+
184
+ - `src/core/modules/ai/README.md` → "Egress allowlist (`ai.allowedBaseUrlHosts`)"
185
+ - `.claude/rules/configurable-features.md` → "AI Egress Allowlist (SSRF)"
186
+ - `src/core/common/interfaces/server-options.interface.ts` → `IAi.allowedBaseUrlHosts`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.39.0",
3
+ "version": "11.40.0",
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",
@@ -25,11 +25,12 @@
25
25
  "cf": "pnpm run check:fix",
26
26
  "check": "node scripts/check.mjs",
27
27
  "check:consumer": "node scripts/check-consumer.mjs",
28
- "check:fix": "pnpm install && pnpm run spectaql:sync && pnpm audit --fix && pnpm run format && pnpm run lint:fix && pnpm run typecheck:tests && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && bash scripts/check-server-start.sh",
28
+ "check:fix": "pnpm install && pnpm run spectaql:sync && pnpm audit --fix && pnpm run check:overrides && pnpm peers check && pnpm run format && pnpm run lint:fix && pnpm run typecheck:tests && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && bash scripts/check-server-start.sh",
29
29
  "check:manifest": "node scripts/check-package-manifest.mjs",
30
30
  "check:mutations": "node scripts/check-mutations.mjs",
31
- "check:naf": "pnpm install && pnpm run spectaql:sync && pnpm run format && pnpm run lint:fix && pnpm run typecheck:tests && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && bash scripts/check-server-start.sh",
32
- "check:raw": "pnpm install --frozen-lockfile && pnpm run spectaql:sync && pnpm audit && pnpm run format:check && pnpm run lint && pnpm run typecheck:tests && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && bash scripts/check-server-start.sh",
31
+ "check:naf": "pnpm install && pnpm run spectaql:sync && pnpm run check:overrides && pnpm peers check && pnpm run format && pnpm run lint:fix && pnpm run typecheck:tests && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && bash scripts/check-server-start.sh",
32
+ "check:overrides": "node scripts/check-overrides.mjs",
33
+ "check:raw": "pnpm install --frozen-lockfile && pnpm run spectaql:sync && pnpm audit && pnpm run check:overrides && pnpm peers check && pnpm run format:check && pnpm run lint && pnpm run typecheck:tests && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && bash scripts/check-server-start.sh",
33
34
  "check:swc-tdz": "nest build -b swc -p tsconfig.swc-tdz.json && node scripts/check-swc-tdz.mjs",
34
35
  "cnaf": "pnpm run check:naf",
35
36
  "docs": "pnpm run docs:ci && open http://127.0.0.1:8080/ && open ./public/index.html && compodoc -p tsconfig.json -s ",
@@ -1712,9 +1712,25 @@ export interface IAi {
1712
1712
  * bare `hostname`); unset → permissive (so local providers like Ollama on localhost
1713
1713
  * work out of the box). `baseUrl` is admin-only, so this guards a compromised or
1714
1714
  * misconfigured admin, not end-user input.
1715
+ *
1716
+ * Accepts an array OR a comma-separated string. The string form is not a convenience:
1717
+ * it is the shape the framework's own env mapping produces. `NSC__AI__ALLOWED_BASE_URL_HOSTS`
1718
+ * becomes `{ ai: { allowedBaseUrlHosts: '<string>' } }`, and lodash `merge` assigns that
1719
+ * scalar straight over a configured array — so the canonical `NSC__` spelling MUST be
1720
+ * understood here or the control silently switches itself off.
1721
+ *
1722
+ * Entries are trimmed, lowercased and stripped of a fully-qualifying trailing dot, and the
1723
+ * same normalisation is applied to the URL being checked, so neither side can win by
1724
+ * spelling the same DNS name differently. A bare hostname entry matches any port on that
1725
+ * host; an entry naming the scheme's default port (`example.com:443` for https) also matches
1726
+ * the portless URL. A value that is neither an array nor a string carries no hostnames, so
1727
+ * the allowlist is inactive — that case is LOGGED as an error rather than passed over, since
1728
+ * it looks identical to "correctly unset" from the outside.
1729
+ *
1715
1730
  * @example ['llm.example.com', 'localhost:11434']
1731
+ * @example 'llm.example.com,localhost:11434' // NSC__AI__ALLOWED_BASE_URL_HOSTS
1716
1732
  */
1717
- allowedBaseUrlHosts?: string[];
1733
+ allowedBaseUrlHosts?: string | string[];
1718
1734
 
1719
1735
  /**
1720
1736
  * Persist an audit record (`aiInteractions`) for every prompt run (admin-readable).
@@ -159,6 +159,39 @@ calling and executes tools itself through `CrudService` with the caller's permis
159
159
  the child runs in a temp dir so no `CLAUDE.md`/settings leak into the context. See
160
160
  `ClaudeCliProvider` for the full security model and the optional `ai.claudeCli` config.
161
161
 
162
+ ## Egress allowlist (`ai.allowedBaseUrlHosts`)
163
+
164
+ A connection's `baseUrl` decides where the server sends outbound HTTP. It is admin-set, so the
165
+ threat model is a compromised or mistyped admin rather than end-user input — but the request still
166
+ leaves from inside your network, which is what makes it an SSRF surface.
167
+
168
+ **Unset (the default) means no restriction**, so a local provider works out of the box. When set,
169
+ only the listed hosts are reachable and everything else is refused with
170
+ `ServiceUnavailableException` plus a WARN naming the host:
171
+
172
+ ```typescript
173
+ ai: {
174
+ allowedBaseUrlHosts: ['llm.example.com', 'localhost:11434'],
175
+ }
176
+ ```
177
+
178
+ ```bash
179
+ # Same setting via the canonical env spelling — a comma-separated string is understood
180
+ NSC__AI__ALLOWED_BASE_URL_HOSTS=llm.example.com,localhost:11434
181
+ ```
182
+
183
+ Matching details worth knowing before you debug a refusal:
184
+
185
+ - A bare hostname entry matches **any port** on that host. An entry that names the scheme's default
186
+ port (`example.com:443` for https) also matches the portless URL.
187
+ - Entries and URLs are both trimmed, lowercased, and stripped of a fully-qualifying trailing dot, so
188
+ `LLM.Example.com`, `llm.example.com` and `llm.example.com.` are one host.
189
+ - The check covers **all three** outbound paths: chat completions, the capability probe, and the
190
+ Ollama context-window probe.
191
+ - A value that is neither a list nor a string carries no hostnames. The allowlist is then inactive
192
+ and the framework logs an error — from the outside that state is indistinguishable from
193
+ "correctly unset", which is exactly why it is not silent.
194
+
162
195
  ## Connections (DB configuration)
163
196
 
164
197
  Connections live in the `aiConnections` collection and are managed by admins via
@@ -134,8 +134,8 @@ export class OpenAiCompatibleProvider implements ILlmProvider {
134
134
  * admin, not an end-user input.
135
135
  */
136
136
  protected assertBaseUrlAllowed(url: string): void {
137
- const allowedHosts = ConfigService.get<string[]>('ai.allowedBaseUrlHosts');
138
- if (!Array.isArray(allowedHosts) || !allowedHosts.length) {
137
+ const allowedHosts = this.resolveAllowedBaseUrlHosts();
138
+ if (!allowedHosts.length) {
139
139
  return;
140
140
  }
141
141
  let parsed: URL;
@@ -144,7 +144,17 @@ export class OpenAiCompatibleProvider implements ILlmProvider {
144
144
  } catch {
145
145
  throw new ServiceUnavailableException(ErrorCode.AI_CONNECTION_INVALID_URL);
146
146
  }
147
- if (!allowedHosts.includes(parsed.host) && !allowedHosts.includes(parsed.hostname)) {
147
+ // A hostname entry stays host-wide (any port); the extra candidates only make an
148
+ // operator who was MORE explicit than necessary succeed rather than fail. `URL.host`
149
+ // omits the default port, so a conscientious `llm.example.com:443` entry would
150
+ // otherwise never match `https://llm.example.com/` — a lockout whose only symptom is
151
+ // a WARN and "the AI stopped working". Nothing here widens the set of reachable hosts.
152
+ const defaultPort = parsed.protocol === 'https:' ? '443' : parsed.protocol === 'http:' ? '80' : '';
153
+ const candidates = [parsed.host, parsed.hostname];
154
+ if (defaultPort && parsed.host === parsed.hostname) {
155
+ candidates.push(`${parsed.hostname}:${defaultPort}`);
156
+ }
157
+ if (!candidates.map((candidate) => this.normaliseHostEntry(candidate)).some((c) => allowedHosts.includes(c))) {
148
158
  this.logger.warn(
149
159
  `AI connection "${this.connection.name}" host "${parsed.host}" is not in ai.allowedBaseUrlHosts`,
150
160
  );
@@ -152,6 +162,62 @@ export class OpenAiCompatibleProvider implements ILlmProvider {
152
162
  }
153
163
  }
154
164
 
165
+ /**
166
+ * The configured egress allowlist as a lowercase array, whatever shape it has.
167
+ *
168
+ * A STRING is split as CSV rather than rejected, because `ai.allowedBaseUrlHosts`
169
+ * is reachable through the framework's own `NSC__AI__ALLOWED_BASE_URL_HOSTS`
170
+ * environment mapping: `getEnvironmentObject()` turns that variable into
171
+ * `{ ai: { allowedBaseUrlHosts: '<string>' } }` and lodash `merge` assigns the
172
+ * scalar straight over the configured array. A bare `!Array.isArray(...) -> return`
173
+ * then reads it as "no allowlist configured" and skips the check entirely — so an
174
+ * operator using the canonical `NSC__` spelling silently disables SSRF egress
175
+ * control, with no log line and no error. A malformed security setting must be
176
+ * interpreted or fail CLOSED, never fail open.
177
+ *
178
+ * Entries are lowercased because `URL.host` / `URL.hostname` always are; a
179
+ * differently-cased entry would otherwise fail closed for no stated reason, and
180
+ * the only symptom would be a WARN log plus "the AI stopped working".
181
+ *
182
+ * A value that is NEITHER an array nor a string carries no hostnames and cannot be
183
+ * interpreted — `NSC__AI__ALLOWED_BASE_URL_HOSTS=0` coerces to a number, and
184
+ * `NEST_SERVER_CONFIG` can deliver an object. Returning an empty list there is the
185
+ * only honest answer, but it reopens egress while the operator believes the control
186
+ * is on, so it is LOGGED every time rather than passed over in silence. That is the
187
+ * difference between this and the documented unset-is-permissive default: unset is a
188
+ * decision, a malformed value is an accident nobody is told about.
189
+ */
190
+ protected resolveAllowedBaseUrlHosts(): string[] {
191
+ const configured = ConfigService.get<unknown>('ai.allowedBaseUrlHosts');
192
+ if (configured === undefined || configured === null) {
193
+ return [];
194
+ }
195
+ const entries = typeof configured === 'string' ? configured.split(',') : configured;
196
+ if (!Array.isArray(entries)) {
197
+ this.logger.error(
198
+ `ai.allowedBaseUrlHosts is a ${typeof configured} and carries no hostnames — the SSRF egress ` +
199
+ 'allowlist is NOT active. Use an array or a comma-separated string.',
200
+ );
201
+ return [];
202
+ }
203
+ return entries.map((host) => this.normaliseHostEntry(String(host))).filter(Boolean);
204
+ }
205
+
206
+ /**
207
+ * Lowercase, trim, and drop a fully-qualifying trailing dot.
208
+ *
209
+ * Applied to BOTH the allowlist entry and the URL being checked, so neither side can
210
+ * win by spelling the same DNS name differently. `llm.example.com.` and
211
+ * `llm.example.com` resolve identically, so treating them as different hosts only ever
212
+ * produced a confusing refusal — never protection.
213
+ */
214
+ protected normaliseHostEntry(value: string): string {
215
+ return value
216
+ .trim()
217
+ .toLowerCase()
218
+ .replace(/\.(?=$|:)/, '');
219
+ }
220
+
155
221
  /**
156
222
  * Probe the backend to auto-detect capabilities for flags the connection left
157
223
  * undefined. Explicit flags are authoritative and are NOT probed. Best effort:
@@ -263,6 +329,13 @@ export class OpenAiCompatibleProvider implements ILlmProvider {
263
329
  if (!base.startsWith('http')) {
264
330
  return undefined;
265
331
  }
332
+ // The third outbound path from the same admin-controlled baseUrl, and the one the
333
+ // allowlist used to miss. It is NOT admin-only in practice: CoreAiService calls
334
+ // detectAndPersistCapabilities() on an ordinary user prompt whenever contextWindow is
335
+ // undefined, and it runs BEFORE checkRateLimit(). A guard applied to two of three
336
+ // egress paths is not a guard. Throwing is right here — detectContextWindow() already
337
+ // wraps this call, so a refusal degrades to "context window unknown".
338
+ this.assertBaseUrlAllowed(`${base}/api/show`);
266
339
  const response = await fetch(`${base}/api/show`, {
267
340
  body: JSON.stringify({ name: this.connection.model }),
268
341
  headers: { 'Content-Type': 'application/json' },