@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.
- package/.claude/rules/configurable-features.md +1 -0
- package/.claude/rules/module-inheritance.md +2 -0
- package/.claude/rules/package-management.md +51 -2
- package/.claude/rules/testing.md +182 -3
- package/CLAUDE.md +13 -1
- package/FRAMEWORK-API.md +2 -2
- package/dist/core/common/interfaces/server-options.interface.d.ts +1 -1
- package/dist/core/modules/ai/providers/openai-compatible.provider.d.ts +2 -0
- package/dist/core/modules/ai/providers/openai-compatible.provider.js +28 -3
- package/dist/core/modules/ai/providers/openai-compatible.provider.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/migration-guides/11.39.0-to-11.40.0.md +186 -0
- package/package.json +5 -4
- package/src/core/common/interfaces/server-options.interface.ts +17 -1
- package/src/core/modules/ai/README.md +33 -0
- package/src/core/modules/ai/providers/openai-compatible.provider.ts +76 -3
|
@@ -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.
|
|
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:
|
|
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 =
|
|
138
|
-
if (!
|
|
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
|
-
|
|
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' },
|