Compare commits

...

29 Commits

Author SHA1 Message Date
n8n-assistant[bot]
b58d9e89d6
🚀 Release 2.44.6 (#41788)
Co-authored-by: Matsuuu <16068444+Matsuuu@users.noreply.github.com>
2026-10-22 09:02:35 +00:00
n8n-assistant[bot]
9395e2a436
ci: Update node versions across CI to latest (backport to release-candidate/2.44.x) (#41783)
Co-authored-by: Matsu <matias.huhta@n8n.io>
2026-10-22 08:38:56 +00:00
n8n-assistant[bot]
664275d0cb
ci: Bump node pc image versions (backport to release-candidate/2.44.x) (#41758)
Co-authored-by: Matsu <matias.huhta@n8n.io>
2026-10-22 06:12:18 +00:00
n8n-assistant[bot]
c30d4d909a
ci: Bump base image node versions (backport to release-candidate/2.44.x) (#41756)
Co-authored-by: Matsu <matias.huhta@n8n.io>
2026-10-22 06:04:27 +00:00
n8n-assistant[bot]
12fe535b4b
feat(editor): Let agent owners make agents available in n8n Chat (no-changelog) (backport to release-candidate/2.44.x) (#41703)
Co-authored-by: Benjamin Schroth <benjamin@n8n.io>
2026-10-21 14:40:43 +00:00
n8n-assistant[bot]
0cbb74c675
ci: Respect pnpm workspace exclusions in dependency verifier (backport to release-candidate/2.44.x) (#41699)
Co-authored-by: Charlie Kolb <charlie@n8n.io>
2026-10-21 14:00:19 +00:00
n8n-assistant[bot]
654315ee2b
fix(Postgres Node): Prevent global pgtypes overwrite (backport to release-candidate/2.44.x) (#41667)
Co-authored-by: yehorkardash <yehor.kardash@n8n.io>
2026-10-21 11:36:00 +00:00
n8n-assistant[bot]
e993c789c8
🚀 Release 2.44.5 (#41607)
Co-authored-by: n8n-release-helper[bot] <265227495+n8n-release-helper[bot]@users.noreply.github.com>
2026-10-21 07:14:05 +00:00
n8n-assistant[bot]
fa60c78685
fix(core): Parse node type names at the first dot (no-changelog) (backport to release-candidate/2.44.x) (#41556)
Co-authored-by: Jaakko Husso <jaakko@n8n.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Robin Braumann <50590409+bjorger@users.noreply.github.com>
2026-10-20 17:19:27 +00:00
n8n-assistant[bot]
5c0f50cc5c
fix(editor): Select the agent's project in the n8n Chat picker (no-changelog) (backport to release-candidate/2.44.x) (#41527)
Co-authored-by: Benjamin Schroth <68321970+schrothbn@users.noreply.github.com>
2026-10-20 11:06:22 +00:00
n8n-assistant[bot]
db15336502
fix(core): Copy native evaluation results as JSON and hand other values to the engine (no-changelog) (backport to release-candidate/2.44.x) (#41451)
Co-authored-by: Mike Repeć <mike.repec@n8n.io>
2026-10-19 14:56:09 +00:00
n8n-assistant[bot]
370a687d6e
feat(editor): Track the concise style experiment for Instance AI (no-changelog) (backport to release-candidate/2.44.x) (#41432)
Co-authored-by: Jaakko Husso <jaakko@n8n.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:00:24 +00:00
n8n-assistant[bot]
67db65c88f
fix(core): Decline the whole expression when the extension rewrite reaches an optional chain (no-changelog) (backport to release-candidate/2.44.x) (#41424)
Co-authored-by: Mike Repeć <mike.repec@n8n.io>
2026-10-19 14:58:41 +02:00
n8n-assistant[bot]
69fd1cb70a
fix(core): Hand node reference results without an item shape to the engine (no-changelog) (backport to release-candidate/2.44.x) (#41411)
Co-authored-by: Mike Repeć <mike.repec@n8n.io>
2026-10-19 11:49:19 +00:00
n8n-assistant[bot]
f00e4f4c4d
fix(core): Charge node reference calls against the native evaluation work budget (no-changelog) (backport to release-candidate/2.44.x) (#41410)
Co-authored-by: Mike Repeć <mike.repec@n8n.io>
2026-10-19 10:53:48 +00:00
n8n-assistant[bot]
615a518eaa
fix(core): Charge element content for comparing array methods in native evaluation (no-changelog) (backport to release-candidate/2.44.x) (#41412)
Co-authored-by: Mike Repeć <mike.repec@n8n.io>
2026-10-19 10:49:30 +00:00
n8n-assistant[bot]
6ff319863b
fix(editor): Load credential types on startup (backport to release-candidate/2.44.x) (#41405)
Co-authored-by: yehorkardash <yehor.kardash@n8n.io>
2026-10-19 10:27:28 +00:00
n8n-assistant[bot]
1f074d4ea7
fix(core): Key queue job outcomes by job on shutdown resume (backport to release-candidate/2.44.x) (#41404)
Co-authored-by: Mike Repeć <mike.repec@n8n.io>
2026-10-19 10:12:48 +00:00
n8n-assistant[bot]
50688e31ed
🚀 Release 2.44.4 (#41362)
Co-authored-by: n8n-release-helper[bot] <265227495+n8n-release-helper[bot]@users.noreply.github.com>
2026-10-19 07:11:56 +00:00
n8n-assistant[bot]
69651932d6
fix(core): Turn off expression spans by default (backport to release-candidate/2.44.x) (#41309)
Co-authored-by: Olivier <52361003+ocdejong@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:47:26 +00:00
n8n-assistant[bot]
b79afe2390
fix(core): Reuse connections in the log streaming webhook destination (backport to release-candidate/2.44.x) (#41281)
Co-authored-by: Mike Repeć <mike.repec@n8n.io>
Co-authored-by: Theo Nastos <theodore.nastos@n8n.io>
2026-10-16 14:42:42 +00:00
n8n-assistant[bot]
bfbeea9c35
🚀 Release 2.44.3 (#41238)
Co-authored-by: n8n-release-helper[bot] <265227495+n8n-release-helper[bot]@users.noreply.github.com>
2026-10-16 07:09:46 +00:00
n8n-assistant[bot]
c09e0a94f5
fix(core): Keep Observer detail lines instead of discarding the response (backport to release-candidate/2.44.x) (#41157)
Co-authored-by: Jaakko Husso <jaakko@n8n.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 07:31:09 +00:00
n8n-assistant[bot]
6525584709
🚀 Release 2.44.2 (#41154)
Co-authored-by: n8n-release-helper[bot] <265227495+n8n-release-helper[bot]@users.noreply.github.com>
2026-10-15 07:10:04 +00:00
n8n-assistant[bot]
c52f1817b3
fix(core): Create an XML parser for each webhook request body (backport to release-candidate/2.44.x) (#41112)
Co-authored-by: Mike Repeć <mike.repec@n8n.io>
2026-10-14 14:21:43 +00:00
n8n-assistant[bot]
16b49eb412
🚀 Release 2.44.1 (#41040)
Co-authored-by: n8n-release-helper[bot] <265227495+n8n-release-helper[bot]@users.noreply.github.com>
2026-10-14 09:25:53 +02:00
n8n-assistant[bot]
89f18b23b8
chore: Bundle/2.x (backport to release-candidate/2.44.x) (#41038)
Co-authored-by: n8n-assistant[bot] <100856346+n8n-assistant[bot]@users.noreply.github.com>
Co-authored-by: Matsu <matias.huhta@n8n.io>
Co-authored-by: Bernhard Wittmann <bernhard.wittmann@n8n.io>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Elias Meire <elias@meire.dev>
Co-authored-by: n8n-assistant[bot] <n8n-assistant[bot]@users.noreply.github.com>
Co-authored-by: João Almeida <jg.almeida56@gmail.com>
Co-authored-by: Savelii <savelii.sychov@n8n.io>
Co-authored-by: Rick Gale <richardgale90@gmail.com>
Co-authored-by: Emilia <100027345+sovietspaceship@users.noreply.github.com>
Co-authored-by: oleg <me@olegivaniv.com>
Co-authored-by: RomanDavydchuk <roman.davydchuk@n8n.io>
Co-authored-by: Stephen Wright <sjw948@gmail.com>
Co-authored-by: Yuliia Pominchuk <31064937+yuliia-pominchuk@users.noreply.github.com>
Co-authored-by: Shireen Missi <94372015+ShireenMissi@users.noreply.github.com>
Co-authored-by: Miguel Ángel Moreno <1411774+miguelsaddress@users.noreply.github.com>
Co-authored-by: Anne Aguirre <anne.aguirre@n8n.io>
Co-authored-by: Dimitri Lavrenük <20122620+dlavrenuek@users.noreply.github.com>
Co-authored-by: Kai <kai.hartlage@n8n.io>
Co-authored-by: Luigi Gubello <27034386+luigigubello@users.noreply.github.com>
Co-authored-by: Danny Martini <danny@n8n.io>
Co-authored-by: MHMD <45968454+dou-mhmd@users.noreply.github.com>
Co-authored-by: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com>
Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
Co-authored-by: Charlie Kolb <charlie@n8n.io>
2026-10-14 07:00:46 +00:00
n8n-assistant[bot]
321b0203b7
feat: Show Gemini models by name and list prioritized models first in model pickers (backport to release-candidate/2.44.x) (#41015)
Co-authored-by: Alexander Gekov <40495748+alexander-gekov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2026-10-13 16:13:15 +00:00
n8n-assistant[bot]
c25417c88e
feat(core): Evaluate primitive reads off $now and $today natively (no-changelog) (backport to release-candidate/2.44.x) (#40991)
Co-authored-by: Mike Repeć <mike.repec@n8n.io>
2026-10-13 14:00:38 +00:00
313 changed files with 9680 additions and 1405 deletions

View File

@ -568,8 +568,8 @@ Runs on push to `master`:
``` ```
Push to master Push to master
├─ build-and-format (Blacksmith: build, then format check; populate master cache) ├─ build-and-format (Blacksmith: build, then format check; populate master cache)
├─ unit-test (matrix: Node 24.18.1, 26.5.1) ├─ unit-test (matrix: Node 24.22.1, 26.12.1)
│ └─ Coverage only on 24.18.1 │ └─ Coverage only on 24.22.1
├─ lint ├─ lint
├─ (performance: CodSpeed benchmarks, paused until the plugin supports Vitest 5) ├─ (performance: CodSpeed benchmarks, paused until the plugin supports Vitest 5)
├─ verify-single-instance-npm (advisory; packages changed by this push) ├─ verify-single-instance-npm (advisory; packages changed by this push)
@ -650,7 +650,7 @@ Composite actions in `.github/actions/`:
```yaml ```yaml
inputs: inputs:
node-version: # default: '26.5.1' node-version: # default: '26.12.1'
enable-docker-cache: # default: 'false' (Blacksmith Buildx) enable-docker-cache: # default: 'false' (Blacksmith Buildx)
docker-cache-key: # required when enable-docker-cache is true docker-cache-key: # required when enable-docker-cache is true
build-command: # default: 'pnpm build' build-command: # default: 'pnpm build'

View File

@ -7,9 +7,9 @@ description: 'Configures Node.js with pnpm, installs Aikido SafeChain for supply
inputs: inputs:
node-version: node-version:
description: 'Node.js version to use. Pinned to 26.5.1 by default for reproducible builds.' description: 'Node.js version to use. Pinned to 26.12.1 by default for reproducible builds.'
required: false required: false
default: '26.5.1' default: '26.12.1'
enable-docker-cache: enable-docker-cache:
description: 'Whether to set up Blacksmith Buildx for Docker layer caching (Blacksmith runners only).' description: 'Whether to set up Blacksmith Buildx for Docker layer caching (Blacksmith runners only).'
required: false required: false

View File

@ -32,11 +32,11 @@ jobs:
# when updating. # when updating.
include: include:
- node_version: '22' - node_version: '22'
dhi_ref: dhi.io/node:22.23.3-alpine3.24-dev@sha256:23ee07ace431ea7d14e22c0e52fb52b0bd1853dabdacd89ba75f937f6dff8a8d dhi_ref: dhi.io/node:22.23.4-alpine3.24-dev@sha256:a2c4778492ad46fd6f362d8d93e21c0543d0009db7f934d5eb9f9ccb77eafdcb
- node_version: '24.21.0' - node_version: '24.22.1'
dhi_ref: dhi.io/node:24.21.0-alpine3.24-dev@sha256:d22ba0e9ab2a54609ae533df4ebfd6f36c1f233b662a4c547f5a3195e8c21937 dhi_ref: dhi.io/node:24.22.1-alpine3.24-dev@sha256:7b851ff65d774343caf89450555663caef54dbd8f673658a615d256883973337
- node_version: '26.10.0' - node_version: '26.12.1'
dhi_ref: dhi.io/node:26.10.0-alpine3.24-dev@sha256:8da859df5dc853c923ace53c4cfe994a909a28917f7fcfc8508869beab8cf132 dhi_ref: dhi.io/node:26.12.1-alpine3.24-dev@sha256:4b1591bfce570a17f9562e206b62e76be84d6df32c3a86bfc677fc87959402cc
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

View File

@ -25,7 +25,7 @@ on:
type: boolean type: boolean
env: env:
NODE_VERSION: '26.10.0' NODE_VERSION: '26.12.1'
jobs: jobs:
build: build:

View File

@ -40,11 +40,11 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
node-version: [24.18.1, 26.5.1] node-version: [24.22.1, 26.12.1]
with: with:
ref: ${{ github.sha }} ref: ${{ github.sha }}
nodeVersion: ${{ matrix.node-version }} nodeVersion: ${{ matrix.node-version }}
collectCoverage: ${{ matrix.node-version == '24.18.1' }} collectCoverage: ${{ matrix.node-version == '24.22.1' }}
secrets: inherit secrets: inherit
lint: lint:

View File

@ -8,7 +8,7 @@ run-name: "${{ github.event_name == 'workflow_dispatch' && format('Docker: Build
env: env:
NODE_OPTIONS: '--max-old-space-size=7168' NODE_OPTIONS: '--max-old-space-size=7168'
NODE_VERSION: '26.10.0' NODE_VERSION: '26.12.1'
on: on:
schedule: schedule:

View File

@ -12,7 +12,7 @@ env:
# The images copy native bindings from the host build, so both have to use the same # The images copy native bindings from the host build, so both have to use the same
# Node. dockerize-n8n.mjs reads this env for the image builds. Keep in sync with # Node. dockerize-n8n.mjs reads this env for the image builds. Keep in sync with
# NODE_VERSION in docker-build-push.yml, which is what the published images use. # NODE_VERSION in docker-build-push.yml, which is what the published images use.
NODE_VERSION: '26.10.0' NODE_VERSION: '26.12.1'
# Also build the distroless runners image and exec-check # Also build the distroless runners image and exec-check
# both runners images' interpreters, so a broken runtime assembly (e.g. a # both runners images' interpreters, so a broken runtime assembly (e.g. a
# missing shared library after a Node bump) fails at PR time. The smoke # missing shared library after a Node bump) fails at PR time. The smoke

View File

@ -19,7 +19,7 @@ on:
default: false default: false
env: env:
NODE_VERSION: '26.10.0' NODE_VERSION: '26.12.1'
SCRATCH_TAG: sbom-probe-${{ github.run_id }} SCRATCH_TAG: sbom-probe-${{ github.run_id }}
# A separate package. These are throwaway fixtures (one is a two-layer # A separate package. These are throwaway fixtures (one is a two-layer
# alpine), and ghcr.io/<repo> is where the real release tags live - they # alpine), and ghcr.io/<repo> is where the real release tags live - they

View File

@ -76,7 +76,7 @@ jobs:
steps: steps:
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
node-version: 24.18.1 node-version: 24.22.1
# Remove after https://github.com/npm/cli/issues/8547 gets resolved # Remove after https://github.com/npm/cli/issues/8547 gets resolved
- run: echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc - run: echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc

View File

@ -67,7 +67,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
node-version: '24.18.1' node-version: '24.22.1'
# A version bump only edits one package.json, so no workspace install/build # A version bump only edits one package.json, so no workspace install/build
# is needed. `pnpm ls -r --only-projects` reads the workspace config directly. # is needed. `pnpm ls -r --only-projects` reads the workspace config directly.

View File

@ -34,7 +34,7 @@ jobs:
uses: ./.github/actions/setup-nodejs uses: ./.github/actions/setup-nodejs
with: with:
# @codspeed/core prebuilds do not load on Node 26 (removed V8 symbol). # @codspeed/core prebuilds do not load on Node 26 (removed V8 symbol).
node-version: '24.18.1' node-version: '24.22.1'
- name: Run benchmarks - name: Run benchmarks
uses: CodSpeedHQ/action@281164b0f014a4e7badd2c02cecad9b595b70537 # v4.11.1 uses: CodSpeedHQ/action@281164b0f014a4e7badd2c02cecad9b595b70537 # v4.11.1

View File

@ -12,7 +12,7 @@ on:
description: Version of node to use. description: Version of node to use.
required: false required: false
type: string type: string
default: 26.5.1 default: 26.12.1
env: env:
# Each shard runs two workers with a 6 GB cap. This fits the runner's 15.2 GB. # Each shard runs two workers with a 6 GB cap. This fits the runner's 15.2 GB.

View File

@ -12,7 +12,7 @@ on:
description: Version of node to use. description: Version of node to use.
required: false required: false
type: string type: string
default: 26.5.1 default: 26.12.1
collectCoverage: collectCoverage:
required: false required: false
default: false default: false

View File

@ -12,7 +12,7 @@ on:
description: Version of node to use. description: Version of node to use.
required: false required: false
type: string type: string
default: 26.5.1 default: 26.12.1
env: env:
NODE_OPTIONS: --max-old-space-size=7168 NODE_OPTIONS: --max-old-space-size=7168

View File

@ -43,7 +43,7 @@ jobs:
if: matrix.safe_chain == false if: matrix.safe_chain == false
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
node-version: '26.5.1' node-version: '26.12.1'
- name: Resolve pnpm version - name: Resolve pnpm version
id: pnpm-version id: pnpm-version

View File

@ -1,3 +1,53 @@
## [2.44.6](https://github.com/n8n-io/n8n/compare/n8n@2.44.5...n8n@2.44.6) (2026-10-22)
### Bug Fixes
* **Postgres Node:** Prevent global pgtypes overwrite ([#41667](https://github.com/n8n-io/n8n/issues/41667)) ([654315e](https://github.com/n8n-io/n8n/commit/654315ee2b751212f09026d112d86b262687b181))
## [2.44.5](https://github.com/n8n-io/n8n/compare/n8n@2.44.4...n8n@2.44.5) (2026-10-21)
### Bug Fixes
* **core:** Key queue job outcomes by job on shutdown resume ([#41404](https://github.com/n8n-io/n8n/issues/41404)) ([1f074d4](https://github.com/n8n-io/n8n/commit/1f074d4ea734cbe07b4e49e8b7db4d1cccc74a89))
* **editor:** Load credential types on startup ([#41405](https://github.com/n8n-io/n8n/issues/41405)) ([6ff3198](https://github.com/n8n-io/n8n/commit/6ff319863b85557eab4db82c20da693763dade0b))
## [2.44.4](https://github.com/n8n-io/n8n/compare/n8n@2.44.3...n8n@2.44.4) (2026-10-19)
### Bug Fixes
* **core:** Reuse connections in the log streaming webhook destination ([#41281](https://github.com/n8n-io/n8n/issues/41281)) ([b79afe2](https://github.com/n8n-io/n8n/commit/b79afe23905e6b7212f4da6ccb65935c1bb29377))
* **core:** Turn off expression spans by default ([#41309](https://github.com/n8n-io/n8n/issues/41309)) ([6965193](https://github.com/n8n-io/n8n/commit/69651932d6311454f62f8d7f4ea45d7879ab9e7e))
## [2.44.3](https://github.com/n8n-io/n8n/compare/n8n@2.44.2...n8n@2.44.3) (2026-10-16)
### Bug Fixes
* **core:** Keep Observer detail lines instead of discarding the response ([#41157](https://github.com/n8n-io/n8n/issues/41157)) ([c09e0a9](https://github.com/n8n-io/n8n/commit/c09e0a94f57d7b046232b78f6cf2c9066784f87e))
## [2.44.2](https://github.com/n8n-io/n8n/compare/n8n@2.44.1...n8n@2.44.2) (2026-10-15)
### Bug Fixes
* **core:** Create an XML parser for each webhook request body ([#41112](https://github.com/n8n-io/n8n/issues/41112)) ([c52f181](https://github.com/n8n-io/n8n/commit/c52f1817b38cb03758fee45b1109caae12411ffa))
## [2.44.1](https://github.com/n8n-io/n8n/compare/n8n@2.44.0...n8n@2.44.1) (2026-10-14)
### Features
* Show Gemini models by name and list prioritized models first in model pickers ([#41015](https://github.com/n8n-io/n8n/issues/41015)) ([321b020](https://github.com/n8n-io/n8n/commit/321b0203b73e3351c766cbece6a8f79c800d453f))
# [2.44.0](https://github.com/n8n-io/n8n/compare/n8n@2.43.0...n8n@2.44.0) (2026-10-13) # [2.44.0](https://github.com/n8n-io/n8n/compare/n8n@2.43.0...n8n@2.44.0) (2026-10-13)

View File

@ -1,7 +1,7 @@
# Every n8n image build uses this file. `pnpm build:docker` and CI both drive # Every n8n image build uses this file. `pnpm build:docker` and CI both drive
# these targets, so a pin changed here changes both. # these targets, so a pin changed here changes both.
variable "NODE_VERSION" { default = "26.10.0" } variable "NODE_VERSION" { default = "26.12.1" }
variable "N8N_VERSION" { default = "snapshot" } variable "N8N_VERSION" { default = "snapshot" }
variable "N8N_RELEASE_TYPE" { default = "dev" } variable "N8N_RELEASE_TYPE" { default = "dev" }
@ -15,14 +15,14 @@ variable "BUILDER_IMAGE" { default = "" }
variable "RUNTIME_IMAGE" { default = "" } variable "RUNTIME_IMAGE" { default = "" }
variable "PC_BUILDER_IMAGE" { variable "PC_BUILDER_IMAGE" {
default = "n8nio/node-pc:26.10.0-dev@sha256:61e4b1f3b3824ffb908812e01bcbddabb64a01ef23d408d561696c344c6679e3" default = "n8nio/node-pc:26.12.1-dev@sha256:8993967928b6167703f05f28a95f82543974357dcfd4160281cf352490d208ca"
} }
variable "PC_RUNTIME_IMAGE" { variable "PC_RUNTIME_IMAGE" {
default = "n8nio/node-pc:26.10.0@sha256:386bae24d41c0091d21b4eee124a86464710fff24d5cb7f37386c4c5649b0c89" default = "n8nio/node-pc:26.12.1@sha256:15afd63862b7cbc828537b7481f0c58a6e504765b62e7f55a226ad052068f77c"
} }
variable "DHI_REF" { variable "DHI_REF" {
default = "dhi.io/node:26.10.0-alpine3.24-dev@sha256:8da859df5dc853c923ace53c4cfe994a909a28917f7fcfc8508869beab8cf132" default = "dhi.io/node:26.12.1-alpine3.24-dev@sha256:4b1591bfce570a17f9562e206b62e76be84d6df32c3a86bfc677fc87959402cc"
} }
variable "N8N_TAGS" { default = "" } variable "N8N_TAGS" { default = "" }

View File

@ -1,4 +1,4 @@
ARG NODE_VERSION=24.18.1 ARG NODE_VERSION=24.22.1
FROM node:${NODE_VERSION}-alpine3.24 FROM node:${NODE_VERSION}-alpine3.24

View File

@ -1,6 +1,6 @@
# CI passes one DHI reference per published Node version (see the matrix in # CI passes one DHI reference per published Node version (see the matrix in
# build-base-image.yml). The default keeps plain `docker build` working. # build-base-image.yml). The default keeps plain `docker build` working.
ARG DHI_REF=dhi.io/node:24.21.0-alpine3.24-dev@sha256:d22ba0e9ab2a54609ae533df4ebfd6f36c1f233b662a4c547f5a3195e8c21937 ARG DHI_REF=dhi.io/node:24.22.1-alpine3.24-dev@sha256:7b851ff65d774343caf89450555663caef54dbd8f673658a615d256883973337
FROM ${DHI_REF} FROM ${DHI_REF}
# Install all dependencies in a single layer to minimize image size # Install all dependencies in a single layer to minimize image size

View File

@ -1,9 +1,9 @@
ARG NODE_VERSION=26.10.0 ARG NODE_VERSION=26.12.1
ARG N8N_VERSION=snapshot ARG N8N_VERSION=snapshot
# The digest pins node:26.10.0-alpine3.24 for amd64 and arm64. Change the tag and the digest together. # The digest pins node:26.12.1-alpine3.24 for amd64 and arm64. Change the tag and the digest together.
ARG BUILDER_IMAGE=node:26.10.0-alpine3.24@sha256:0b36e8c136b94cd4fcf02188228e76c31ad5872eef3fec8cbd2eee500cfd9e80 ARG BUILDER_IMAGE=node:26.12.1-alpine3.24@sha256:40cd011b55415bc894e45b02fdd5a5dfde056921b7d23a64c74f80497af48797
ARG RUNTIME_IMAGE=n8nio/base:26.10.0@sha256:9205c76a7745bfe71de35c2a0b7420238ccb0bdf69fc5273ab2e617fe918cf5f ARG RUNTIME_IMAGE=n8nio/base:26.12.1@sha256:435e5310b29cfa5880b5512703f7d10bafcf0f5dd700c7adac6ee8cfbe77306a
# The runtime base has no compiler, so this stage supplies one. # The runtime base has no compiler, so this stage supplies one.
FROM ${BUILDER_IMAGE} AS toolchain FROM ${BUILDER_IMAGE} AS toolchain

View File

@ -10,7 +10,7 @@
# dev - plain Alpine with apk usable, for the n8n builder # dev - plain Alpine with apk usable, for the n8n builder
# stage, which must compile native addons against this # stage, which must compile native addons against this
# Node's headers # Node's headers
ARG NODE_VERSION=26.10.0 ARG NODE_VERSION=26.12.1
# Compiling V8 needs 1.5-2 GB of memory per parallel job. Lower this on # Compiling V8 needs 1.5-2 GB of memory per parallel job. Lower this on
# builders whose memory-to-core ratio cannot cover all cores. # builders whose memory-to-core ratio cannot cover all cores.
ARG MAKE_JOBS= ARG MAKE_JOBS=
@ -90,7 +90,7 @@ ENV npm_config_nodedir=/usr/local
# npx, corepack) are swapped for the pointer-compressed build's files. # npx, corepack) are swapped for the pointer-compressed build's files.
# Pinned to a multi-arch index digest (linux/amd64 + linux/arm64) for reproducible builds. # Pinned to a multi-arch index digest (linux/amd64 + linux/arm64) for reproducible builds.
# Bump the tag and digest together when updating. # Bump the tag and digest together when updating.
FROM n8nio/base:26.10.0@sha256:9205c76a7745bfe71de35c2a0b7420238ccb0bdf69fc5273ab2e617fe918cf5f AS runtime FROM n8nio/base:26.12.1@sha256:435e5310b29cfa5880b5512703f7d10bafcf0f5dd700c7adac6ee8cfbe77306a AS runtime
RUN rm -f /usr/bin/node /usr/bin/nodejs /usr/bin/npm /usr/bin/npx /usr/bin/corepack /usr/local/bin/node RUN rm -f /usr/bin/node /usr/bin/nodejs /usr/bin/npm /usr/bin/npx /usr/bin/corepack /usr/local/bin/node
COPY --from=builder /node-install/usr/local /usr/local COPY --from=builder /node-install/usr/local /usr/local

View File

@ -1,4 +1,4 @@
ARG NODE_VERSION=26.10.0 ARG NODE_VERSION=26.12.1
ARG PNPM_VERSION=12.4.2 ARG PNPM_VERSION=12.4.2
ARG PYTHON_VERSION=3.13 ARG PYTHON_VERSION=3.13
@ -132,9 +132,9 @@ COPY --from=python-runner-builder /usr/local/bin/uv /usr/local/bin/uv
# Bring node over from node-alpine # Bring node over from node-alpine
COPY --from=node-alpine /usr/local/bin/node /usr/local/bin/node COPY --from=node-alpine /usr/local/bin/node /usr/local/bin/node
# libstdc++ is required by Node # libstdc++ and libatomic are required by Node
# libc6-compat is required by task-runner-launcher # libc6-compat is required by task-runner-launcher
RUN apk add --no-cache ca-certificates tini libstdc++ libc6-compat && \ RUN apk add --no-cache ca-certificates tini libstdc++ libatomic libc6-compat && \
apk upgrade --no-cache && \ apk upgrade --no-cache && \
pip install --no-cache-dir "pip>=26.0" && \ pip install --no-cache-dir "pip>=26.0" && \
apk del apk-tools apk del apk-tools

View File

@ -12,7 +12,7 @@
# - Uses distroless nonroot user (UID 65532) # - Uses distroless nonroot user (UID 65532)
# ============================================================================== # ==============================================================================
ARG NODE_VERSION=26.10.0 ARG NODE_VERSION=26.12.1
ARG PNPM_VERSION=12.4.2 ARG PNPM_VERSION=12.4.2
ARG PYTHON_VERSION=3.13 ARG PYTHON_VERSION=3.13

View File

@ -1,6 +1,6 @@
{ {
"name": "n8n-monorepo", "name": "n8n-monorepo",
"version": "2.44.0", "version": "2.44.6",
"private": true, "private": true,
"engines": { "engines": {
"node": ">=24.0.0", "node": ">=24.0.0",

View File

@ -1,6 +1,6 @@
{ {
"name": "@n8n/agents", "name": "@n8n/agents",
"version": "0.29.0", "version": "0.29.5",
"description": "AI agent SDK for n8n's code-first execution engine", "description": "AI agent SDK for n8n's code-first execution engine",
"engines": { "engines": {
"node": ">=24.0.0" "node": ">=24.0.0"

View File

@ -237,6 +237,68 @@ describe('parseObservationLogMarkdown', () => {
]); ]);
expect(parsed.skippedLines).toEqual(['not a bullet']); expect(parsed.skippedLines).toEqual(['not a bullet']);
}); });
it('appends indented unmarked detail lines to the entry above', () => {
const parsed = parseObservationLogMarkdown(
[
"* CRITICAL (15:09) User's invoice rules:",
' * Count only invoices with status paid or pending.',
' - Status void means cancelled.',
'* IMPORTANT (15:10) Open questions for the user',
' * INFO (15:10) Setup is blocked on two answers.',
' 1. Which bridge do they use?',
' 2. Which webhook URL?',
].join('\n'),
);
expect(parsed.entries).toEqual([
{
marker: 'critical',
text: "User's invoice rules: Count only invoices with status paid or pending. Status void means cancelled.",
parentIndex: null,
},
{ marker: 'important', text: 'Open questions for the user', parentIndex: null },
{
marker: 'info',
text: 'Setup is blocked on two answers. 1. Which bridge do they use? 2. Which webhook URL?',
parentIndex: 1,
},
]);
expect(parsed.skippedLines).toEqual([]);
});
it('joins a detail line with a semicolon when the entry has no closing punctuation', () => {
const parsed = parseObservationLogMarkdown(
['* INFO (10:02) Published version 90f4e3d6', ' * Current draft fbda5a15'].join('\n'),
);
expect(parsed.entries).toEqual([
{
marker: 'info',
text: 'Published version 90f4e3d6; Current draft fbda5a15',
parentIndex: null,
},
]);
});
it('keeps detail lines that start with an uppercase word', () => {
const parsed = parseObservationLogMarkdown(
[
'* INFO (10:02) Endpoints checked:',
' * GET /orders returns 200.',
' * API key rotated.',
].join('\n'),
);
expect(parsed.entries).toEqual([
{
marker: 'info',
text: 'Endpoints checked: GET /orders returns 200. API key rotated.',
parentIndex: null,
},
]);
expect(parsed.skippedLines).toEqual([]);
});
}); });
describe('renderObserverTranscript', () => { describe('renderObserverTranscript', () => {
@ -520,6 +582,43 @@ describe('runObservationLogObserver', () => {
}, },
); );
it('writes observations with indented detail lists and advances the cursor', async () => {
const store = new InMemoryMemory();
await store.saveThread({ id: 'thread-1', resourceId: 'user-1' });
await store.saveMessages({
threadId: 'thread-1',
resourceId: 'user-1',
messages: [message('m1', 'user', 'Remember my rules.', new Date(2026, 4, 12, 14, 30))],
});
const result = await runObservationLogObserver({
memory: store,
observationScopeId: 'thread-1',
observationLogTailLimit: 20,
tokenCounter: () => 10,
now: new Date(2026, 4, 12, 14, 31),
observe: async () =>
await Promise.resolve(
[
'* CRITICAL (14:31) User rules:',
' * Count paid invoices.',
' * Skip void ones.',
].join('\n'),
),
});
expect(result).toMatchObject({
status: 'ran',
observationsWritten: 1,
cursorAdvanced: true,
skippedLines: [],
});
expect(await store.getActiveObservationLog({ observationScopeId: 'thread-1' })).toMatchObject([
{ marker: 'critical', text: 'User rules: Count paid invoices. Skip void ones.' },
]);
expect(await store.getCursor('thread-1')).toMatchObject({ lastObservedMessageId: 'm1' });
});
it('writes parsed observations and advances the cursor after observing', async () => { it('writes parsed observations and advances the cursor after observing', async () => {
const store = new InMemoryMemory(); const store = new InMemoryMemory();
const parentText = 'User needs the current request remembered.'; const parentText = 'User needs the current request remembered.';
@ -610,6 +709,12 @@ describe('runObservationLogObserver', () => {
'```\nNO_OBSERVATIONS\n```', '```\nNO_OBSERVATIONS\n```',
'NO_OBSERVATIONS\n* CRITICAL Keep this fact.', 'NO_OBSERVATIONS\n* CRITICAL Keep this fact.',
'* CRITICAL Keep this fact.\nUnformatted detail.', '* CRITICAL Keep this fact.\nUnformatted detail.',
' Indented detail before any entry.\n* CRITICAL Keep this fact.',
'* CRITICAL Keep this fact.\n * COMPLETED (14:31) Unknown marker.',
'* CRITICAL Keep this fact.\n * COMPLETED Deploy is set for noon.',
'* CRITICAL Keep this fact.\n * INFO: Marker with a colon.',
'* CRITICAL Keep this fact.\n ```\n code\n ```',
'* CRITICAL Keep this fact.\n NO_OBSERVATIONS',
'* INFO ', '* INFO ',
'```\n* CRITICAL Keep this fact.\n```', '```\n* CRITICAL Keep this fact.\n```',
])('rejects an invalid response without changing memory: %j', async (output) => { ])('rejects an invalid response without changing memory: %j', async (output) => {

View File

@ -42,6 +42,8 @@ A valid response has exactly one of these forms:
Use only one indentation level. A child attaches to the preceding top-level bullet in this response. Never omit the \`*\` prefix. Use only one indentation level. A child attaches to the preceding top-level bullet in this response. Never omit the \`*\` prefix.
Every line is one observation with its own marker. To add details to an observation, write each detail as a marked child, or put the details on the same line separated by semicolons. Never write a plain sub-list, a numbered list, or any line without a marker.
2. Exactly this marker when there is nothing durable to record: 2. Exactly this marker when there is nothing durable to record:
NO_OBSERVATIONS NO_OBSERVATIONS
@ -90,6 +92,16 @@ GOOD:
* CRITICAL (14:30) User approved the data migration. Deployment requires separate approval. * CRITICAL (14:30) User approved the data migration. Deployment requires separate approval.
* IMPORTANT (14:31) Agent plans to start the migration; completion is not verified. * IMPORTANT (14:31) Agent plans to start the migration; completion is not verified.
Details go on marked lines
BAD:
* CRITICAL (15:09) User's invoice rules:
* Count only invoices with status paid or pending.
- Status void means cancelled.
GOOD:
* CRITICAL (15:09) User's invoice rules: count only invoices with status paid or pending; status void means cancelled.
Keep verification evidence together Keep verification evidence together
Transcript: Transcript:

View File

@ -29,6 +29,12 @@ const MARKER_BY_TOKEN: Record<string, ObservationLogMarker> = {
const BULLET_PATTERN = const BULLET_PATTERN =
/^(\s*)[*-]\s+(CRITICAL|IMPORTANT|INFO|COMPLETION)(?:\s+\(\d{2}:\d{2}\))?\s+(.+)$/iu; /^(\s*)[*-]\s+(CRITICAL|IMPORTANT|INFO|COMPLETION)(?:\s+\(\d{2}:\d{2}\))?\s+(.+)$/iu;
/**
* Indented lines that are malformed structure, not detail: a fence, the sentinel, a timed
* unknown marker, or a misspelled marker. Other uppercase words (API, GET) stay detail.
*/
const NON_DETAIL_PATTERN =
/^(?:```|NO_OBSERVATIONS$|(?:[*-]\s+)?(?:[A-Z_]+\s+\(\d{2}:\d{2}\)|(?:COMPLET|CRITIC|IMPORTAN|INFO)[A-Z]*\b))/u;
const DEFAULT_MAX_SERIALIZED_CHARS = 2_000; const DEFAULT_MAX_SERIALIZED_CHARS = 2_000;
const DEFAULT_MAX_STRING_CHARS = 500; const DEFAULT_MAX_STRING_CHARS = 500;
const DEFAULT_MAX_ARRAY_ITEMS = 20; const DEFAULT_MAX_ARRAY_ITEMS = 20;
@ -119,6 +125,13 @@ export function parseObservationLogMarkdown(markdown: string): ParseObservationL
const match = BULLET_PATTERN.exec(rawLine); const match = BULLET_PATTERN.exec(rawLine);
if (!match) { if (!match) {
// Models often add an indented detail list under an observation. Keep
// that detail on the entry above instead of rejecting the response.
const previous = entries.at(-1);
if (previous && /^\s/.test(rawLine) && !NON_DETAIL_PATTERN.test(trimmed)) {
previous.text = appendObservationDetail(previous.text, trimmed);
continue;
}
skippedLines.push(rawLine); skippedLines.push(rawLine);
continue; continue;
} }
@ -141,6 +154,12 @@ export function parseObservationLogMarkdown(markdown: string): ParseObservationL
return { entries, skippedLines }; return { entries, skippedLines };
} }
/** Joins a detail line onto one line, so the rendered log keeps one entry per line. */
function appendObservationDetail(text: string, detailLine: string): string {
const detail = detailLine.replace(/^[*-]\s+/, '');
return /[:;.!?]$/.test(text) ? `${text} ${detail}` : `${text}; ${detail}`;
}
export function renderObserverTranscript( export function renderObserverTranscript(
messages: AgentDbMessage[], messages: AgentDbMessage[],
options: RenderObserverTranscriptOptions = {}, options: RenderObserverTranscriptOptions = {},

View File

@ -1,6 +1,6 @@
{ {
"name": "@n8n/ai-node-sdk", "name": "@n8n/ai-node-sdk",
"version": "0.34.0", "version": "0.34.4",
"description": "SDK for building AI nodes in n8n", "description": "SDK for building AI nodes in n8n",
"types": "dist/esm/index.d.ts", "types": "dist/esm/index.d.ts",
"module": "dist/esm/index.js", "module": "dist/esm/index.js",

View File

@ -1,6 +1,6 @@
{ {
"name": "@n8n/ai-utilities", "name": "@n8n/ai-utilities",
"version": "0.37.0", "version": "0.37.4",
"description": "Utilities for building AI nodes in n8n", "description": "Utilities for building AI nodes in n8n",
"types": "dist/esm/index.d.ts", "types": "dist/esm/index.d.ts",
"module": "dist/esm/index.js", "module": "dist/esm/index.js",

View File

@ -153,6 +153,88 @@ describe('model-discovery', () => {
{ id: 'models/gemini-2.5-pro', name: 'models/gemini-2.5-pro' }, { id: 'models/gemini-2.5-pro', name: 'models/gemini-2.5-pro' },
]); ]);
}); });
it("labels models with the API's displayName, else the id", async () => {
const fetch = mockFetch({
models: [
{
name: 'models/gemini-2.5-pro',
displayName: 'Gemini 2.5 Pro',
supportedGenerationMethods: ['generateContent'],
},
{
name: 'models/gemini-2.5-flash',
displayName: ' ',
supportedGenerationMethods: ['generateContent'],
},
],
});
const models = await listModelsForProvider('google', { apiKey: 'g-key', fetch });
expect(models).toEqual([
{ id: 'models/gemini-2.5-pro', name: 'Gemini 2.5 Pro' },
{ id: 'models/gemini-2.5-flash', name: 'models/gemini-2.5-flash' },
]);
});
it('lists models with a priority first and passes the priority on', async () => {
const fetch = mockFetch({
models: [
{ name: 'models/gemini-2.5-pro', supportedGenerationMethods: ['generateContent'] },
{
name: 'models/gemini-featured',
priority: 10,
supportedGenerationMethods: ['generateContent'],
},
{ name: 'models/gemini-2.5-flash', supportedGenerationMethods: ['generateContent'] },
],
});
const models = await listModelsForProvider('google', { apiKey: 'g-key', fetch });
expect(models).toEqual([
{ id: 'models/gemini-featured', name: 'models/gemini-featured', priority: 10 },
{ id: 'models/gemini-2.5-flash', name: 'models/gemini-2.5-flash' },
{ id: 'models/gemini-2.5-pro', name: 'models/gemini-2.5-pro' },
]);
});
});
describe('google empty response', () => {
it('returns no models when the response has no models list', async () => {
const fetch = mockFetch({});
await expect(listModelsForProvider('google', { apiKey: 'g-key', fetch })).resolves.toEqual(
[],
);
});
});
describe('google priority', () => {
it('drops a priority that is not a finite number', async () => {
const fetch = mockFetch({
models: [
{
name: 'models/gemini-2.5-pro',
priority: Number.POSITIVE_INFINITY,
supportedGenerationMethods: ['generateContent'],
},
{
name: 'models/gemini-2.5-flash',
priority: '5',
supportedGenerationMethods: ['generateContent'],
},
],
});
const models = await listModelsForProvider('google', { apiKey: 'g-key', fetch });
expect(models).toEqual([
{ id: 'models/gemini-2.5-flash', name: 'models/gemini-2.5-flash' },
{ id: 'models/gemini-2.5-pro', name: 'models/gemini-2.5-pro' },
]);
});
}); });
describe('groq', () => { describe('groq', () => {

View File

@ -70,7 +70,14 @@ export {
} from './providers/azure'; } from './providers/azure';
export { listCohereModels } from './providers/cohere'; export { listCohereModels } from './providers/cohere';
export { listDeepSeekModels } from './providers/deepseek'; export { listDeepSeekModels } from './providers/deepseek';
export { listGoogleModels, shouldIncludeGoogleModel } from './providers/google'; export {
getGoogleModelLabel,
listGoogleModels,
parseGoogleModels,
shouldIncludeGoogleModel,
type GoogleModel,
} from './providers/google';
export { pinPrioritizedModels } from '@n8n/api-types';
export { listGroqModels } from './providers/groq'; export { listGroqModels } from './providers/groq';
export { listMiniMaxModels } from './providers/minimax'; export { listMiniMaxModels } from './providers/minimax';
export { listMistralModels } from './providers/mistral'; export { listMistralModels } from './providers/mistral';

View File

@ -1,3 +1,6 @@
import { pinPrioritizedModels } from '@n8n/api-types';
import { z } from 'zod';
import { baseUrl, byName, getJson } from '../request'; import { baseUrl, byName, getJson } from '../request';
import type { ListModelsFn } from '../types'; import type { ListModelsFn } from '../types';
@ -33,22 +36,48 @@ export function shouldIncludeGoogleModel(model: {
*/ */
export const listGoogleModels: ListModelsFn = async (options) => { export const listGoogleModels: ListModelsFn = async (options) => {
const base = baseUrl(options, 'https://generativelanguage.googleapis.com'); const base = baseUrl(options, 'https://generativelanguage.googleapis.com');
const data = (await getJson( const data = await getJson(
`${base}/v1beta/models`, `${base}/v1beta/models`,
{ 'x-goog-api-key': options.apiKey }, { 'x-goog-api-key': options.apiKey },
options, options,
'google', 'google',
)) as { models?: Array<{ name?: unknown; supportedGenerationMethods?: unknown }> }; );
return (data.models ?? []) const models = parseGoogleModels(data)
.filter( .filter(shouldIncludeGoogleModel)
(model): model is { name: string; supportedGenerationMethods?: unknown } => .map((model) => ({
typeof model.name === 'string' && id: model.name,
shouldIncludeGoogleModel({ name: getGoogleModelLabel(model.name, model.displayName),
name: model.name, ...(model.priority !== undefined ? { priority: model.priority } : {}),
supportedGenerationMethods: model.supportedGenerationMethods, }))
}),
)
.map((model) => ({ id: model.name, name: model.name }))
.sort(byName); .sort(byName);
return pinPrioritizedModels(models, (model) => model.priority);
}; };
/** A malformed optional field is dropped; a model without a name is skipped. */
const googleModelSchema = z.object({
name: z.string(),
displayName: z.string().optional().catch(undefined),
priority: z.number().finite().optional().catch(undefined),
supportedGenerationMethods: z.array(z.string()).optional().catch(undefined),
});
const googleModelsResponseSchema = z.object({
models: z.array(z.unknown()).optional().catch(undefined),
});
export type GoogleModel = z.infer<typeof googleModelSchema>;
/** Models of a Google `GET /v1beta/models` response. */
export function parseGoogleModels(data: unknown): GoogleModel[] {
const response = googleModelsResponseSchema.safeParse(data);
return (response.success ? (response.data.models ?? []) : []).flatMap((item) => {
const model = googleModelSchema.safeParse(item);
return model.success ? [model.data] : [];
});
}
/** Dropdown label of a Google model: the API's `displayName`, else the id. */
export function getGoogleModelLabel(modelId: string, displayName?: string): string {
return displayName?.trim() || modelId;
}

View File

@ -4,6 +4,8 @@ export interface ProviderModel {
id: string; id: string;
/** Human-readable name when the provider supplies one, otherwise the id. */ /** Human-readable name when the provider supplies one, otherwise the id. */
name: string; name: string;
/** Pickers list models with a priority first, highest first. Only the AI Gateway reports one. */
priority?: number;
} }
export interface ListModelsOptions { export interface ListModelsOptions {

View File

@ -1,6 +1,6 @@
{ {
"name": "@n8n/ai-workflow-builder", "name": "@n8n/ai-workflow-builder",
"version": "1.44.0", "version": "1.44.4",
"scripts": { "scripts": {
"clean": "rimraf dist .turbo", "clean": "rimraf dist .turbo",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",

View File

@ -1,6 +1,6 @@
{ {
"name": "@n8n/api-types", "name": "@n8n/api-types",
"version": "1.44.0", "version": "1.44.3",
"scripts": { "scripts": {
"clean": "rimraf dist .turbo", "clean": "rimraf dist .turbo",
"dev": "pnpm watch", "dev": "pnpm watch",

View File

@ -0,0 +1,81 @@
import { isChatHubToolTypeAllowed } from '../chat-hub';
describe('isChatHubToolTypeAllowed', () => {
it.each(['@n8n/n8n-nodes-langchain.chat', '@n8n/n8n-nodes-langchain.chatHitlTool'])(
'blocks the %s variant of the Chat tool for all users',
(type) => {
expect(isChatHubToolTypeAllowed(type, { isChatUser: false })).toBe(false);
},
);
it.each([
'n8nNodesBaseSlackTool',
'n8n-nodes-base.',
'.slackTool',
'n8n-nodes-base.slackTool.v2',
'n8n-nodes-base.slack.Tool',
'@n8n/n8n-nodes-langchain.toolCode.x',
'@n8n/n8n-nodes-langchain.toolWorkflow.Tool',
])('rejects the malformed node type %s for all users', (type) => {
expect(isChatHubToolTypeAllowed(type, { isChatUser: false })).toBe(false);
expect(isChatHubToolTypeAllowed(type, { isChatUser: true })).toBe(false);
});
it('blocks the Data Table HITL tool for chat users', () => {
expect(isChatHubToolTypeAllowed('n8n-nodes-base.dataTableHitlTool', { isChatUser: true })).toBe(
false,
);
});
it('blocks the Chat tool for all users', () => {
expect(
isChatHubToolTypeAllowed('@n8n/n8n-nodes-langchain.chatTool', { isChatUser: false }),
).toBe(false);
expect(
isChatHubToolTypeAllowed('@n8n/n8n-nodes-langchain.chatTool', { isChatUser: true }),
).toBe(false);
});
it.each([
'@n8n/n8n-nodes-langchain.toolWorkflow',
'n8n-nodes-base.dataTableTool',
'n8n-nodes-base.executeCommand',
'n8n-nodes-base.executeCommandTool',
'n8n-nodes-base.code',
'@n8n/n8n-nodes-langchain.toolCode',
'n8n-nodes-base.git',
'n8n-nodes-base.gitTool',
'n8n-nodes-base.messageAnAgentTool',
'n8n-nodes-base.executeCommandHitlTool',
'n8n-nodes-base.codeHitlTool',
'n8n-nodes-base.readWriteFileHitlTool',
'n8n-nodes-base.executeWorkflowHitlTool',
'n8n-nodes-base.n8nHitlTool',
])('blocks %s for chat users', (type) => {
expect(isChatHubToolTypeAllowed(type, { isChatUser: true })).toBe(false);
});
it.each(['n8n-nodes-base.executeCommandTool', '@n8n/n8n-nodes-langchain.toolCode'])(
'allows %s for users who can build workflows',
(type) => {
expect(isChatHubToolTypeAllowed(type, { isChatUser: false })).toBe(true);
},
);
it.each([
'n8n-nodes-base.slackTool',
'n8n-nodes-base.gmailTool',
'n8n-nodes-base.httpRequestTool',
'@n8n/n8n-nodes-langchain.toolHttpRequest',
'n8n-nodes-base.graphqlTool',
'n8n-nodes-base.rssFeedReadTool',
'@n8n/n8n-nodes-langchain.mcpClientTool',
'n8n-nodes-base.sshTool',
'n8n-nodes-base.ftpHitlTool',
'n8n-nodes-base.jenkinsTool',
'n8n-nodes-base.rundeckTool',
'n8n-nodes-base.awsLambdaTool',
])('allows %s for chat users', (type) => {
expect(isChatHubToolTypeAllowed(type, { isChatUser: true })).toBe(true);
});
});

View File

@ -0,0 +1,37 @@
import { pinPrioritizedModels } from '../model-priority';
describe('pinPrioritizedModels', () => {
it('moves models with a priority to the front, highest first, and keeps the order of the rest', () => {
const models = [
{ id: 'b' },
{ id: 'low', priority: 1 },
{ id: 'a' },
{ id: 'high', priority: 10 },
];
expect(pinPrioritizedModels(models, (model) => model.priority).map((m) => m.id)).toEqual([
'high',
'low',
'b',
'a',
]);
});
it('ignores priorities that are not finite numbers', () => {
const models = [
{ id: 'a', priority: '5' },
{ id: 'b', priority: Number.NaN },
{ id: 'c' },
{ id: 'd', priority: Number.POSITIVE_INFINITY },
{ id: 'valid', priority: 1 },
];
expect(pinPrioritizedModels(models, (model) => model.priority).map((m) => m.id)).toEqual([
'valid',
'a',
'b',
'c',
'd',
]);
});
});

View File

@ -70,6 +70,8 @@ export interface AgentCatalogModel {
toolCall: boolean; toolCall: boolean;
cost?: { input: number; output: number; cacheRead?: number; cacheWrite?: number }; cost?: { input: number; output: number; cacheRead?: number; cacheWrite?: number };
limits?: { context?: number; output?: number }; limits?: { context?: number; output?: number };
/** Pickers list models with a priority first, highest first. Reported by the models API. */
priority?: number;
} }
/** Response of `GET /agents/v2/catalog/models/:provider`. */ /** Response of `GET /agents/v2/catalog/models/:provider`. */

View File

@ -420,7 +420,7 @@ export interface AgentChatMessagesResponse {
/** /**
* One agent as the n8n chat page lists it. * One agent as the n8n chat page lists it.
* *
* Deliberately narrow. Its audience holds `agent:execute` alone — a * Deliberately narrow. Its audience can hold `agent:execute` alone — a
* `project:chatUser` can start a production chat but has no `agent:read` — so * `project:chatUser` can start a production chat but has no `agent:read` — so
* the published instructions, tools and skills must not travel with the list. * the published instructions, tools and skills must not travel with the list.
* The page needs a label, an avatar, the owning project, and a user-facing * The page needs a label, an avatar, the owning project, and a user-facing
@ -436,6 +436,11 @@ export interface AgentChatListItem {
project: { id: string; name: string }; project: { id: string; name: string };
/** Media types this agent's model accepts as attachments, derived from its published config. */ /** Media types this agent's model accepts as attachments, derived from its published config. */
attachments: ProviderAttachmentCapabilities; attachments: ProviderAttachmentCapabilities;
/**
* `false` for an agent the user owns that is not published to n8n Chat yet.
* Its icon, blurb and attachments then come from the draft config.
*/
availableInChat: boolean;
} }
export interface AgentChatListResponse { export interface AgentChatListResponse {

View File

@ -2,7 +2,10 @@ import type { Scope } from '@n8n/permissions';
import { import {
CHAT_TOOL_NODE_TYPE, CHAT_TOOL_NODE_TYPE,
type ChunkType, type ChunkType,
CODE_NODE_TYPE,
CODE_TOOL_LANGCHAIN_NODE_TYPE,
DATA_TABLE_TOOL_NODE_TYPE, DATA_TABLE_TOOL_NODE_TYPE,
EXECUTE_WORKFLOW_NODE_TYPE,
type INode, type INode,
INodeSchema, INodeSchema,
WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE, WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE,
@ -709,12 +712,52 @@ export interface ChatHubToolDto {
/** Tool types blocked for ALL users in the Chat Hub. */ /** Tool types blocked for ALL users in the Chat Hub. */
export const ALWAYS_BLOCKED_CHAT_HUB_TOOL_TYPES: string[] = [CHAT_TOOL_NODE_TYPE]; export const ALWAYS_BLOCKED_CHAT_HUB_TOOL_TYPES: string[] = [CHAT_TOOL_NODE_TYPE];
/** Additional tool types blocked for chat-only users (global:chatUser). */ /**
* Additional tool types blocked for chat-only users (global:chatUser).
* These tools run code or commands on the n8n instance, or access its files,
* workflows, data tables or agents. Tools that act on a remote system through
* a credential stay allowed: the credential limits what they can do.
* Chat users cannot build workflows, so they cannot add these tools either.
* A `*Tool` or `*HitlTool` variant of a listed node type is blocked too.
* Any node can have a `*HitlTool` variant, so the list also holds node types
* that are not usable as tools.
*/
export const CHAT_USER_BLOCKED_CHAT_HUB_TOOL_TYPES: string[] = [ export const CHAT_USER_BLOCKED_CHAT_HUB_TOOL_TYPES: string[] = [
WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE, WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE,
DATA_TABLE_TOOL_NODE_TYPE, DATA_TABLE_TOOL_NODE_TYPE,
CODE_NODE_TYPE,
CODE_TOOL_LANGCHAIN_NODE_TYPE,
EXECUTE_WORKFLOW_NODE_TYPE,
'n8n-nodes-base.executeCommand',
'n8n-nodes-base.messageAnAgent',
'n8n-nodes-base.git',
'n8n-nodes-base.readWriteFile',
'n8n-nodes-base.n8n',
]; ];
/** Matches the node type registry, which resolves both suffixes to the base node type. */
function withoutToolSuffix(type: string): string {
return type.replace(/HitlTool$/, '').replace(/Tool$/, '');
}
/** Compares base node types, so an entry also covers the base, `*Tool` and `*HitlTool` forms. */
function isListed(list: string[], type: string): boolean {
const baseType = withoutToolSuffix(type);
return list.some((entry) => withoutToolSuffix(entry) === baseType);
}
/** A node type is a package name and a node name, joined by a single dot. */
function isWellFormedNodeType(type: string): boolean {
return /^[^.]+\.[^.]+$/.test(type);
}
/** Returns whether a user can add or run a tool of this type in the Chat Hub. */
export function isChatHubToolTypeAllowed(type: string, user: { isChatUser: boolean }): boolean {
if (!isWellFormedNodeType(type)) return false;
if (isListed(ALWAYS_BLOCKED_CHAT_HUB_TOOL_TYPES, type)) return false;
return !(user.isChatUser && isListed(CHAT_USER_BLOCKED_CHAT_HUB_TOOL_TYPES, type));
}
/** /**
* Request schema for creating a chat hub tool * Request schema for creating a chat hub tool
*/ */

View File

@ -245,6 +245,11 @@ export { GetWorkflowQueryDto } from './workflows/get-workflow-query.dto';
export { UpdateWorkflowPublicDto } from './workflows/update-workflow-public.dto'; export { UpdateWorkflowPublicDto } from './workflows/update-workflow-public.dto';
export { UpdateWorkflowQueryDto } from './workflows/update-workflow-query.dto'; export { UpdateWorkflowQueryDto } from './workflows/update-workflow-query.dto';
export { ListWorkflowsQueryDto } from './workflows/list-workflows-query.dto'; export { ListWorkflowsQueryDto } from './workflows/list-workflows-query.dto';
export {
ListCallableSubworkflowsQueryDto,
type CallableSubworkflow,
type CallableSubworkflowsResponse,
} from './workflows/list-callable-subworkflows-query.dto';
export { export {
WorkflowPublicDto, WorkflowPublicDto,
CreatedWorkflowPublicDto, CreatedWorkflowPublicDto,

View File

@ -212,6 +212,11 @@ describe('CreateWorkflowDto', () => {
request: { name: 'a'.repeat(129), nodes: [], connections: {} }, request: { name: 'a'.repeat(129), nodes: [], connections: {} },
expectedErrorPath: ['name'], expectedErrorPath: ['name'],
}, },
...['__proto__', 'constructor', 'prototype'].map((id) => ({
name: `reserved id ${id}`,
request: { id, name: 'My Workflow', nodes: [], connections: {} },
expectedErrorPath: ['id'],
})),
{ {
name: 'missing nodes', name: 'missing nodes',
request: { name: 'Test', connections: {} }, request: { name: 'Test', connections: {} },

View File

@ -1,9 +1,13 @@
import { z } from 'zod'; import { z } from 'zod';
import { baseWorkflowShape } from './base-workflow.dto'; import { baseWorkflowShape } from './base-workflow.dto';
import { isSafeObjectKey } from '../../schemas/instance-ai.schema';
import { Z } from '../../zod-class'; import { Z } from '../../zod-class';
export const workflowIdSchema = z.string(); // Workflow ids are used as object keys, so `__proto__`, `constructor` and `prototype` are not allowed
export const workflowIdSchema = z
.string()
.refine(isSafeObjectKey, { message: 'Invalid workflow id' });
export class CreateWorkflowDto extends Z.class({ export class CreateWorkflowDto extends Z.class({
// Spread base fields (name, nodes, connections, settings, etc.) // Spread base fields (name, nodes, connections, settings, etc.)

View File

@ -0,0 +1,28 @@
import { z } from 'zod';
import { Z } from '../../zod-class';
import { paginationSchema } from '../pagination/pagination.dto';
/**
* Query for the list of workflows that a parent workflow can call as
* sub-workflows. Only name search and pagination are accepted, so callers
* cannot probe workflow contents.
*/
export class ListCallableSubworkflowsQueryDto extends Z.class({
...paginationSchema,
query: z.string().trim().max(256).optional(),
}) {}
export type CallableSubworkflow = {
id: string;
name: string;
active: boolean;
isArchived: boolean;
/** Set only when the workflow belongs to a team project. */
projectName?: string;
};
export type CallableSubworkflowsResponse = {
count: number;
data: CallableSubworkflow[];
};

View File

@ -96,6 +96,7 @@ export {
ChatHubUpdateToolRequest, ChatHubUpdateToolRequest,
ALWAYS_BLOCKED_CHAT_HUB_TOOL_TYPES, ALWAYS_BLOCKED_CHAT_HUB_TOOL_TYPES,
CHAT_USER_BLOCKED_CHAT_HUB_TOOL_TYPES, CHAT_USER_BLOCKED_CHAT_HUB_TOOL_TYPES,
isChatHubToolTypeAllowed,
chatHubVectorStoreProviderSchema, chatHubVectorStoreProviderSchema,
type ChatHubVectorStoreProvider, type ChatHubVectorStoreProvider,
VECTOR_STORE_PROVIDER_CREDENTIAL_TYPE_MAP, VECTOR_STORE_PROVIDER_CREDENTIAL_TYPE_MAP,
@ -966,3 +967,4 @@ export { compareExecutionListItems } from './dto/executions/compare-execution-li
export type * from './workflow-suggestions'; export type * from './workflow-suggestions';
export * from './self-healing-results'; export * from './self-healing-results';
export * from './model-priority';

View File

@ -0,0 +1,23 @@
/**
* Moves models that have a priority to the front, highest first, and keeps the
* order of the rest. A models API reports a priority to promote a model, for
* example at its launch.
*/
export function pinPrioritizedModels<T>(models: T[], getPriority: (model: T) => unknown): T[] {
const entries = models.map((model) => {
const priority = getPriority(model);
return {
model,
priority: typeof priority === 'number' && Number.isFinite(priority) ? priority : null,
};
});
const prioritized = entries
.flatMap((entry) =>
entry.priority === null ? [] : [{ model: entry.model, priority: entry.priority }],
)
.sort((a, b) => b.priority - a.priority);
return [
...prioritized.map((entry) => entry.model),
...entries.filter((entry) => entry.priority === null).map((entry) => entry.model),
];
}

View File

@ -1,6 +1,6 @@
{ {
"name": "@n8n/backend-common", "name": "@n8n/backend-common",
"version": "1.44.0", "version": "1.44.4",
"scripts": { "scripts": {
"clean": "rimraf dist .turbo", "clean": "rimraf dist .turbo",
"dev": "pnpm watch", "dev": "pnpm watch",

View File

@ -1,6 +1,6 @@
{ {
"name": "@n8n/backend-network", "name": "@n8n/backend-network",
"version": "1.18.0", "version": "1.18.4",
"scripts": { "scripts": {
"clean": "rimraf dist .turbo", "clean": "rimraf dist .turbo",
"dev": "pnpm watch", "dev": "pnpm watch",

View File

@ -2,11 +2,13 @@ import { IncomingMessage } from 'http';
import { Readable } from 'stream'; import { Readable } from 'stream';
import { binaryToString } from '../binary-string'; import { binaryToString } from '../binary-string';
import { Socket } from 'net';
const bufferToIncomingMessage = (buffer: Buffer, encoding = 'utf-8') => { const bufferToIncomingMessage = (buffer: Buffer, encoding = 'utf-8') => {
const incomingMessage = Readable.from(buffer) as IncomingMessage; const incomingMessage = new IncomingMessage(new Socket());
incomingMessage.headers = { 'content-type': `application/json;charset=${encoding}` }; incomingMessage.headers = { 'content-type': `application/json;charset=${encoding}` };
Object.setPrototypeOf(incomingMessage, IncomingMessage.prototype); incomingMessage.push(buffer);
incomingMessage.push(null);
return incomingMessage; return incomingMessage;
}; };

View File

@ -1,8 +1,8 @@
import type { HttpsProxyAgent } from 'https-proxy-agent'; import type { HttpsProxyAgent } from 'https-proxy-agent';
import type http from 'node:http'; import http from 'node:http';
import { makeSsrfBridge } from '../../ssrf/__tests__/mock-ssrf-bridge'; import { makeSsrfBridge } from '../../ssrf/__tests__/mock-ssrf-bridge';
import { installProxyConnectionGuard } from '../connection-guards'; import { installConnectionGuard, installProxyConnectionGuard } from '../connection-guards';
type ConnectOpts = Parameters<HttpsProxyAgent<string>['connect']>[1]; type ConnectOpts = Parameters<HttpsProxyAgent<string>['connect']>[1];
@ -76,3 +76,78 @@ describe('installProxyConnectionGuard', () => {
expect(bridge.validateConnectionHost).not.toHaveBeenCalled(); expect(bridge.validateConnectionHost).not.toHaveBeenCalled();
}); });
}); });
describe('installConnectionGuard', () => {
function guarded() {
const createConnection = vi.fn().mockReturnValue('SOCKET');
const target = { createConnection };
return { target, createConnection };
}
it('validates the target host and delegates when the policy allows it', () => {
const bridge = makeSsrfBridge();
const { target, createConnection } = guarded();
installConnectionGuard(target, bridge);
expect(target.createConnection({ host: 'target.example.com' })).toBe('SOCKET');
expect(bridge.validateConnectionHost).toHaveBeenCalledWith('target.example.com');
expect(createConnection).toHaveBeenCalled();
});
it('rejects a connection to a socket path without validating the unused host', () => {
const bridge = makeSsrfBridge();
const { target, createConnection } = guarded();
installConnectionGuard(target, bridge);
expect(() =>
target.createConnection({ host: 'target.example.com', socketPath: '/var/run/docker.sock' }),
).toThrow('Cannot open a connection to a socket path');
expect(createConnection).not.toHaveBeenCalled();
expect(bridge.validateConnectionHost).not.toHaveBeenCalled();
});
it('reports a socket path through the callback when the caller passes one', () => {
const bridge = makeSsrfBridge();
const { target, createConnection } = guarded();
installConnectionGuard(target, bridge);
const onConnect = vi.fn();
expect(
target.createConnection({ socketPath: '/var/run/docker.sock' }, onConnect),
).toBeUndefined();
expect(onConnect).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Cannot open a connection to a socket path' }),
);
expect(createConnection).not.toHaveBeenCalled();
});
});
describe('installConnectionGuard on a real http.Agent', () => {
async function get(agent: http.Agent): Promise<string> {
return await new Promise((resolve, reject) => {
const req = http.get('http://target.example.com/x', { agent, timeout: 3000 }, (res) => {
let data = '';
res.on('data', (chunk) => (data += String(chunk)));
res.on('end', () => resolve(data));
});
req.on('error', reject);
req.on('timeout', () => {
req.destroy();
reject(new Error('timeout'));
});
});
}
it.each([
['ssrf active', () => makeSsrfBridge()],
['ssrf disabled', () => 'disabled' as const],
])('refuses a socket path Node puts in the connect options (%s)', async (_label, ssrf) => {
const agent = new http.Agent({ socketPath: '/tmp/backend-network.sock' } as http.AgentOptions);
installConnectionGuard(agent as unknown as { createConnection: never }, ssrf());
await expect(get(agent)).rejects.toThrow('Cannot open a connection to a socket path');
});
});

View File

@ -15,7 +15,7 @@ const { getProxyForUrl } = vi.hoisted(() => ({
vi.mock('proxy-from-env', () => ({ getProxyForUrl })); vi.mock('proxy-from-env', () => ({ getProxyForUrl }));
const options = (o: Partial<http.RequestOptions>): http.RequestOptions => o as http.RequestOptions; const options = (o: Partial<http.RequestOptions>): http.RequestOptions => o as http.RequestOptions;
const createAgent = () => vi.fn((proxyUrl: string) => ({ proxyUrl })); const createAgent = () => vi.fn((proxyUrl: string) => ({ proxyUrl, destroy: vi.fn() }));
describe('EnvProxyRouter', () => { describe('EnvProxyRouter', () => {
beforeEach(() => getProxyForUrl.mockReset()); beforeEach(() => getProxyForUrl.mockReset());
@ -110,4 +110,17 @@ describe('EnvProxyRouter', () => {
expect(createProxyAgent).toHaveBeenCalledTimes(2); expect(createProxyAgent).toHaveBeenCalledTimes(2);
expect(a).not.toBe(b); expect(a).not.toBe(b);
}); });
it('destroys cached proxy agents and creates a new one after destroy', () => {
getProxyForUrl.mockReturnValue('http://proxy.internal:3128');
const createProxyAgent = createAgent();
const router = new EnvProxyRouter('http', 80, createProxyAgent);
const first = router.resolve(options({ host: 'a.example' }));
router.destroy();
const second = router.resolve(options({ host: 'a.example' }));
expect(first?.destroy).toHaveBeenCalledTimes(1);
expect(second).not.toBe(first);
});
}); });

View File

@ -0,0 +1,17 @@
-----BEGIN CERTIFICATE-----
MIICwDCCAaigAwIBAgIJAIe0kAmSG0RGMA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNV
BAMMCTEyNy4wLjAuMTAgFw0yNjEwMTYxMDQzMjRaGA8yMTI2MDkyMjEwNDMyNFow
FDESMBAGA1UEAwwJMTI3LjAuMC4xMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB
CgKCAQEAray8R7pr1LGPofrgxuawPyJ/kvJrBY452nQxVXP/0e69hmnJskO2Xab+
cADx1+Xv9pRmSPLL5E4ZmwebHPCDE1PHd5tQo8u+I1h/mLsrxc3aeehnQGxpDi9p
RjOdM00j1gBuZIJoC/scJEkOdFz+7aHmNFIZzf9OgqJA9CyFzUrFB/i7PyyILkUs
pIZta0iMZa2hytpFeHGB9FAZypU/XaBZQHbRc8IU3R9CL8ppGBayHMvdGNiEFK1U
5oPEk5de7S9UN/trK7nypqaZynP+hkcr6PywTswPnAJ5xBX1XG/IQplZEBrNcE1B
aHbKeC187qEJEf4xaUHkHdFGvokF3QIDAQABoxMwETAPBgNVHREECDAGhwR/AAAB
MA0GCSqGSIb3DQEBCwUAA4IBAQBKRUtiIhIaIxaOFUrCZLCM6XVypKwEb0IzU3rC
fspk9L7sFH+U1rv1lOy0aGDr7CDf+NJhY2s7p7vbyWP4xo4mDI99R/W9VF1SXOUs
nVao0Ial+N+/7BCwCZRcdPAnv7roUr6Dt7RYRTG5zdUfu079dWkm7RcicmerU4uD
2asNeHPuunkGFu8FyrT67S03bYjRUppsqxDnKjtFTavA578Ky2Gk2jzpCKL8wmM9
hcVGA90vnGPXekmMWvE0okJlEA5z+nLDPXqeT11ycwdcIsS/d7ll0bZSVZrmRLa0
TYR9TXIlbzgyTe0oSRSHruTyn5rPEero7lAWi32SLkk8KSX+
-----END CERTIFICATE-----

View File

@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCtrLxHumvUsY+h
+uDG5rA/In+S8msFjjnadDFVc//R7r2GacmyQ7Zdpv5wAPHX5e/2lGZI8svkThmb
B5sc8IMTU8d3m1Cjy74jWH+YuyvFzdp56GdAbGkOL2lGM50zTSPWAG5kgmgL+xwk
SQ50XP7toeY0UhnN/06CokD0LIXNSsUH+Ls/LIguRSykhm1rSIxlraHK2kV4cYH0
UBnKlT9doFlAdtFzwhTdH0IvymkYFrIcy90Y2IQUrVTmg8STl17tL1Q3+2srufKm
ppnKc/6GRyvo/LBOzA+cAnnEFfVcb8hCmVkQGs1wTUFodsp4LXzuoQkR/jFpQeQd
0Ua+iQXdAgMBAAECggEAbFHfAHGyDW9NYT6i8Qsyg74ViWYu/8EMLkbn+h8mQB5C
ENJsHQYEePbNnLfKHtjnyIgYH4lnR5a6e6nLwcn0RpqrPtsc+Xv6s/rfYjfi/jT8
/anItDKpd1J0u3c3paaIIlIUiBWZpVdR3wO9wy+5RCcE+D74nzWMFwfxsRkPvtDL
eJPCjWwzqTV/YI0pmlUqwrJiezEs6B96bFIv1r0CC2/4XY5OCQ3QuYc9TFth+7mq
jtJw5XKRH7Zh3COdM6BnsCtLrtFVqIC8dFIghcNd36ypPIQf9WAKnCSZjEGSfn8w
yzKKJj6q966iTwja0ckZGoX6UP790gzB1hAYBgRIFQKBgQDUdTpzioC7MSNKthJL
/Cr2dCXvIzvKo4QHQYEIJNc4Kop3e4mj6VqPlOHJd31Fdec3Sfl6vVLwLTpe1GD6
HhNnzjFJ3WvoIshH7XzVmo+YVCTMPi5W8qHIHbQJyxZZJJyCiAZh9ZVq+PQEODFj
gyGZyAwuUUF04N8uhxsxS0LlwwKBgQDRRLeox/M8Z6o5bNs38+LjaU1+pkS2Ly5p
HpdaMTOPlgc4skPKm6wiFjFXWmvGkJHh2MWL/QQ7EwLk0bye1fSg3qcV/vbSwfTs
haCoZhr6K+FlSk9OtbUMcD0HN/p54+DHo8S3YQMgIFh0yYnDgf1Lk3aRUcR9vvbH
oi99z1aL3wKBgAP0erYT6fmSHJoTcZvAgfw8hUhCW6aWDKsl6QUByo/dGt7GifVn
LqxllR5u/wIAA+HfmqlSROz7qo6BXZzKWoogKIK7gHq9ywSrwxsm2W14g4bZ0XsE
4UYcZ3GXOj9GkzNK3PON4s4u/Dvn3+N+WW3Hb5KdKJ7IYN843KS6RfCJAoGBAKO0
oggDAyDJ57wskAFhZKiKA/mDkFUE68WIDNadjrxQnS2ZJrckuK+htM+lCJK5HaYT
sdYlBF5CLQ1blbaboVcHVqY6YlPVLwioMFoMJiZ2lUhrrUbhltQ4KUWwYN1hisIi
Nw1Ncuh65TfXQVIUKlYo8aQU1yWXce4Rzh0ZSZb5AoGBAIHC3nYPZAJXl7SxH4k0
P13Y10j3K71kjuXsvqRmg9WhYV+Y9sSGLvcM5tIkBexz1CCqeJ/9x7B8UR0zNTaO
PumXYr0XW5Dnoesr8oDTch3pRoCqLvt0GWrLzJChpVVWY3qeyxIOjA1O0cLCFMBB
/vW69JqXYMyIHnLMLoyDp8fy
-----END PRIVATE KEY-----

View File

@ -15,7 +15,7 @@ import { installConnectionGuard, UnknownConnectionHostError } from '../connectio
import { EnvProxyHttpAgent } from '../env-proxy-http-agent'; import { EnvProxyHttpAgent } from '../env-proxy-http-agent';
import { EnvProxyHttpsAgent } from '../env-proxy-https-agent'; import { EnvProxyHttpsAgent } from '../env-proxy-https-agent';
import { type LocalServer, startServer } from '../local-server'; import { type LocalServer, startServer } from '../local-server';
import { buildNodeAgents, type ProxyUrl } from '../node-agents'; import { buildNodeAgents, type NodeAgentOptions, type ProxyUrl } from '../node-agents';
// HttpsProxyAgent stores `lookup` in `connectOpts` rather than `options` // HttpsProxyAgent stores `lookup` in `connectOpts` rather than `options`
// (unlike http.Agent and HttpProxyAgent which use `options`). // (unlike http.Agent and HttpProxyAgent which use `options`).
@ -32,6 +32,10 @@ function getAgentOptions(agent: http.Agent | https.Agent): { keepAlive?: boolean
return (agent as unknown as { options?: { keepAlive?: boolean } }).options ?? {}; return (agent as unknown as { options?: { keepAlive?: boolean } }).options ?? {};
} }
function withSocketPath(socketPath: string): NodeAgentOptions {
return { socketPath } as unknown as NodeAgentOptions;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// buildNodeAgents — shared builder (single source of truth for the undici // buildNodeAgents — shared builder (single source of truth for the undici
// factory, the axios transport layer, and the global proxy agents) // factory, the axios transport layer, and the global proxy agents)
@ -111,6 +115,29 @@ describe('buildNodeAgents', () => {
}); });
}); });
describe('rejects a caller-provided socketPath (not a validated connection target)', () => {
const socketPath = '/tmp/backend-network.sock';
it.each([
['ssrf disabled', 'disabled' as const],
['ssrf active', makeSsrfBridge()],
])('throws when agentOptions.socketPath is set (%s)', (_label, ssrf) => {
expect(() => buildNodeAgents(false, ssrf, withSocketPath(socketPath))).toThrow(
'`agentOptions.socketPath` is not supported',
);
});
it.each([
['direct', false as const],
['env proxy', 'env' as const],
['explicit proxy', 'http://proxy.internal:3128' as ProxyUrl],
])('throws on the %s path too', (_label, proxy) => {
expect(() => buildNodeAgents(proxy, makeSsrfBridge(), withSocketPath(socketPath))).toThrow(
'`agentOptions.socketPath` is not supported',
);
});
});
describe('SSRF lookup placement', () => { describe('SSRF lookup placement', () => {
const modes: Array<[string, false | 'env' | ProxyUrl]> = [ const modes: Array<[string, false | 'env' | ProxyUrl]> = [
['proxy: false', false], ['proxy: false', false],

View File

@ -0,0 +1,239 @@
import type { Logger } from '@n8n/backend-common';
import type { SsrfProtectionConfig } from '@n8n/config';
import { readFileSync } from 'node:fs';
import http from 'node:http';
import https from 'node:https';
import type { AddressInfo, Socket } from 'node:net';
import { join } from 'node:path';
import type { IHttpRequestOptions } from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';
import type { SsrfProtectionService } from '../../ssrf';
import { useCleanProxyEnv } from '../../ssrf/__tests__/mock-ssrf-bridge';
import { OutboundHttp } from '../outbound-http';
interface CountingServer {
url: string;
connections: Socket[];
openConnections: () => number;
maxConcurrentRequests: () => number;
close: () => Promise<void>;
}
async function startCountingServer(
server: http.Server | https.Server,
scheme: 'http' | 'https',
responseDelayMs = 0,
): Promise<CountingServer> {
const connections: Socket[] = [];
let active = 0;
let maxActive = 0;
server.on('connection', (socket: Socket) => connections.push(socket));
server.on('request', (_req: http.IncomingMessage, res: http.ServerResponse) => {
active++;
maxActive = Math.max(maxActive, active);
setTimeout(() => {
active--;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ ok: true }));
}, responseDelayMs);
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address() as AddressInfo;
return {
url: `${scheme}://127.0.0.1:${port}`,
connections,
openConnections: () => connections.filter((socket) => !socket.destroyed).length,
maxConcurrentRequests: () => maxActive,
close: async () => {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
},
};
}
const fixture = (name: string) => readFileSync(join(__dirname, 'fixtures', name));
function makeOutboundHttp(): OutboundHttp {
return new OutboundHttp(
mock<SsrfProtectionService>(),
mock<SsrfProtectionConfig>({ enabled: false }),
mock<Logger>(),
);
}
async function sendSequentially(request: () => Promise<unknown>, count: number) {
for (let i = 0; i < count; i++) await request();
}
describe('OutboundHttp.requests with a connection pool', () => {
useCleanProxyEnv();
const servers: CountingServer[] = [];
const start = async (...args: Parameters<typeof startCountingServer>) => {
const server = await startCountingServer(...args);
servers.push(server);
return server;
};
afterEach(async () => {
await Promise.all(servers.splice(0).map(async (server) => await server.close()));
});
it('opens a new connection for each request without a pool', async () => {
const target = await start(http.createServer(), 'http');
const client = makeOutboundHttp().requests({ useDefaultSsrfPolicy: 'unsafe' });
await sendSequentially(async () => await client.request({ url: target.url }), 3);
expect(target.connections).toHaveLength(3);
});
it('reuses one connection for sequential HTTP requests', async () => {
const target = await start(http.createServer(), 'http');
const client = makeOutboundHttp().requests({
useDefaultSsrfPolicy: 'unsafe',
connectionPool: { agentOptions: { keepAlive: true } },
});
await sendSequentially(async () => await client.request({ url: target.url }), 3);
expect(target.connections).toHaveLength(1);
client.close();
});
it('keeps connections alive by default', async () => {
const target = await start(http.createServer(), 'http');
const client = makeOutboundHttp().requests({
useDefaultSsrfPolicy: 'unsafe',
connectionPool: {},
});
await sendSequentially(async () => await client.request({ url: target.url }), 3);
expect(target.connections).toHaveLength(1);
client.close();
});
it('rejects a pool with a fixed servername', () => {
expect(() =>
makeOutboundHttp().requests({
useDefaultSsrfPolicy: 'unsafe',
connectionPool: { agentOptions: { servername: 'example.com' } },
}),
).toThrow('servername');
});
it('reuses one TLS connection for sequential HTTPS requests', async () => {
const target = await start(
https.createServer({
key: fixture('loopback-test-key.pem'),
cert: fixture('loopback-test-cert.pem'),
}),
'https',
);
const client = makeOutboundHttp().requests({
useDefaultSsrfPolicy: 'unsafe',
connectionPool: { agentOptions: { keepAlive: true }, skipSslCertificateValidation: true },
});
await sendSequentially(async () => await client.request({ url: target.url }), 3);
expect(target.connections).toHaveLength(1);
client.close();
});
it('limits concurrent connections to maxSockets', async () => {
const target = await start(http.createServer(), 'http', 20);
const client = makeOutboundHttp().requests({
useDefaultSsrfPolicy: 'unsafe',
connectionPool: { agentOptions: { keepAlive: true, maxSockets: 1 } },
});
await Promise.all(
Array.from({ length: 5 }, async () => await client.request({ url: target.url })),
);
expect(target.connections).toHaveLength(1);
expect(target.maxConcurrentRequests()).toBe(1);
client.close();
});
it('closes idle connections after the agent timeout', async () => {
const target = await start(http.createServer(), 'http');
const client = makeOutboundHttp().requests({
useDefaultSsrfPolicy: 'unsafe',
connectionPool: { agentOptions: { keepAlive: true, timeout: 100 } },
});
await client.request({ url: target.url });
await vi.waitFor(() => expect(target.openConnections()).toBe(0), { timeout: 2000 });
client.close();
});
it('closes pooled connections on close()', async () => {
const target = await start(http.createServer(), 'http');
const client = makeOutboundHttp().requests({
useDefaultSsrfPolicy: 'unsafe',
connectionPool: { agentOptions: { keepAlive: true } },
});
await client.request({ url: target.url });
expect(target.openConnections()).toBe(1);
client.close();
await vi.waitFor(() => expect(target.openConnections()).toBe(0), { timeout: 2000 });
});
it('reuses one connection to an explicit proxy', async () => {
const proxy = await start(http.createServer(), 'http');
const client = makeOutboundHttp().requests({
useDefaultSsrfPolicy: 'unsafe',
connectionPool: {
proxy: { protocol: 'http', host: '127.0.0.1', port: Number(new URL(proxy.url).port) },
agentOptions: { keepAlive: true },
},
});
await sendSequentially(async () => await client.request({ url: 'http://target.invalid/x' }), 3);
expect(proxy.connections).toHaveLength(1);
client.close();
});
it('reuses one connection to an environment proxy and closes it on close()', async () => {
const proxy = await start(http.createServer(), 'http');
process.env.HTTP_PROXY = proxy.url;
const client = makeOutboundHttp().requests({
useDefaultSsrfPolicy: 'unsafe',
connectionPool: { agentOptions: { keepAlive: true } },
});
await sendSequentially(async () => await client.request({ url: 'http://target.invalid/x' }), 3);
expect(proxy.connections).toHaveLength(1);
expect(proxy.openConnections()).toBe(1);
client.close();
await vi.waitFor(() => expect(proxy.openConnections()).toBe(0), { timeout: 2000 });
});
it.each<Partial<IHttpRequestOptions>>([
{ proxy: { host: '127.0.0.1', port: 1 } },
{ agentOptions: { keepAlive: false } },
{ skipSslCertificateValidation: true },
])('rejects a request that sets a pool setting itself: %o', async (requestSettings) => {
const client = makeOutboundHttp().requests({
useDefaultSsrfPolicy: 'unsafe',
connectionPool: { agentOptions: { keepAlive: true } },
});
await expect(client.request({ url: 'http://127.0.0.1:1', ...requestSettings })).rejects.toThrow(
'does not accept',
);
client.close();
});
});

View File

@ -37,7 +37,7 @@ describe('OutboundHttp.requests', () => {
expect(res).toEqual({ statusCode: 200, body: 'ok' }); expect(res).toEqual({ statusCode: 200, body: 'ok' });
expect(mockedHttpRequest).toHaveBeenCalledTimes(1); expect(mockedHttpRequest).toHaveBeenCalledTimes(1);
expect(mockedHttpRequest).toHaveBeenCalledWith(REQUEST, expect.anything()); expect(mockedHttpRequest).toHaveBeenCalledWith(REQUEST, expect.anything(), undefined);
}); });
it('uses the container SSRF service as the bridge by default', async () => { it('uses the container SSRF service as the bridge by default', async () => {
@ -46,7 +46,7 @@ describe('OutboundHttp.requests', () => {
await client.request(REQUEST); await client.request(REQUEST);
expect(mockedHttpRequest).toHaveBeenCalledWith(REQUEST, service); expect(mockedHttpRequest).toHaveBeenCalledWith(REQUEST, service, undefined);
}); });
it('omits the bridge when the instance disables SSRF protection', async () => { it('omits the bridge when the instance disables SSRF protection', async () => {
@ -54,7 +54,7 @@ describe('OutboundHttp.requests', () => {
await client.request(REQUEST); await client.request(REQUEST);
expect(mockedHttpRequest).toHaveBeenCalledWith(REQUEST, undefined); expect(mockedHttpRequest).toHaveBeenCalledWith(REQUEST, undefined, undefined);
}); });
it('omits the bridge when SSRF protection is disabled', async () => { it('omits the bridge when SSRF protection is disabled', async () => {
@ -62,7 +62,7 @@ describe('OutboundHttp.requests', () => {
await client.request(REQUEST); await client.request(REQUEST);
expect(mockedHttpRequest).toHaveBeenCalledWith(REQUEST, undefined); expect(mockedHttpRequest).toHaveBeenCalledWith(REQUEST, undefined, undefined);
}); });
describe('bound baseURL', () => { describe('bound baseURL', () => {
@ -77,6 +77,7 @@ describe('OutboundHttp.requests', () => {
baseURL: 'https://vault.test/v1/', baseURL: 'https://vault.test/v1/',
}), }),
expect.anything(), expect.anything(),
undefined,
); );
}); });
@ -88,6 +89,7 @@ describe('OutboundHttp.requests', () => {
expect(mockedHttpRequest).toHaveBeenCalledWith( expect(mockedHttpRequest).toHaveBeenCalledWith(
expect.objectContaining({ baseURL: 'https://other.test/' }), expect.objectContaining({ baseURL: 'https://other.test/' }),
expect.anything(), expect.anything(),
undefined,
); );
}); });
}); });
@ -101,6 +103,7 @@ describe('OutboundHttp.requests', () => {
expect(mockedHttpRequest).toHaveBeenCalledWith( expect(mockedHttpRequest).toHaveBeenCalledWith(
expect.objectContaining({ timeout: 5000 }), expect.objectContaining({ timeout: 5000 }),
expect.anything(), expect.anything(),
undefined,
); );
}); });
@ -112,6 +115,7 @@ describe('OutboundHttp.requests', () => {
expect(mockedHttpRequest).toHaveBeenCalledWith( expect(mockedHttpRequest).toHaveBeenCalledWith(
expect.objectContaining({ timeout: 1000 }), expect.objectContaining({ timeout: 1000 }),
expect.anything(), expect.anything(),
undefined,
); );
}); });
}); });
@ -129,6 +133,7 @@ describe('OutboundHttp.requests', () => {
headers: { 'X-Vault-Namespace': 'admin', Accept: 'text/plain' }, headers: { 'X-Vault-Namespace': 'admin', Accept: 'text/plain' },
}), }),
expect.anything(), expect.anything(),
undefined,
); );
}); });
@ -142,6 +147,7 @@ describe('OutboundHttp.requests', () => {
expect(mockedHttpRequest).toHaveBeenCalledWith( expect(mockedHttpRequest).toHaveBeenCalledWith(
expect.objectContaining({ headers: { 'content-type': 'text/plain' } }), expect.objectContaining({ headers: { 'content-type': 'text/plain' } }),
expect.anything(), expect.anything(),
undefined,
); );
}); });
@ -159,11 +165,13 @@ describe('OutboundHttp.requests', () => {
1, 1,
expect.objectContaining({ headers: { Authorization: 'Bearer first' } }), expect.objectContaining({ headers: { Authorization: 'Bearer first' } }),
expect.anything(), expect.anything(),
undefined,
); );
expect(mockedHttpRequest).toHaveBeenNthCalledWith( expect(mockedHttpRequest).toHaveBeenNthCalledWith(
2, 2,
expect.objectContaining({ headers: { Authorization: 'Bearer second' } }), expect.objectContaining({ headers: { Authorization: 'Bearer second' } }),
expect.anything(), expect.anything(),
undefined,
); );
}); });
@ -175,6 +183,7 @@ describe('OutboundHttp.requests', () => {
expect(mockedHttpRequest).toHaveBeenCalledWith( expect(mockedHttpRequest).toHaveBeenCalledWith(
expect.objectContaining({ headers: {} }), expect.objectContaining({ headers: {} }),
expect.anything(), expect.anything(),
undefined,
); );
}); });
}); });

View File

@ -24,10 +24,12 @@ import {
validateUrlSsrf, validateUrlSsrf,
} from './utils'; } from './utils';
import type { SsrfBridge } from '../../ssrf'; import type { SsrfBridge } from '../../ssrf';
import type { NodeAgents } from '../node-agents';
export function convertN8nRequestToAxios( export function convertN8nRequestToAxios(
n8nRequest: IHttpRequestOptions, n8nRequest: IHttpRequestOptions,
ssrfBridge?: SsrfBridge, ssrfBridge?: SsrfBridge,
agents?: NodeAgents,
): AxiosRequestConfig { ): AxiosRequestConfig {
// Destructure properties with the same name first. // Destructure properties with the same name first.
const { headers, method, timeout, auth, proxy, url } = n8nRequest; const { headers, method, timeout, auth, proxy, url } = n8nRequest;
@ -62,6 +64,11 @@ export function convertN8nRequestToAxios(
axiosRequest.responseType = n8nRequest.encoding; axiosRequest.responseType = n8nRequest.encoding;
} }
if (agents) {
axiosRequest.httpAgent = agents.httpAgent;
axiosRequest.httpsAgent = agents.httpsAgent;
}
const agentOptions = buildAgentOptions(n8nRequest); const agentOptions = buildAgentOptions(n8nRequest);
setAxiosAgents(axiosRequest, agentOptions, proxy, ssrfBridge ?? 'disabled'); setAxiosAgents(axiosRequest, agentOptions, proxy, ssrfBridge ?? 'disabled');
@ -172,18 +179,22 @@ export function removeEmptyBody(requestOptions: IHttpRequestOptions | IRequestOp
export async function httpRequest( export async function httpRequest(
requestOptions: IHttpRequestOptions & { returnFullResponse: true }, requestOptions: IHttpRequestOptions & { returnFullResponse: true },
ssrfBridge?: SsrfBridge, ssrfBridge?: SsrfBridge,
agents?: NodeAgents,
): Promise<IN8nHttpFullResponse>; ): Promise<IN8nHttpFullResponse>;
export async function httpRequest( export async function httpRequest(
requestOptions: IHttpRequestOptions & { returnFullResponse?: false }, requestOptions: IHttpRequestOptions & { returnFullResponse?: false },
ssrfBridge?: SsrfBridge, ssrfBridge?: SsrfBridge,
agents?: NodeAgents,
): Promise<IN8nHttpResponse>; ): Promise<IN8nHttpResponse>;
export async function httpRequest( export async function httpRequest(
requestOptions: IHttpRequestOptions, requestOptions: IHttpRequestOptions,
ssrfBridge?: SsrfBridge, ssrfBridge?: SsrfBridge,
agents?: NodeAgents,
): Promise<IN8nHttpFullResponse | IN8nHttpResponse>; ): Promise<IN8nHttpFullResponse | IN8nHttpResponse>;
export async function httpRequest( export async function httpRequest(
requestOptions: IHttpRequestOptions, requestOptions: IHttpRequestOptions,
ssrfBridge?: SsrfBridge, ssrfBridge?: SsrfBridge,
agents?: NodeAgents,
): Promise<IN8nHttpFullResponse | IN8nHttpResponse> { ): Promise<IN8nHttpFullResponse | IN8nHttpResponse> {
removeEmptyBody(requestOptions); removeEmptyBody(requestOptions);
@ -191,7 +202,7 @@ export async function httpRequest(
await validateUrlSsrf(url, ssrfBridge); await validateUrlSsrf(url, ssrfBridge);
await validateProxySsrf(requestOptions.proxy, ssrfBridge); await validateProxySsrf(requestOptions.proxy, ssrfBridge);
const axiosRequest = convertN8nRequestToAxios(requestOptions, ssrfBridge); const axiosRequest = convertN8nRequestToAxios(requestOptions, ssrfBridge, agents);
if (axiosRequest.data === undefined || axiosRequest.method?.toUpperCase() === 'GET') { if (axiosRequest.data === undefined || axiosRequest.method?.toUpperCase() === 'GET') {
delete axiosRequest.data; delete axiosRequest.data;
} }

View File

@ -1,3 +1,5 @@
import type { IHttpRequestOptions } from 'n8n-workflow';
import type { HttpRequestDefaultHeaders } from './client-default-headers'; import type { HttpRequestDefaultHeaders } from './client-default-headers';
import type { UseDefaultSsrfPolicy } from './use-default-ssrf-policy'; import type { UseDefaultSsrfPolicy } from './use-default-ssrf-policy';
@ -27,4 +29,24 @@ export interface HttpRequestClientOptions {
* Set it once here instead of repeating it on each request. * Set it once here instead of repeating it on each request.
*/ */
timeout?: number; timeout?: number;
/**
* Reuse connections across requests on this client.
* The client creates its agents once, with these settings, and `request()` sends through them.
* `keepAlive` defaults to `true`.
* Call `close()` on the client to release them.
*
* Redirect hops create their own agents, and `requestLegacy()` does not use the pool.
* `maxSockets` and `close()` do not apply to them.
*
* A request on this client must not set `proxy`, `agentOptions` or `skipSslCertificateValidation`.
* The client throws if it does, because the shared agents cannot apply them.
*/
connectionPool?: HttpConnectionPoolOptions;
}
export interface HttpConnectionPoolOptions {
proxy?: IHttpRequestOptions['proxy'];
agentOptions?: IHttpRequestOptions['agentOptions'];
skipSslCertificateValidation?: boolean;
} }

View File

@ -14,8 +14,18 @@ export class UnknownConnectionHostError extends UnexpectedError {
} }
} }
export class SocketPathConnectionError extends UnexpectedError {
constructor() {
super('Cannot open a connection to a socket path');
}
}
/** Subset of an agent's connection options we read to find the target host. */ /** Subset of an agent's connection options we read to find the target host. */
type ConnectionOptions = { host?: string | null; hostname?: string | null }; type ConnectionOptions = {
host?: string | null;
hostname?: string | null;
socketPath?: string;
};
/** /**
* Runtime-only `createConnection` method of Node's http(s) agents. * Runtime-only `createConnection` method of Node's http(s) agents.
@ -59,6 +69,16 @@ export function installProxyConnectionGuard(agent: ProxyConnectionAgent, ssrf: S
}; };
} }
function connectionError(
ssrf: SsrfBridge | 'disabled',
options: ConnectionOptions,
): Error | undefined {
if (options.socketPath) {
return new SocketPathConnectionError();
}
return ssrf === 'disabled' ? undefined : connectionHostError(ssrf, options);
}
/** /**
* Wraps an agent's `createConnection` to validate the connection target before the socket opens. * Wraps an agent's `createConnection` to validate the connection target before the socket opens.
* *
@ -68,11 +88,11 @@ export function installProxyConnectionGuard(agent: ProxyConnectionAgent, ssrf: S
*/ */
export function installConnectionGuard( export function installConnectionGuard(
target: { createConnection: CreateConnection }, target: { createConnection: CreateConnection },
ssrf: SsrfBridge, ssrf: SsrfBridge | 'disabled',
): void { ): void {
const createConnection = target.createConnection.bind(target); const createConnection = target.createConnection.bind(target);
target.createConnection = (options, onConnect) => { target.createConnection = (options, onConnect) => {
const error = connectionHostError(ssrf, options); const error = connectionError(ssrf, options);
if (error) { if (error) {
if (onConnect) { if (onConnect) {
onConnect(error); onConnect(error);

View File

@ -40,4 +40,9 @@ export class EnvProxyHttpAgent extends http.Agent {
// No proxy for this target: serve it directly from this agent's own pool. // No proxy for this target: serve it directly from this agent's own pool.
super.addRequest(req, options); super.addRequest(req, options);
} }
destroy(): void {
this.router.destroy();
super.destroy();
}
} }

View File

@ -32,4 +32,9 @@ export class EnvProxyHttpsAgent extends https.Agent {
// No proxy for this target: serve it directly from this agent's own pool. // No proxy for this target: serve it directly from this agent's own pool.
super.addRequest(req, options); super.addRequest(req, options);
} }
destroy(): void {
this.router.destroy();
super.destroy();
}
} }

View File

@ -13,7 +13,7 @@ const MAX_CACHED_AGENTS = 64;
* *
* @typeParam TProxyAgent - the proxy-agent type for this scheme (`HttpProxyAgent` or `HttpsProxyAgent`). * @typeParam TProxyAgent - the proxy-agent type for this scheme (`HttpProxyAgent` or `HttpsProxyAgent`).
*/ */
export class EnvProxyRouter<TProxyAgent> { export class EnvProxyRouter<TProxyAgent extends { destroy(): void }> {
private readonly proxyCache = new Map<string, TProxyAgent>(); private readonly proxyCache = new Map<string, TProxyAgent>();
// Defensive upper bound on cached agents. Normally unreachable: the cache is keyed by the *proxy* URL. // Defensive upper bound on cached agents. Normally unreachable: the cache is keyed by the *proxy* URL.
@ -48,6 +48,16 @@ export class EnvProxyRouter<TProxyAgent> {
return agent; return agent;
} }
/**
* Destroys the cached proxy agents and empties the cache.
* Agents created past `MAX_CACHED_AGENTS` are not tracked, so this does not destroy them.
* Reaching the limit needs that many distinct environment proxies, which is already a misconfiguration.
*/
destroy(): void {
for (const agent of this.proxyCache.values()) agent.destroy();
this.proxyCache.clear();
}
private cacheAgent(proxyUrl: string, agent: TProxyAgent): void { private cacheAgent(proxyUrl: string, agent: TProxyAgent): void {
if (this.proxyCache.size >= MAX_CACHED_AGENTS) { if (this.proxyCache.size >= MAX_CACHED_AGENTS) {
if (!this.cacheLimitWarned) { if (!this.cacheLimitWarned) {

View File

@ -132,6 +132,7 @@ export function createFakeOutboundHttp(routes: Route[], fn: MockFnFactory) {
requestLegacy: () => { requestLegacy: () => {
throw new Error('requestLegacy is not supported by the fake OutboundHttp'); throw new Error('requestLegacy is not supported by the fake OutboundHttp');
}, },
close: () => {},
}), }),
); );
const outboundHttp = { const outboundHttp = {

View File

@ -29,4 +29,4 @@ export {
isTransportFailure, isTransportFailure,
markHttpRequestError, markHttpRequestError,
} from './client-request-error'; } from './client-request-error';
export type { HttpRequestClientOptions } from './client-options'; export type { HttpConnectionPoolOptions, HttpRequestClientOptions } from './client-options';

View File

@ -1,4 +1,4 @@
import { UnexpectedError } from 'n8n-workflow'; import { UserError } from 'n8n-workflow';
import http from 'node:http'; import http from 'node:http';
import https from 'node:https'; import https from 'node:https';
import type { LookupFunction } from 'node:net'; import type { LookupFunction } from 'node:net';
@ -9,6 +9,11 @@ import { EnvProxyHttpsAgent } from './env-proxy-https-agent';
import { createProxiedHttpAgent, createProxiedHttpsAgent } from '../proxy/proxied-agents'; import { createProxiedHttpAgent, createProxiedHttpsAgent } from '../proxy/proxied-agents';
import type { SsrfBridge } from '../ssrf'; import type { SsrfBridge } from '../ssrf';
export interface NodeAgents {
httpAgent: http.Agent;
httpsAgent: https.Agent;
}
/** /**
* An explicit proxy URL for routing all requests from this client. * An explicit proxy URL for routing all requests from this client.
* Only HTTP(S) forward proxies are supported: both the Node.js agents * Only HTTP(S) forward proxies are supported: both the Node.js agents
@ -81,6 +86,10 @@ export type NodeAgentOptions = https.AgentOptions;
* therefore has no effect and is rejected to avoid a false sense of control over * therefore has no effect and is rejected to avoid a false sense of control over
* DNS resolution. * DNS resolution.
* *
* `agentOptions.socketPath` is rejected for a related reason: it replaces the
* connection target, leaving the request's host and port unused, so nothing the
* policy decides applies to the socket that opens.
*
* `agentOptions` describes the connection to the **target**. Behind a proxy its TLS * `agentOptions` describes the connection to the **target**. Behind a proxy its TLS
* options travel with the tunnelled session rather than the proxy handshake * options travel with the tunnelled session rather than the proxy handshake
* (see `proxy/proxied-agents.ts`). * (see `proxy/proxied-agents.ts`).
@ -89,13 +98,19 @@ export function buildNodeAgents(
proxy: ProxyOption, proxy: ProxyOption,
ssrf: SsrfOption, ssrf: SsrfOption,
agentOptions?: NodeAgentOptions, agentOptions?: NodeAgentOptions,
): { httpAgent: http.Agent; httpsAgent: https.Agent } { ): NodeAgents {
if (agentOptions?.lookup) { if (agentOptions?.lookup) {
throw new UnexpectedError( throw new UserError(
'`agentOptions.lookup` is not supported: DNS resolution is managed by the SSRF policy. Remove it from `agentOptions`.', '`agentOptions.lookup` is not supported: DNS resolution is managed by the SSRF policy. Remove it from `agentOptions`.',
); );
} }
if (agentOptions !== undefined && 'socketPath' in agentOptions && agentOptions.socketPath) {
throw new UserError(
'`agentOptions.socketPath` is not supported. Remove it from `agentOptions`.',
);
}
const lookup: LookupFunction | undefined = const lookup: LookupFunction | undefined =
ssrf !== 'disabled' ? ssrf.createSecureLookup() : undefined; ssrf !== 'disabled' ? ssrf.createSecureLookup() : undefined;
@ -132,15 +147,16 @@ export function buildNodeAgents(
} }
/** /**
* Installs {@link installConnectionGuard} on a direct-path agent pair when SSRF protection is active. * Installs {@link installConnectionGuard} on a direct-path agent pair.
*
* It goes on whatever the SSRF setting, because it also refuses a socket path, which
* is not an SSRF decision.
*/ */
function applyConnectionGuard( function applyConnectionGuard(
agents: { httpAgent: http.Agent; httpsAgent: https.Agent }, agents: { httpAgent: http.Agent; httpsAgent: https.Agent },
ssrf: SsrfOption, ssrf: SsrfOption,
): { httpAgent: http.Agent; httpsAgent: https.Agent } { ): { httpAgent: http.Agent; httpsAgent: https.Agent } {
if (ssrf !== 'disabled') { installConnectionGuard(agents.httpAgent, ssrf);
installConnectionGuard(agents.httpAgent, ssrf); installConnectionGuard(agents.httpsAgent, ssrf);
installConnectionGuard(agents.httpsAgent, ssrf);
}
return agents; return agents;
} }

View File

@ -1,6 +1,7 @@
import { Logger } from '@n8n/backend-common'; import { Logger } from '@n8n/backend-common';
import { SsrfProtectionConfig } from '@n8n/config'; import { SsrfProtectionConfig } from '@n8n/config';
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import { UnexpectedError } from 'n8n-workflow';
import type { import type {
IHttpRequestOptions, IHttpRequestOptions,
IN8nHttpFullResponse, IN8nHttpFullResponse,
@ -15,11 +16,12 @@ import type { Dispatcher } from 'undici';
import { passthroughEgressFilter, SsrfProtectionService } from '../ssrf'; import { passthroughEgressFilter, SsrfProtectionService } from '../ssrf';
import { httpRequest } from './axios/request'; import { httpRequest } from './axios/request';
import { getUrlFromProxyConfig, resolveProxyOption } from './axios/utils';
import { withClientDefaults } from './client-default-headers'; import { withClientDefaults } from './client-default-headers';
import { HttpRequestClientOptions } from './client-options'; import type { HttpConnectionPoolOptions, HttpRequestClientOptions } from './client-options';
import { markHttpRequestError } from './client-request-error'; import { markHttpRequestError } from './client-request-error';
import { executeLegacyRequest, type LegacyRequestCallbacks } from './legacy-request'; import { executeLegacyRequest, type LegacyRequestCallbacks } from './legacy-request';
import type { NodeAgentOptions, ProxyOption, SsrfOption } from './node-agents'; import type { NodeAgentOptions, NodeAgents, ProxyOption, SsrfOption } from './node-agents';
import { buildNodeAgents } from './node-agents'; import { buildNodeAgents } from './node-agents';
import { import {
createDispatcherTransport, createDispatcherTransport,
@ -114,13 +116,20 @@ export interface HttpRequestClient {
* `callbacks.onFetched` runs once after data is successfully fetched (used by * `callbacks.onFetched` runs once after data is successfully fetched (used by
* the execution engine to fire its `nodeFetchedData` hook). * the execution engine to fire its `nodeFetchedData` hook).
* *
* Ignores the default `baseUrl` and `headers` defined on `HttpRequestClientOptions`. * Ignores the default `baseUrl` and `headers` defined on `HttpRequestClientOptions`,
* and does not use the `connectionPool`.
* *
* @deprecated Use {@link request} with `IHttpRequestOptions`. This exists only * @deprecated Use {@link request} with `IHttpRequestOptions`. This exists only
* to back the deprecated `request` helpers. * to back the deprecated `request` helpers.
*/ */
// oxlint-disable-next-line typescript/no-deprecated // oxlint-disable-next-line typescript/no-deprecated
requestLegacy(options: IRequestOptions, callbacks?: LegacyRequestCallbacks): Promise<unknown>; requestLegacy(options: IRequestOptions, callbacks?: LegacyRequestCallbacks): Promise<unknown>;
/**
* Closes the connections of the client's `connectionPool`, including requests in flight.
* Does nothing for a client without a pool.
*/
close(): void;
} }
/** /**
@ -189,6 +198,9 @@ export class OutboundHttp {
const applyDefaults = (requestOptions: IHttpRequestOptions): IHttpRequestOptions => const applyDefaults = (requestOptions: IHttpRequestOptions): IHttpRequestOptions =>
withClientDefaults(requestOptions, options?.baseURL, options?.headers, options?.timeout); withClientDefaults(requestOptions, options?.baseURL, options?.headers, options?.timeout);
const pool = options?.connectionPool;
const poolAgents = pool && buildPoolAgents(pool, ssrf);
function request<T = IN8nHttpResponse | Readable>( function request<T = IN8nHttpResponse | Readable>(
requestOptions: IHttpRequestOptions & { returnFullResponse: true }, requestOptions: IHttpRequestOptions & { returnFullResponse: true },
): Promise<TypedHttpFullResponse<T>>; ): Promise<TypedHttpFullResponse<T>>;
@ -199,8 +211,9 @@ export class OutboundHttp {
requestOptions: IHttpRequestOptions, requestOptions: IHttpRequestOptions,
): Promise<T | TypedHttpFullResponse<T>>; ): Promise<T | TypedHttpFullResponse<T>>;
async function request(requestOptions: IHttpRequestOptions): Promise<unknown> { async function request(requestOptions: IHttpRequestOptions): Promise<unknown> {
const withPool = pool ? withConnectionPool(requestOptions, pool) : requestOptions;
try { try {
return await httpRequest(applyDefaults(requestOptions), ssrfBridge); return await httpRequest(applyDefaults(withPool), ssrfBridge, poolAgents);
} catch (error) { } catch (error) {
// Tag so callers can recognize a client-rejected error transport-agnostically. // Tag so callers can recognize a client-rejected error transport-agnostically.
throw markHttpRequestError(error); throw markHttpRequestError(error);
@ -217,6 +230,10 @@ export class OutboundHttp {
throw markHttpRequestError(error); throw markHttpRequestError(error);
} }
}, },
close: () => {
poolAgents?.httpAgent.destroy();
poolAgents?.httpsAgent.destroy();
},
}; };
} }
@ -275,3 +292,38 @@ function lazy<T>(factory: () => T): () => T {
let cached: { value: T } | undefined; let cached: { value: T } | undefined;
return () => (cached ??= { value: factory() }).value; return () => (cached ??= { value: factory() }).value;
} }
const POOL_SETTINGS = ['proxy', 'agentOptions', 'skipSslCertificateValidation'] as const;
function buildPoolAgents(pool: HttpConnectionPoolOptions, ssrf: SsrfOption): NodeAgents {
// Node derives SNI from each request's host, so one pool serves any host.
if (pool.agentOptions?.servername) {
throw new UnexpectedError(
'`connectionPool.agentOptions.servername` is not supported: the pool serves every host of the client. Remove it.',
);
}
return buildNodeAgents(resolveProxyOption(getUrlFromProxyConfig(pool.proxy)), ssrf, {
keepAlive: true,
...pool.agentOptions,
...(pool.skipSslCertificateValidation === true && { rejectUnauthorized: false }),
});
}
/** Copies the pool's settings onto the request so the SSRF proxy check and redirects see them. */
function withConnectionPool(
requestOptions: IHttpRequestOptions,
pool: HttpConnectionPoolOptions,
): IHttpRequestOptions {
const conflicting = POOL_SETTINGS.filter((key) => requestOptions[key] !== undefined);
if (conflicting.length > 0) {
throw new UnexpectedError(
`A client with a \`connectionPool\` does not accept \`${conflicting.join('`, `')}\` on a request. Set them on \`connectionPool\` instead.`,
);
}
return {
...requestOptions,
proxy: pool.proxy,
agentOptions: pool.agentOptions,
skipSslCertificateValidation: pool.skipSslCertificateValidation,
};
}

View File

@ -1,6 +1,6 @@
{ {
"name": "@n8n/backend-services", "name": "@n8n/backend-services",
"version": "1.4.0", "version": "1.4.4",
"scripts": { "scripts": {
"clean": "rimraf dist .turbo", "clean": "rimraf dist .turbo",
"dev": "pnpm watch", "dev": "pnpm watch",

View File

@ -71,4 +71,21 @@ describe('ProjectScopeService', () => {
]); ]);
expect(result).toEqual(['project-1', 'project-2']); expect(result).toEqual(['project-1', 'project-2']);
}); });
describe('getMemberProjectIds', () => {
it('returns only member projects for a user with the global scope', async () => {
roleService.rolesWithScope.mockResolvedValue(['project:admin']);
projectRelationRepository.getAccessibleProjectsByRoles.mockResolvedValue(['project-1']);
const result = await service.getMemberProjectIds(makeUser(['agent:update']), [
'agent:update',
]);
expect(projectRelationRepository.getAccessibleProjectsByRoles).toHaveBeenCalledWith(
'user-1',
['project:admin'],
);
expect(result).toEqual(['project-1']);
});
});
}); });

View File

@ -35,14 +35,17 @@ describe('WorkflowProjectService', () => {
}); });
}); });
it('loads the owner project when the cached value is malformed', async () => { it.each(['invalid', {}])(
const project = { id: 'project-1' } as Project; 'loads the owner project when the cached value is malformed',
cacheService.getHashValue.mockResolvedValue('invalid' as never); async (cached) => {
sharedWorkflowRepository.findOneOrFail.mockResolvedValue({ project } as never); const project = { id: 'project-1' } as Project;
cacheService.getHashValue.mockResolvedValue(cached as never);
sharedWorkflowRepository.findOneOrFail.mockResolvedValue({ project } as never);
await expect(service.getWorkflowProjectCached('workflow-1')).resolves.toBe(project); await expect(service.getWorkflowProjectCached('workflow-1')).resolves.toBe(project);
expect(sharedWorkflowRepository.findOneOrFail).toHaveBeenCalledOnce(); expect(sharedWorkflowRepository.findOneOrFail).toHaveBeenCalledOnce();
}); },
);
it('sets the cached owner project', async () => { it('sets the cached owner project', async () => {
const project = { id: 'project-1' } as Project; const project = { id: 'project-1' } as Project;

View File

@ -338,6 +338,27 @@ for (const backend of ['memory', 'redis'] as const) {
await expect(cacheService.getHashValue('key', 'field1')).resolves.toBe('value1'); await expect(cacheService.getHashValue('key', 'field1')).resolves.toBe('value1');
}); });
if (backend === 'memory') {
test('should not return inherited properties as hash field values', async () => {
await cacheService.setHash('key', { field1: { id: 'value1' } });
await expect(cacheService.getHashValue('key', '__proto__')).resolves.toBeUndefined();
await expect(cacheService.getHashValue('key', 'constructor')).resolves.toBeUndefined();
});
test('should not store a `__proto__` hash field', async () => {
await cacheService.setHash('key', { field1: { id: 'value1' } });
// A computed key creates an own `__proto__` field, as a hash keyed by a workflow id does
await cacheService.setHash('key', { ['__proto__']: { id: 'value2' } });
expect(Object.getPrototypeOf(await cacheService.get('key'))).toBe(Object.prototype);
await expect(cacheService.getHashValue('key', '__proto__')).resolves.toBeUndefined();
await expect(cacheService.getHashValue('key', 'field1')).resolves.toEqual({
id: 'value1',
});
});
}
}); });
}); });
} }

View File

@ -151,7 +151,15 @@ export class CacheService extends TypedEmitter<CacheEvents> {
const hashObject: Hash = (await this.get(key)) ?? {}; const hashObject: Hash = (await this.get(key)) ?? {};
Object.assign(hashObject, hash); for (const [field, value] of Object.entries(hash)) {
// Skip `__proto__`. On a plain object, `hash['__proto__'] = value` does not add a field.
// It changes the object's prototype.
if (field === '__proto__') {
continue;
}
hashObject[field] = value;
}
await this.set(key, hashObject); await this.set(key, hashObject);
} }
@ -309,7 +317,10 @@ export class CacheService extends TypedEmitter<CacheEvents> {
} else { } else {
const hashObject = await this.cache.store.get<Hash<T>>(cacheKey); const hashObject = await this.cache.store.get<Hash<T>>(cacheKey);
hashValue = hashObject?.[hashKey] as MaybeHash<T>; hashValue =
hashObject && Object.hasOwn(hashObject, hashKey)
? (hashObject[hashKey] as MaybeHash<T>)
: undefined;
} }
if (hashValue !== undefined) { if (hashValue !== undefined) {

View File

@ -22,9 +22,17 @@ export class ProjectScopeService {
} }
async getProjectIds(user: User, scopes: Scope[]): Promise<string[] | null> { async getProjectIds(user: User, scopes: Scope[]): Promise<string[] | null> {
const roles = await this.getProjectRoleSlugs(user, scopes); if (hasGlobalScope(user, scopes, { mode: 'allOf' })) return null;
if (roles === null) return null;
return await this.getMemberProjectIds(user, scopes);
}
/**
* Like {@link getProjectIds}, but ignores the global role: only projects the
* user is a member of with a role that grants all `scopes`.
*/
async getMemberProjectIds(user: User, scopes: Scope[]): Promise<string[]> {
const roles = await this.roleService.rolesWithScope('project', scopes);
return await this.projectRelationRepository.getAccessibleProjectsByRoles(user.id, roles); return await this.projectRelationRepository.getAccessibleProjectsByRoles(user.id, roles);
} }
} }

View File

@ -10,7 +10,9 @@ export class WorkflowProjectService {
) {} ) {}
private reconstructProject(project: Partial<Project>): Project | undefined { private reconstructProject(project: Partial<Project>): Project | undefined {
if (typeof project !== 'object' || project === null) return undefined; if (typeof project !== 'object' || project === null || typeof project.id !== 'string') {
return undefined;
}
return Object.assign(new Project(), project); return Object.assign(new Project(), project);
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "@n8n/backend-test-utils", "name": "@n8n/backend-test-utils",
"version": "1.44.0", "version": "1.44.4",
"scripts": { "scripts": {
"clean": "rimraf dist .turbo", "clean": "rimraf dist .turbo",
"dev": "pnpm watch", "dev": "pnpm watch",

View File

@ -1,6 +1,6 @@
{ {
"name": "@n8n/blob-storage", "name": "@n8n/blob-storage",
"version": "1.12.0", "version": "1.12.4",
"description": "Blob storage layer for n8n, with filesystem, S3, and Azure Blob backends", "description": "Blob storage layer for n8n, with filesystem, S3, and Azure Blob backends",
"scripts": { "scripts": {
"clean": "rimraf dist .turbo", "clean": "rimraf dist .turbo",

View File

@ -1,6 +1,6 @@
{ {
"name": "@n8n/chat-hub", "name": "@n8n/chat-hub",
"version": "1.37.0", "version": "1.37.3",
"scripts": { "scripts": {
"clean": "rimraf dist .turbo", "clean": "rimraf dist .turbo",
"dev": "pnpm watch", "dev": "pnpm watch",

View File

@ -1,6 +1,6 @@
{ {
"name": "@n8n/client-oauth2", "name": "@n8n/client-oauth2",
"version": "1.26.0", "version": "1.26.4",
"scripts": { "scripts": {
"clean": "rimraf dist .turbo", "clean": "rimraf dist .turbo",
"dev": "pnpm watch", "dev": "pnpm watch",

View File

@ -1,6 +1,6 @@
{ {
"name": "@n8n/config", "name": "@n8n/config",
"version": "2.42.0", "version": "2.42.1",
"scripts": { "scripts": {
"clean": "rimraf dist .turbo", "clean": "rimraf dist .turbo",
"dev": "pnpm watch", "dev": "pnpm watch",

View File

@ -156,8 +156,8 @@ describe('ExpressionEngineConfig', () => {
expect(Container.get(ExpressionEngineConfig).observabilityEnabled).toBe(true); expect(Container.get(ExpressionEngineConfig).observabilityEnabled).toBe(true);
}); });
test('tracesEnabled defaults to true', () => { test('tracesEnabled defaults to false', () => {
expect(Container.get(ExpressionEngineConfig).tracesEnabled).toBe(true); expect(Container.get(ExpressionEngineConfig).tracesEnabled).toBe(false);
}); });
test('slowEvaluationThresholdMs defaults to 50', () => { test('slowEvaluationThresholdMs defaults to 50', () => {

View File

@ -65,12 +65,13 @@ export class ExpressionEngineConfig {
observabilityEnabled: boolean = true; observabilityEnabled: boolean = true;
/** /**
* Whether to emit OpenTelemetry spans for expression evaluation. * Whether n8n sends OpenTelemetry spans for expression evaluations when `N8N_OTEL_ENABLED`
* Slow evaluations (>slowEvaluationThresholdMs) and errors always emit a span. * is true. n8n creates a span for each evaluation that fails or takes longer than
* Healthy-path evaluations are sampled at tracesSampleRate. * `N8N_EXPRESSION_ENGINE_SLOW_EVAL_THRESHOLD_MS`, and samples the rest at
* `N8N_EXPRESSION_ENGINE_TRACES_SAMPLE_RATE`.
*/ */
@Env('N8N_EXPRESSION_ENGINE_TRACES_ENABLED') @Env('N8N_EXPRESSION_ENGINE_TRACES_ENABLED')
tracesEnabled: boolean = true; tracesEnabled: boolean = false;
/** Threshold in ms above which an evaluation is considered "slow" and gets a span. */ /** Threshold in ms above which an evaluation is considered "slow" and gets a span. */
@Env('N8N_EXPRESSION_ENGINE_SLOW_EVAL_THRESHOLD_MS', z.number({ coerce: true }).positive()) @Env('N8N_EXPRESSION_ENGINE_SLOW_EVAL_THRESHOLD_MS', z.number({ coerce: true }).positive())

View File

@ -691,7 +691,7 @@ describe('GlobalConfig', () => {
bridgeTimeout: 5000, bridgeTimeout: 5000,
bridgeMemoryLimit: 128, bridgeMemoryLimit: 128,
observabilityEnabled: true, observabilityEnabled: true,
tracesEnabled: true, tracesEnabled: false,
slowEvaluationThresholdMs: 50, slowEvaluationThresholdMs: 50,
tracesSampleRate: 0.0, tracesSampleRate: 0.0,
allowWebhookIsolateSkip: true, allowWebhookIsolateSkip: true,

View File

@ -1,6 +1,6 @@
{ {
"name": "@n8n/create-node", "name": "@n8n/create-node",
"version": "0.52.0", "version": "0.52.4",
"description": "Official CLI to create new community nodes for n8n", "description": "Official CLI to create new community nodes for n8n",
"bin": { "bin": {
"create-node": "bin/create-node.cjs" "create-node": "bin/create-node.cjs"

View File

@ -1,6 +1,6 @@
{ {
"name": "@n8n/db", "name": "@n8n/db",
"version": "1.44.0", "version": "1.44.4",
"scripts": { "scripts": {
"clean": "rimraf dist .turbo .tmp-schema-docs", "clean": "rimraf dist .turbo .tmp-schema-docs",
"gen:migration-index": "node scripts/generate-migration-index.mjs", "gen:migration-index": "node scripts/generate-migration-index.mjs",

View File

@ -1,5 +1,6 @@
import { GlobalConfig } from '@n8n/config'; import { GlobalConfig } from '@n8n/config';
import { In, IsNull, MoreThan, Not, type SelectQueryBuilder } from '@n8n/typeorm'; import { In, IsNull, MoreThan, Not, type SelectQueryBuilder } from '@n8n/typeorm';
import type { IWorkflowSettings } from 'n8n-workflow';
import type { Mock, Mocked } from 'vitest'; import type { Mock, Mocked } from 'vitest';
import { mock } from 'vitest-mock-extended'; import { mock } from 'vitest-mock-extended';
@ -840,4 +841,270 @@ describe('WorkflowRepository', () => {
expect(mockExecute).toHaveBeenCalled(); expect(mockExecute).toHaveBeenCalled();
}); });
}); });
describe('caller allowlist purge', () => {
let writes: Array<{ id: string; callerIds: string }>;
let writtenSettings: Array<IWorkflowSettings | undefined>;
let selectQb: { where: Mock; andWhere: Mock; setLock: Mock };
const stubCandidates = (candidates: Array<Partial<WorkflowEntity>>) => {
writes = [];
writtenSettings = [];
const select = {
select: vi.fn(),
where: vi.fn(),
andWhere: vi.fn(),
setLock: vi.fn(),
getMany: vi.fn().mockResolvedValue(candidates),
};
select.select.mockReturnValue(select);
select.where.mockReturnValue(select);
select.andWhere.mockReturnValue(select);
select.setLock.mockReturnValue(select);
let pendingId = '';
let pendingCallerIds = '';
let pendingSettings: IWorkflowSettings | undefined;
const update = {
update: vi.fn(),
set: vi.fn(),
where: vi.fn(),
setParameter: vi.fn(),
execute: vi.fn(async () => {
writes.push({ id: pendingId, callerIds: pendingCallerIds });
writtenSettings.push(pendingSettings);
}),
};
update.update.mockReturnValue(update);
// Postgres writes the whole document, SQLite the one key through a parameter.
update.set.mockImplementation((values: { settings: unknown }) => {
if (typeof values.settings === 'object' && values.settings !== null) {
pendingSettings = values.settings as IWorkflowSettings;
pendingCallerIds = pendingSettings.callerIds ?? '';
}
return update;
});
update.where.mockImplementation((_sql: string, parameters: { workflowId: string }) => {
pendingId = parameters.workflowId;
return update;
});
update.setParameter.mockImplementation((_name: string, value: string) => {
pendingCallerIds = value;
return update;
});
vi.mocked(entityManager.createQueryBuilder).mockImplementation(((...args: unknown[]) =>
args.length > 0 ? select : update) as never);
transactionRunner.run.mockImplementation(
async (ctx: unknown, fn: (c: unknown) => Promise<unknown>) => await fn(ctx),
);
selectQb = select;
return select;
};
it('removes the entry and keeps the rest in order, whatever whitespace separates them', async () => {
stubCandidates([
{
id: 'subworkflow-1',
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'first,\n second , ,\tthird' },
},
]);
await workflowRepository.removeFromCallerAllowlists(['second'], {});
expect(writes).toEqual([{ id: 'subworkflow-1', callerIds: 'first,third' }]);
});
it('rewrites only the allowlists that hold the id as an entry of its own', async () => {
stubCandidates([
{
id: 'subworkflow-1',
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'abc,abcdef' },
},
{
id: 'subworkflow-2',
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'abcdef' },
},
{ id: 'subworkflow-3', settings: { callerPolicy: 'workflowsFromAList' } },
]);
await workflowRepository.removeFromCallerAllowlists(['abc'], {});
expect(writes).toEqual([{ id: 'subworkflow-1', callerIds: 'abcdef' }]);
});
it('leaves an allowlist alone when the id only appears inside another entry', async () => {
stubCandidates([
{
id: 'subworkflow-1',
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'abc-1, abc-2' },
},
]);
await workflowRepository.removeFromCallerAllowlists(['abc'], {});
expect(writes).toEqual([]);
});
it('rewrites every workflow that names the id', async () => {
stubCandidates([
{
id: 'subworkflow-1',
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'first,second' },
},
{
id: 'subworkflow-2',
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'second,third' },
},
]);
await workflowRepository.removeFromCallerAllowlists(['second'], {});
expect(writes).toEqual([
{ id: 'subworkflow-1', callerIds: 'first' },
{ id: 'subworkflow-2', callerIds: 'third' },
]);
});
it('takes every id out of one allowlist in a single write', async () => {
stubCandidates([
{
id: 'subworkflow-1',
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'first,second,third' },
},
]);
await workflowRepository.removeFromCallerAllowlists(['first', 'third'], {});
expect(writes).toEqual([{ id: 'subworkflow-1', callerIds: 'second' }]);
});
it('reads the candidates once, whatever the batch size, and locks only the ones it rewrites', async () => {
const select = stubCandidates([
{
id: 'subworkflow-1',
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'one,first' },
},
{
id: 'subworkflow-2',
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'first' },
},
]);
await workflowRepository.removeFromCallerAllowlists(['one', 'two'], {});
expect(select.where).toHaveBeenCalledTimes(2);
expect(select.where).toHaveBeenNthCalledWith(1, expect.stringContaining('callerIds'));
expect(select.where).toHaveBeenNthCalledWith(2, 'workflow.id IN (:...ids)', {
ids: ['subworkflow-1'],
});
expect(select.setLock).toHaveBeenCalledTimes(1);
expect(select.setLock).toHaveBeenCalledWith('pessimistic_write');
expect(writes).toEqual([{ id: 'subworkflow-1', callerIds: 'first' }]);
});
it('locks nothing when no allowlist names a removed id', async () => {
const select = stubCandidates([
{
id: 'subworkflow-1',
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'first' },
},
]);
await workflowRepository.removeFromCallerAllowlists(['one'], {});
expect(select.setLock).not.toHaveBeenCalled();
expect(writes).toEqual([]);
});
it('matches candidates on the stored document, never on a value read out of it', async () => {
const select = stubCandidates([]);
await workflowRepository.removeFromCallerAllowlists(['one'], {});
expect(select.where).toHaveBeenCalledWith('workflow.settings::text LIKE \'%"callerIds"%\'');
});
it('keeps a value the binary JSON form has no room for', async () => {
const noBinaryJsonForm = String.fromCharCode(0);
stubCandidates([
{
id: 'subworkflow-1',
settings: {
callerPolicy: 'workflowsFromAList',
callerIds: 'first,second',
errorWorkflow: `error-workflow${noBinaryJsonForm}`,
},
},
]);
await workflowRepository.removeFromCallerAllowlists(['second'], {});
expect(writtenSettings).toEqual([
{
callerPolicy: 'workflowsFromAList',
callerIds: 'first',
errorWorkflow: `error-workflow${noBinaryJsonForm}`,
},
]);
});
it('purges a row that exists at an id being removed', async () => {
stubCandidates([
{
id: 'recreated-1',
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'first,second' },
},
]);
await workflowRepository.removeFromCallerAllowlists(['recreated-1', 'second'], {});
expect(writes).toEqual([{ id: 'recreated-1', callerIds: 'first' }]);
});
it('writes nothing when given no ids', async () => {
stubCandidates([]);
await workflowRepository.removeFromCallerAllowlists([], {});
expect(selectQb.where).not.toHaveBeenCalled();
expect(writes).toEqual([]);
});
it('removes only the entries naming an id without a row', async () => {
stubCandidates([
{
id: 'subworkflow-1',
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'deleted,survivor,other' },
},
]);
vi.mocked(entityManager.find).mockResolvedValue([{ id: 'survivor' }, { id: 'other' }]);
await workflowRepository.removeMissingFromCallerAllowlists({});
expect(entityManager.find).toHaveBeenCalledWith(WorkflowEntity, {
select: ['id'],
where: { id: In(['deleted', 'survivor', 'other']) },
});
expect(writes).toEqual([{ id: 'subworkflow-1', callerIds: 'survivor,other' }]);
});
it('locks and writes nothing when every named id has a row', async () => {
const select = stubCandidates([
{
id: 'subworkflow-1',
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'first,second' },
},
]);
vi.mocked(entityManager.find).mockResolvedValue([{ id: 'first' }, { id: 'second' }]);
await workflowRepository.removeMissingFromCallerAllowlists({});
expect(select.setLock).not.toHaveBeenCalled();
expect(writes).toEqual([]);
});
});
}); });

View File

@ -454,6 +454,136 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
} }
} }
/** Remove every id in `workflowIds` from the `callerIds` allowlist of every other workflow. */
async removeFromCallerAllowlists(workflowIds: string[], ctx: OperationContext): Promise<void> {
if (workflowIds.length === 0) return;
await this.runInTransaction(ctx, async (tx) => {
const candidates = await this.findCallerAllowlistCandidates(tx);
await this.purgeCallerAllowlists(candidates, new Set(workflowIds), tx);
});
}
/** Remove every id that has no workflow row from every `callerIds` allowlist. */
async removeMissingFromCallerAllowlists(ctx: OperationContext): Promise<void> {
await this.runInTransaction(ctx, async (tx) => {
const candidates = await this.findCallerAllowlistCandidates(tx);
const namedIds = [
...new Set(candidates.flatMap((workflow) => this.callerIdEntries(workflow))),
];
const existingIds = new Set<string>();
for (const chunk of chunkIds(namedIds)) {
const existing = await tx.find(WorkflowEntity, {
select: ['id'],
where: { id: In(chunk) },
});
for (const { id } of existing) existingIds.add(id);
}
const missingIds = new Set(namedIds.filter((id) => !existingIds.has(id)));
if (missingIds.size > 0) await this.purgeCallerAllowlists(candidates, missingIds, tx);
});
}
/** Rewrite the allowlists among `candidates` that name any of `removedIds`, under a row lock. */
private async purgeCallerAllowlists(
candidates: WorkflowEntity[],
removedIds: Set<string>,
tx: EntityManager,
): Promise<void> {
const affected = candidates.filter(
(workflow) => this.callerIdsWithout(workflow, removedIds) !== undefined,
);
for (const workflow of await this.lockCallerAllowlists(affected, tx)) {
const remaining = this.callerIdsWithout(workflow, removedIds);
if (remaining !== undefined) await this.writeCallerIds(workflow, remaining, tx);
}
}
/** The non-empty, trimmed entries of the `callerIds` allowlist of `workflow`. */
private callerIdEntries(workflow: WorkflowEntity): string[] {
const callerIds = workflow.settings?.callerIds;
if (typeof callerIds !== 'string') return [];
return callerIds
.split(',')
.map((callerId) => callerId.trim())
.filter((callerId) => callerId !== '');
}
/** The allowlist of `workflow` without `removedIds`, or `undefined` when it names none of them. */
private callerIdsWithout(workflow: WorkflowEntity, removedIds: Set<string>): string | undefined {
const entries = this.callerIdEntries(workflow);
if (!entries.some((callerId) => removedIds.has(callerId))) return undefined;
return entries.filter((callerId) => !removedIds.has(callerId)).join(',');
}
/** Find every workflow that has a `callerIds` allowlist. */
private async findCallerAllowlistCandidates(tx: EntityManager): Promise<WorkflowEntity[]> {
// On Postgres, match the raw text rather than extract: the `json` column accepts characters,
// such as an escaped NUL, that extracting `callerIds` rejects with `unsupported Unicode
// escape sequence`, which fails the query for every row.
const hasAllowlist =
this.globalConfig.database.type === 'postgresdb'
? 'workflow.settings::text LIKE \'%"callerIds"%\''
: `${this.settingsTextValue('workflow.settings', 'callerIds')} IS NOT NULL`;
return await tx
.createQueryBuilder(WorkflowEntity, 'workflow')
.select(['workflow.id', 'workflow.settings'])
.where(hasAllowlist)
.getMany();
}
/** Lock `workflows` until the transaction ends and re-read their settings under the lock. */
private async lockCallerAllowlists(
workflows: WorkflowEntity[],
tx: EntityManager,
): Promise<WorkflowEntity[]> {
// SQLite serialises writes, so what it read is already current.
if (this.globalConfig.database.type !== 'postgresdb') return workflows;
const locked: WorkflowEntity[] = [];
for (const chunk of chunkIds(workflows.map(({ id }) => id))) {
locked.push(
...(await tx
.createQueryBuilder(WorkflowEntity, 'workflow')
.select(['workflow.id', 'workflow.settings'])
.where('workflow.id IN (:...ids)', { ids: chunk })
.setLock('pessimistic_write')
.getMany()),
);
}
return locked;
}
/** Store `callerIds` back on one workflow, keeping its other settings. */
private async writeCallerIds(
workflow: WorkflowEntity,
callerIds: string,
tx: EntityManager,
): Promise<void> {
const update = tx
.createQueryBuilder()
.update(WorkflowEntity)
.where('id = :workflowId', { workflowId: workflow.id });
if (this.globalConfig.database.type === 'postgresdb') {
// `jsonb_set` rejects characters the `json` column accepts, so rewrite the whole document
// read under the row lock.
await update.set({ settings: { ...workflow.settings, callerIds } }).execute();
return;
}
await update
.set({ settings: () => "JSON_SET(COALESCE(settings, '{}'), '$.callerIds', :callerIds)" })
.setParameter('callerIds', callerIds)
.execute();
}
async findById(workflowId: string) { async findById(workflowId: string) {
return await this.findOne({ return await this.findOne({
where: { id: workflowId }, where: { id: workflowId },
@ -864,7 +994,6 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
onlySharedWithMe?: boolean; onlySharedWithMe?: boolean;
}, },
options: WorkflowListQueryOptions = {}, options: WorkflowListQueryOptions = {},
callableForParentWorkflowId?: string,
) { ) {
if ( if (
options.filter?.parentFolderId && options.filter?.parentFolderId &&
@ -884,18 +1013,8 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
} }
const [workflowsAndFolders, count] = await Promise.all([ const [workflowsAndFolders, count] = await Promise.all([
this.getWorkflowsAndFoldersUnionWithSharingSubquery( this.getWorkflowsAndFoldersUnionWithSharingSubquery(user, sharingOptions, options),
user, this.getWorkflowsAndFoldersCountWithSharingSubquery(user, sharingOptions, options),
sharingOptions,
options,
callableForParentWorkflowId,
),
this.getWorkflowsAndFoldersCountWithSharingSubquery(
user,
sharingOptions,
options,
callableForParentWorkflowId,
),
]); ]);
const isArchived = const isArchived =
@ -922,15 +1041,9 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
onlySharedWithMe?: boolean; onlySharedWithMe?: boolean;
}, },
options: WorkflowListQueryOptions = {}, options: WorkflowListQueryOptions = {},
callableForParentWorkflowId?: string,
) { ) {
const { baseQuery, sortByColumn, sortByDirection } = const { baseQuery, sortByColumn, sortByDirection } =
this.buildBaseUnionQueryWithSharingSubquery( this.buildBaseUnionQueryWithSharingSubquery(user, sharingOptions, options);
user,
sharingOptions,
options,
callableForParentWorkflowId,
);
const query = this.buildUnionQuery(baseQuery, { const query = this.buildUnionQuery(baseQuery, {
sortByColumn, sortByColumn,
@ -956,7 +1069,6 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
onlySharedWithMe?: boolean; onlySharedWithMe?: boolean;
}, },
options: WorkflowListQueryOptions = {}, options: WorkflowListQueryOptions = {},
callableForParentWorkflowId?: string,
) { ) {
const { skip, take, ...baseQueryParameters } = options; const { skip, take, ...baseQueryParameters } = options;
@ -964,7 +1076,6 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
user, user,
sharingOptions, sharingOptions,
baseQueryParameters, baseQueryParameters,
callableForParentWorkflowId,
); );
const response = await baseQuery const response = await baseQuery
@ -986,7 +1097,6 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
onlySharedWithMe?: boolean; onlySharedWithMe?: boolean;
}, },
options: WorkflowListQueryOptions = {}, options: WorkflowListQueryOptions = {},
callableForParentWorkflowId?: string,
) { ) {
// Common fields for both folders and workflows // Common fields for both folders and workflows
const commonFields = { const commonFields = {
@ -1036,7 +1146,6 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
user, user,
sharingOptions, sharingOptions,
workflowQueryParameters, workflowQueryParameters,
callableForParentWorkflowId,
).addSelect("'workflow'", 'resource'); ).addSelect("'workflow'", 'resource');
const qb = this.manager.createQueryBuilder(); const qb = this.manager.createQueryBuilder();
@ -1155,14 +1264,8 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
onlySharedWithMe?: boolean; onlySharedWithMe?: boolean;
}, },
options: WorkflowListQueryOptions = {}, options: WorkflowListQueryOptions = {},
callableForParentWorkflowId?: string,
): Promise<WorkflowListResult> { ): Promise<WorkflowListResult> {
const query = this.getManyQueryWithSharingSubquery( const query = this.getManyQueryWithSharingSubquery(user, sharingOptions, options);
user,
sharingOptions,
options,
callableForParentWorkflowId,
);
const [workflows, count] = await query.getManyAndCount(); const [workflows, count] = await query.getManyAndCount();
return { workflows, count }; return { workflows, count };
@ -1182,7 +1285,6 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
onlySharedWithMe?: boolean; onlySharedWithMe?: boolean;
}, },
options: WorkflowListQueryOptions = {}, options: WorkflowListQueryOptions = {},
callableForParentWorkflowId?: string,
): SelectQueryBuilder<WorkflowEntity> { ): SelectQueryBuilder<WorkflowEntity> {
const qb = this.createQueryBuilder('workflow'); const qb = this.createQueryBuilder('workflow');
@ -1200,21 +1302,9 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
sharingOptionsWithProjectId, sharingOptionsWithProjectId,
); );
if (callableForParentWorkflowId) { // Apply the sharing filter using the subquery
// Union: workflows readable by the user OR callable by the parent workflow. qb.andWhere(`workflow.id IN (${sharedWorkflowSubquery.getQuery()})`);
const callableSubquery = this.buildCallablePolicySubquery(callableForParentWorkflowId); qb.setParameters(sharedWorkflowSubquery.getParameters());
qb.andWhere(
`(workflow.id IN (${sharedWorkflowSubquery.getQuery()}) OR workflow.id IN (${callableSubquery.getQuery()}))`,
);
qb.setParameters({
...sharedWorkflowSubquery.getParameters(),
...callableSubquery.getParameters(),
});
} else {
// Apply the sharing filter using the subquery
qb.andWhere(`workflow.id IN (${sharedWorkflowSubquery.getQuery()})`);
qb.setParameters(sharedWorkflowSubquery.getParameters());
}
// Apply other filters // Apply other filters
// For personal project and shared-with-me cases, projectId is already handled in the subquery // For personal project and shared-with-me cases, projectId is already handled in the subquery
@ -1238,70 +1328,98 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
} }
/** /**
* Build a subquery returning IDs of workflows whose callerPolicy permits the * List the workflows that `parentWorkflowId` can call as sub-workflows. The
* given `parentWorkflowId` to call them. * list holds the workflows the user can read, plus the workflows whose
* caller policy names the parent explicitly (`any`, or an allowlist that
* contains the parent id).
*
* Only a minimal set of columns is loaded, because the callable rows can
* belong to projects that the user has no access to.
*/
async findCallableSubworkflows(
user: User,
sharingOptions: { scopes: Scope[]; projectRoles: string[]; workflowRoles: string[] },
parentWorkflowId: string,
options: { query?: string; triggerNodeTypes: string[]; skip: number; take: number },
): Promise<{ workflows: WorkflowEntity[]; count: number }> {
const sharedWorkflowSubquery = this.buildSharedWorkflowIdsSubquery(user, sharingOptions);
const callableSubquery = this.buildCallablePolicySubquery(parentWorkflowId);
const qb = this.createQueryBuilder('workflow')
.select([
'workflow.id',
'workflow.name',
'workflow.activeVersionId',
'workflow.isArchived',
'workflow.updatedAt',
])
.leftJoin('workflow.shared', 'owner', 'owner.role = :callableOwnerRole', {
callableOwnerRole: 'workflow:owner',
})
.leftJoin('owner.project', 'ownerProject')
.addSelect([
'owner.workflowId',
'owner.projectId',
'owner.role',
'ownerProject.id',
'ownerProject.type',
'ownerProject.name',
])
.where(
`(workflow.id IN (${sharedWorkflowSubquery.getQuery()}) OR workflow.id IN (${callableSubquery.getQuery()}))`,
)
.setParameters({
...sharedWorkflowSubquery.getParameters(),
...callableSubquery.getParameters(),
});
// Match names only: the list can hold workflows the user cannot read, so
// their descriptions must not affect the result.
const searchWords = this.parseSearchWords(options.query);
if (searchWords.length > 0) {
const { conditions, parameters } = this.buildSearchConditions(
searchWords,
'LOWER(workflow.name)',
);
qb.andWhere(`(${conditions.join(' AND ')})`, parameters);
}
this.applyTriggerNodeTypesFilter(qb, options.triggerNodeTypes);
qb.orderBy('workflow.updatedAt', 'DESC').skip(options.skip).take(options.take);
const [workflows, count] = await qb.getManyAndCount();
return { workflows, count };
}
/**
* Build a subquery returning IDs of workflows whose callerPolicy names the
* given `parentWorkflowId` explicitly.
*
* The `workflowsFromSameOwner` policy is left out on purpose: for a user who
* is not the owner, it would list workflows that the owner did not share.
* The owner can already read those workflows through the sharing subquery.
*/ */
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
private buildCallablePolicySubquery(parentWorkflowId: string): SelectQueryBuilder<any> { private buildCallablePolicySubquery(parentWorkflowId: string): SelectQueryBuilder<any> {
const subquery = this.manager
.createQueryBuilder()
.select('cpw.id')
.from(WorkflowEntity, 'cpw')
.leftJoin(
SharedWorkflow,
'sw_sub',
"sw_sub.workflowId = cpw.id AND sw_sub.role = 'workflow:owner'",
)
.leftJoin(
SharedWorkflow,
'sw_par',
"sw_par.workflowId = :cpParentWorkflowId AND sw_par.role = 'workflow:owner'",
);
const { conditions, params } = this.buildCallablePolicyConditions(parentWorkflowId);
subquery.where('(' + conditions.join(' OR ') + ')', params);
return subquery;
}
private buildCallablePolicyConditions(parentWorkflowId: string): {
conditions: string[];
params: Record<string, string>;
} {
const defaultPolicy = this.globalConfig.workflows.callerPolicyDefaultOption; const defaultPolicy = this.globalConfig.workflows.callerPolicyDefaultOption;
const callerPolicy = this.settingsTextValue('cpw.settings', 'callerPolicy'); const callerPolicy = this.settingsTextValue('cpw.settings', 'callerPolicy');
const callerIds = this.settingsTextValue('cpw.settings', 'callerIds'); const callerIds = this.settingsTextValue('cpw.settings', 'callerIds');
const conditions: string[] = []; const conditions = [
const params: Record<string, string> = { `${callerPolicy} = 'any'`,
cpParentWorkflowId: parentWorkflowId, // The allowlist must contain parentWorkflowId as a whole ID.
cpCallerIdMembership: `%,${escapeLike(parentWorkflowId)},%`,
};
// Branch 1: callerPolicy = 'any'
conditions.push(`${callerPolicy} = 'any'`);
// Branch 2: callerPolicy = 'workflowsFromAList' and the allowlist contains parentWorkflowId as a whole ID.
conditions.push(
`(${callerPolicy} = 'workflowsFromAList' AND (',' || REPLACE(${callerIds}, ' ', '') || ',') LIKE :cpCallerIdMembership ${LIKE_ESCAPE_CLAUSE})`, `(${callerPolicy} = 'workflowsFromAList' AND (',' || REPLACE(${callerIds}, ' ', '') || ',') LIKE :cpCallerIdMembership ${LIKE_ESCAPE_CLAUSE})`,
); ];
// Branch 3: callerPolicy = 'workflowsFromSameOwner' (or NULL when default is 'workflowsFromSameOwner').
const sameOwnerPolicyClauses = [`${callerPolicy} = 'workflowsFromSameOwner'`];
if (defaultPolicy === 'workflowsFromSameOwner') {
sameOwnerPolicyClauses.push(`${callerPolicy} IS NULL`);
}
conditions.push(
`((${sameOwnerPolicyClauses.join(' OR ')}) AND sw_sub.projectId = sw_par.projectId AND sw_par.projectId IS NOT NULL)`,
);
// Handle NULL callerPolicy when default is 'any'
if (defaultPolicy === 'any') { if (defaultPolicy === 'any') {
conditions.push(`${callerPolicy} IS NULL`); conditions.push(`${callerPolicy} IS NULL`);
} }
return { conditions, params }; return this.manager
.createQueryBuilder()
.select('cpw.id')
.from(WorkflowEntity, 'cpw')
.where(`(${conditions.join(' OR ')})`, {
cpCallerIdMembership: `%,${escapeLike(parentWorkflowId)},%`,
});
} }
private settingsTextValue(field: string, key: string): string { private settingsTextValue(field: string, key: string): string {
@ -1467,14 +1585,15 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
/** /**
* Builds search conditions and parameters for matching all of the search words * Builds search conditions and parameters for matching all of the search words
*/ */
private buildSearchConditions(searchWords: string[]): { private buildSearchConditions(
searchWords: string[],
fieldExpression = this.getFieldConcatExpression(),
): {
conditions: string[]; conditions: string[];
parameters: Record<string, string>; parameters: Record<string, string>;
} { } {
const concatExpression = this.getFieldConcatExpression();
const conditions = searchWords.map((_, index) => { const conditions = searchWords.map((_, index) => {
return `${concatExpression} LIKE :searchWord${index}`; return `${fieldExpression} LIKE :searchWord${index}`;
}); });
const parameters: Record<string, string> = {}; const parameters: Record<string, string> = {};

View File

@ -1,6 +1,6 @@
{ {
"name": "@n8n/decorators", "name": "@n8n/decorators",
"version": "1.44.0", "version": "1.44.3",
"scripts": { "scripts": {
"clean": "rimraf dist .turbo", "clean": "rimraf dist .turbo",
"dev": "pnpm watch", "dev": "pnpm watch",

View File

@ -1,6 +1,6 @@
{ {
"name": "@n8n/engine", "name": "@n8n/engine",
"version": "0.24.0", "version": "0.24.1",
"description": "n8n workflow execution engine (v2)", "description": "n8n workflow execution engine (v2)",
"scripts": { "scripts": {
"clean": "rimraf dist .turbo compiled", "clean": "rimraf dist .turbo compiled",

View File

@ -1,6 +1,6 @@
{ {
"name": "@n8n/inbound-auth", "name": "@n8n/inbound-auth",
"version": "0.3.0", "version": "0.3.3",
"scripts": { "scripts": {
"clean": "rimraf dist .turbo", "clean": "rimraf dist .turbo",
"dev": "pnpm watch", "dev": "pnpm watch",

View File

@ -1,6 +1,6 @@
{ {
"name": "@n8n/instance-ai", "name": "@n8n/instance-ai",
"version": "1.29.0", "version": "1.29.5",
"scripts": { "scripts": {
"clean": "rimraf dist .turbo", "clean": "rimraf dist .turbo",
"typecheck": "tsc --noEmit && tsc -p evaluations/tsconfig.json", "typecheck": "tsc --noEmit && tsc -p evaluations/tsconfig.json",

View File

@ -1,6 +1,6 @@
{ {
"name": "@n8n/mcp-apps", "name": "@n8n/mcp-apps",
"version": "0.21.0", "version": "0.21.3",
"description": "MCP Apps UI resources and server helpers for n8n", "description": "MCP Apps UI resources and server helpers for n8n",
"main": "dist/server/index.js", "main": "dist/server/index.js",
"types": "dist/server/index.d.ts", "types": "dist/server/index.d.ts",

View File

@ -1,6 +1,6 @@
{ {
"name": "@n8n/node-cli", "name": "@n8n/node-cli",
"version": "0.53.0", "version": "0.53.4",
"description": "Official CLI for developing community nodes for n8n", "description": "Official CLI for developing community nodes for n8n",
"bin": { "bin": {
"n8n-node": "bin/n8n-node.mjs" "n8n-node": "bin/n8n-node.mjs"

View File

@ -1,6 +1,6 @@
{ {
"name": "@n8n/node-engine-compatibility", "name": "@n8n/node-engine-compatibility",
"version": "0.12.0", "version": "0.12.4",
"description": "Adapts v1 nodes and workflows to the Engine v2 execution model", "description": "Adapts v1 nodes and workflows to the Engine v2 execution model",
"scripts": { "scripts": {
"clean": "rimraf dist .turbo", "clean": "rimraf dist .turbo",

View File

@ -18,7 +18,11 @@ import {
N8nLlmTracing, N8nLlmTracing,
getConnectionHintNoticeField, getConnectionHintNoticeField,
} from '@n8n/ai-utilities'; } from '@n8n/ai-utilities';
import { shouldIncludeGoogleModel } from '@n8n/ai-utilities/model-discovery'; import {
getGoogleModelLabel,
pinPrioritizedModels,
shouldIncludeGoogleModel,
} from '@n8n/ai-utilities/model-discovery';
import { MODEL_SELECTION_HINT } from '@utils/model-builder-hints'; import { MODEL_SELECTION_HINT } from '@utils/model-builder-hints';
@ -37,6 +41,30 @@ async function filterChatModels(
); );
} }
/** Show models under the name the API reports, sorted by it, with prioritized models first. */
async function labelModels(
this: IExecuteSingleFunctions,
items: INodeExecutionData[],
): Promise<INodeExecutionData[]> {
const labelled = items.map((item) => {
// `displayName` and `priority` only carry the API's values to here; options do not keep them.
const { displayName, priority, ...option } = item.json;
const json =
typeof option.value === 'string'
? {
...option,
name: getGoogleModelLabel(
option.value,
typeof displayName === 'string' ? displayName : undefined,
),
}
: option;
return { item: { ...item, json }, priority };
});
labelled.sort((a, b) => String(a.item.json.name).localeCompare(String(b.item.json.name)));
return pinPrioritizedModels(labelled, (entry) => entry.priority).map((entry) => entry.item);
}
function errorDescriptionMapper(error: NodeError) { function errorDescriptionMapper(error: NodeError) {
if (error.description?.includes('properties: should be non-empty for OBJECT type')) { if (error.description?.includes('properties: should be non-empty for OBJECT type')) {
return 'Google Gemini requires at least one <a href="https://docs.n8n.io/advanced-ai/examples/using-the-fromai-function/" target="_blank">dynamic parameter</a> when using tools'; return 'Google Gemini requires at least one <a href="https://docs.n8n.io/advanced-ai/examples/using-the-fromai-function/" target="_blank">dynamic parameter</a> when using tools';
@ -78,14 +106,11 @@ const modelRLC: INodeProperties = {
name: '={{$responseItem.name}}', name: '={{$responseItem.name}}',
value: '={{$responseItem.name}}', value: '={{$responseItem.name}}',
description: '={{$responseItem.description}}', description: '={{$responseItem.description}}',
displayName: '={{$responseItem.displayName}}',
priority: '={{$responseItem.priority}}',
}, },
}, },
{ labelModels,
type: 'sort',
properties: {
key: 'name',
},
},
], ],
}, },
}, },
@ -133,14 +158,11 @@ const modelRLCV2: INodeProperties = {
name: '={{$responseItem.name}}', name: '={{$responseItem.name}}',
value: '={{$responseItem.name}}', value: '={{$responseItem.name}}',
description: '={{$responseItem.description}}', description: '={{$responseItem.description}}',
displayName: '={{$responseItem.displayName}}',
priority: '={{$responseItem.priority}}',
}, },
}, },
{ labelModels,
type: 'sort',
properties: {
key: 'name',
},
},
], ],
}, },
}, },

View File

@ -1,3 +1,5 @@
import type { IExecuteSingleFunctions, INodeExecutionData, PostReceiveAction } from 'n8n-workflow';
import { LmChatGoogleGemini } from '../LmChatGoogleGemini.node'; import { LmChatGoogleGemini } from '../LmChatGoogleGemini.node';
describe('LmChatGoogleGemini', () => { describe('LmChatGoogleGemini', () => {
@ -23,4 +25,72 @@ describe('LmChatGoogleGemini', () => {
expect(hint).not.toMatch(/searchModels|explore-resources|nodes\(/); expect(hint).not.toMatch(/searchModels|explore-resources|nodes\(/);
} }
}); });
it('should label, sort and pin models in every version', async () => {
const modelProperties = new LmChatGoogleGemini().description.properties.filter(
(p) => p.name === 'modelName',
);
const options: INodeExecutionData[] = [
{
json: {
name: 'models/gemini-zeta',
value: 'models/gemini-zeta',
displayName: '',
priority: '',
},
},
{
json: {
name: 'models/gemini-featured',
value: 'models/gemini-featured',
displayName: 'Gemini Featured',
priority: 10,
},
},
{ json: { name: 'models/gemini-2.5-pro', value: 'models/gemini-2.5-pro' } },
{
json: {
name: 'models/gemini-api-named',
value: 'models/gemini-api-named',
displayName: 'Gemini Api Named',
},
},
];
expect(modelProperties).toHaveLength(3);
for (const property of modelProperties) {
const postReceive = property.typeOptions?.loadOptions?.routing?.output
?.postReceive as PostReceiveAction[];
const labelModels = postReceive.at(-1);
if (typeof labelModels !== 'function') throw new Error('Expected a postReceive function');
const result = await labelModels.call({} as IExecuteSingleFunctions, options, {} as never);
// The API's displayName labels a model, else its id. A priority pins a model.
expect(result.map((option) => option.json)).toEqual([
{ name: 'Gemini Featured', value: 'models/gemini-featured' },
{ name: 'Gemini Api Named', value: 'models/gemini-api-named' },
{ name: 'models/gemini-2.5-pro', value: 'models/gemini-2.5-pro' },
{ name: 'models/gemini-zeta', value: 'models/gemini-zeta' },
]);
}
});
it('should leave the label of an option without a string value alone', async () => {
const property = new LmChatGoogleGemini().description.properties.find(
(p) => p.name === 'modelName',
);
const postReceive = property?.typeOptions?.loadOptions?.routing?.output
?.postReceive as PostReceiveAction[];
const labelModels = postReceive.at(-1);
if (typeof labelModels !== 'function') throw new Error('Expected a postReceive function');
const result = await labelModels.call(
{} as IExecuteSingleFunctions,
[{ json: { name: 'Unknown', value: 42, displayName: 'Ignored' } }],
{} as never,
);
expect(result.map((option) => option.json)).toEqual([{ name: 'Unknown', value: 42 }]);
});
}); });

View File

@ -1098,6 +1098,10 @@ export class ChatTrigger extends Node {
// By header as well as by the iframe's attribute, so the document has no // By header as well as by the iframe's attribute, so the document has no
// origin even if the attribute is ever stripped. // origin even if the attribute is ever stripped.
res.setHeader('Content-Security-Policy', `sandbox ${CHAT_FRAME_SANDBOX}`); res.setHeader('Content-Security-Policy', `sandbox ${CHAT_FRAME_SANDBOX}`);
} else {
// The webhook route sandboxes every response. This page keeps its origin,
// because the widget keeps the session in `localStorage`.
res.removeHeader('Content-Security-Policy');
} }
const page = createPage({ const page = createPage({

View File

@ -984,6 +984,17 @@ describe('ChatTrigger Node', () => {
expect(renderedView().sandbox).not.toContain('allow-popups-to-escape-sandbox'); expect(renderedView().sandbox).not.toContain('allow-popups-to-escape-sandbox');
}); });
it('removes the route sandbox from the public chat page', async () => {
await renderSetupPage('none');
expect(mockResponse.removeHeader).toHaveBeenCalledWith('Content-Security-Policy');
expect(mockResponse.setHeader).not.toHaveBeenCalledWith(
'Content-Security-Policy',
expect.anything(),
);
expect(renderedPage()).toContain('createChat');
});
it('renders the author chat for the frame own request', async () => { it('renders the author chat for the frame own request', async () => {
mockRequest.query = { n8nShellInner: '1' }; mockRequest.query = { n8nShellInner: '1' };
mockRequest.headers = { mockRequest.headers = {
@ -998,6 +1009,7 @@ describe('ChatTrigger Node', () => {
'Content-Security-Policy', 'Content-Security-Policy',
'sandbox allow-scripts allow-forms allow-modals allow-popups', 'sandbox allow-scripts allow-forms allow-modals allow-popups',
); );
expect(mockResponse.removeHeader).not.toHaveBeenCalled();
expect(renderedPage()).toContain('createChat'); expect(renderedPage()).toContain('createChat');
// Identity injected server-side, and a header token for the messages that follow. // Identity injected server-side, and a header token for the messages that follow.
expect(renderedPage()).toContain('"email":"visitor@example.com"'); expect(renderedPage()).toContain('"email":"visitor@example.com"');

View File

@ -1,5 +1,6 @@
import { MongoClient } from 'mongodb'; import { MongoClient } from 'mongodb';
import type { ILoadOptionsFunctions, ISupplyDataFunctions } from 'n8n-workflow'; import type { ILoadOptionsFunctions, ISupplyDataFunctions } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { mock } from 'vitest-mock-extended'; import { mock } from 'vitest-mock-extended';
import { import {
@ -144,6 +145,30 @@ describe('VectorStoreMongoDBAtlas', () => {
expect(mockClient.connect).toHaveBeenCalledTimes(1); expect(mockClient.connect).toHaveBeenCalledTimes(1);
expect(client).toBe(mockClient); expect(client).toBe(mockClient);
}); });
it('should not include the connection string in the error when the client cannot be created', async () => {
MockMongoClient.mockImplementation(function () {
throw new Error(
'Protocol and host list are required in "mongodb://testuser:secret-pass@?x:27017"',
);
});
mockContext.getCredentials.mockResolvedValue({
configurationType: 'values',
host: '?x',
user: 'testuser',
password: 'secret-pass',
port: 27017,
database: 'testdb',
});
const error = await createMongoClient(mockContext, 1.1).catch((e: unknown) => e);
expect(error).toBeInstanceOf(NodeOperationError);
expect((error as Error).message).toBe(
'MongoDB connection error: Protocol and host list are required in "mongodb://[REDACTED]"',
);
expect(JSON.stringify(error)).not.toContain('secret-pass');
expect((error as Error).stack).not.toContain('secret-pass');
});
}); });
describe('.getCollections', () => { describe('.getCollections', () => {

View File

@ -11,7 +11,10 @@ import {
} from 'n8n-workflow'; } from 'n8n-workflow';
import { metadataFilterField, createVectorStoreNode } from '@n8n/ai-utilities'; import { metadataFilterField, createVectorStoreNode } from '@n8n/ai-utilities';
import { validateAndResolveMongoCredentials } from 'n8n-nodes-base/dist/nodes/MongoDb/GenericFunctions'; import {
sanitizeMongoUriInMessage,
validateAndResolveMongoCredentials,
} from 'n8n-nodes-base/dist/nodes/MongoDb/GenericFunctions';
/** /**
* Constants for the name of the credentials and Node parameters. * Constants for the name of the credentials and Node parameters.
@ -169,15 +172,22 @@ export async function createMongoClient(
const node = context.getNode(); const node = context.getNode();
const { connectionString } = validateAndResolveMongoCredentials(node, credentials); const { connectionString } = validateAndResolveMongoCredentials(node, credentials);
const client = new MongoClient(connectionString, { try {
appName: 'devrel.integration.n8n_vector_integ', const client = new MongoClient(connectionString, {
driverInfo: { appName: 'devrel.integration.n8n_vector_integ',
name: 'n8n_vector', driverInfo: {
version: version.toString(), name: 'n8n_vector',
}, version: version.toString(),
}); },
await client.connect(); });
return client; await client.connect();
return client;
} catch (error) {
throw new NodeOperationError(
node,
`MongoDB connection error: ${sanitizeMongoUriInMessage(error, connectionString)}`,
);
}
} }
/** /**

Some files were not shown because too many files have changed in this diff Show More