mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-19 16:55:02 +02:00
Compare commits
27 Commits
master
...
n8n@2.35.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
602f737443 | ||
|
|
af53a1a71c | ||
|
|
17926a0e8b | ||
|
|
f97d74fc03 | ||
|
|
738fa9b1f9 | ||
|
|
7ee5295ed5 | ||
|
|
c6c3c4f42f | ||
|
|
d2ce3c084c | ||
|
|
00aad642c2 | ||
|
|
fa781db207 | ||
|
|
7f7f0a1964 | ||
|
|
a8b2716344 | ||
|
|
d8e3ee6c7e | ||
|
|
0dccd14a44 | ||
|
|
a29cabc294 | ||
|
|
69140c0610 | ||
|
|
75780ec66f | ||
|
|
e58a68016b | ||
|
|
66ba995eed | ||
|
|
0eba018529 | ||
|
|
3c078f45c2 | ||
|
|
07e3927d0c | ||
|
|
e80209024e | ||
|
|
b81e52d76d | ||
|
|
2f6059f686 | ||
|
|
2a53043249 | ||
|
|
2e2ad77114 |
60
CHANGELOG.md
60
CHANGELOG.md
|
|
@ -1,3 +1,63 @@
|
|||
## [2.35.5](https://github.com/n8n-io/n8n/compare/n8n@2.35.4...n8n@2.35.5) (2026-08-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **core:** Avoid restarting task runners that are only slow ([#36610](https://github.com/n8n-io/n8n/issues/36610)) ([7ee5295](https://github.com/n8n-io/n8n/commit/7ee5295ed58ccf2b7921ac22a6fc89a315b11bfe))
|
||||
* **core:** Only init the expression engine for commands that evaluate expressions ([#36648](https://github.com/n8n-io/n8n/issues/36648)) ([af53a1a](https://github.com/n8n-io/n8n/commit/af53a1a71c8ec3428ef130e524bcc645a82d4ddf))
|
||||
* **core:** Release test-webhook isolate only after teardown completes ([#36632](https://github.com/n8n-io/n8n/issues/36632)) ([17926a0](https://github.com/n8n-io/n8n/commit/17926a0e8b665bc99a71ef8b7baa35ec85494c65))
|
||||
* **editor:** Restore Edit Fields type selector styles ([#36628](https://github.com/n8n-io/n8n/issues/36628)) ([f97d74f](https://github.com/n8n-io/n8n/commit/f97d74fc039da374eaa2b4e701b19f8c0a1e8ad9))
|
||||
|
||||
|
||||
## [2.35.4](https://github.com/n8n-io/n8n/compare/n8n@2.35.3...n8n@2.35.4) (2026-08-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **API:** Return a request schema for decorator routes in /discover ([#36497](https://github.com/n8n-io/n8n/issues/36497)) ([fa781db](https://github.com/n8n-io/n8n/commit/fa781db2079eff0db5675fb2cd63558629db3dd7))
|
||||
* **Google Ads Node:** Select the renamed video views metric on API v25 ([#36414](https://github.com/n8n-io/n8n/issues/36414)) ([a8b2716](https://github.com/n8n-io/n8n/commit/a8b2716344fa5c3dacccc524bcdfce04df3ed7e7))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **core:** Add AI Assistant setup funnel telemetry ([#36464](https://github.com/n8n-io/n8n/issues/36464)) ([7f7f0a1](https://github.com/n8n-io/n8n/commit/7f7f0a19642bb9e8d0938c98daba0e3bb7ecf7cf))
|
||||
* Show scopes for managed OAuth credentials with N8N_MANAGED_OAUTH_SHOW_SCOPES ([#36397](https://github.com/n8n-io/n8n/issues/36397)) ([d8e3ee6](https://github.com/n8n-io/n8n/commit/d8e3ee6c7ea7bdbfa395a40687ca0d70037c98bf))
|
||||
|
||||
|
||||
## [2.35.3](https://github.com/n8n-io/n8n/compare/n8n@2.35.2...n8n@2.35.3) (2026-08-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **core:** Allow `details` field in `continueErrorOutput` mode ([#36228](https://github.com/n8n-io/n8n/issues/36228)) ([e58a680](https://github.com/n8n-io/n8n/commit/e58a68016b881f29b2509f6c9d1ba093edf7b83b))
|
||||
* **core:** Bound workflow publication outbox record processing with an abort deadline ([#36286](https://github.com/n8n-io/n8n/issues/36286)) ([69140c0](https://github.com/n8n-io/n8n/commit/69140c0610754ef3a8da66d23fb2f8b00e1d256d))
|
||||
* **editor:** Update ready-to-run demo templates to a current OpenAI model ([#36201](https://github.com/n8n-io/n8n/issues/36201)) ([66ba995](https://github.com/n8n-io/n8n/commit/66ba995eed583cf0eb782dcba8e0872bc3b270be))
|
||||
* **Google Ads Node:** Migrate from sunset v21 API to v25 ([#36259](https://github.com/n8n-io/n8n/issues/36259)) ([75780ec](https://github.com/n8n-io/n8n/commit/75780ec66f08e5de10327cc2bd445dd4fd4ae56c))
|
||||
* **Microsoft Teams Node:** Restore Group.ReadWrite.All OAuth2 scope ([#36186](https://github.com/n8n-io/n8n/issues/36186)) ([0eba018](https://github.com/n8n-io/n8n/commit/0eba018529593012d5d21b2723c3a3accc02bac6))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **core:** Skip update approval for workflows created in the same Instance AI session ([#36106](https://github.com/n8n-io/n8n/issues/36106)) ([a29cabc](https://github.com/n8n-io/n8n/commit/a29cabc2946fcf758b1cc5c2e143bd3d779c620c))
|
||||
|
||||
|
||||
## [2.35.2](https://github.com/n8n-io/n8n/compare/n8n@2.35.1...n8n@2.35.2) (2026-08-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **core:** Report the real activation mode for triggers activated via the publication outbox ([#36151](https://github.com/n8n-io/n8n/issues/36151)) ([07e3927](https://github.com/n8n-io/n8n/commit/07e3927d0ced53aa1572d278933c424a1bc265ee))
|
||||
|
||||
|
||||
## [2.35.1](https://github.com/n8n-io/n8n/compare/n8n@2.35.0...n8n@2.35.1) (2026-08-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **core:** Accept Always allow scope on data-tables resume ([#36080](https://github.com/n8n-io/n8n/issues/36080)) ([b81e52d](https://github.com/n8n-io/n8n/commit/b81e52d76d05ab90bf77fcd201b5e29e4e05ea37))
|
||||
* **core:** Apply TLS options per hop when requests go through a proxy ([#36016](https://github.com/n8n-io/n8n/issues/36016)) ([2e2ad77](https://github.com/n8n-io/n8n/commit/2e2ad771142487b105d2e5590ba70528c69b750b))
|
||||
|
||||
|
||||
# [2.35.0](https://github.com/n8n-io/n8n/compare/n8n@2.34.0...n8n@2.35.0) (2026-08-11)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand.
|
|||
| [public.workflow_dependency](public.workflow_dependency.md) | 9 | | BASE TABLE |
|
||||
| [public.workflow_entity](public.workflow_entity.md) | 20 | | BASE TABLE |
|
||||
| [public.workflow_history](public.workflow_history.md) | 11 | | BASE TABLE |
|
||||
| [public.workflow_publication_outbox](public.workflow_publication_outbox.md) | 7 | | BASE TABLE |
|
||||
| [public.workflow_publication_outbox](public.workflow_publication_outbox.md) | 8 | | BASE TABLE |
|
||||
| [public.workflow_publication_trigger_status](public.workflow_publication_trigger_status.md) | 8 | | BASE TABLE |
|
||||
| [public.workflow_publish_history](public.workflow_publish_history.md) | 6 | | BASE TABLE |
|
||||
| [public.workflow_published_version](public.workflow_published_version.md) | 4 | | BASE TABLE |
|
||||
|
|
@ -1478,6 +1478,7 @@ erDiagram
|
|||
text errorMessage
|
||||
integer id
|
||||
varchar_36_ publishedVersionId
|
||||
varchar_20_ reason
|
||||
varchar_20_ status
|
||||
timestamp_3__with_time_zone updatedAt
|
||||
varchar_36_ workflowId
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
| errorMessage | text | | true | | | Error details for surfacing failed publications to the user. |
|
||||
| id | integer | | false | | | |
|
||||
| publishedVersionId | varchar(36) | | false | | | References workflow_history.versionId. |
|
||||
| reason | varchar(20) | 'publish'::character varying | false | | | Why this publication was enqueued: user publish/unpublish, leader startup pass, leadership takeover, or reconciler drift-heal |
|
||||
| status | varchar(20) | | false | | | |
|
||||
| updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
|
||||
| workflowId | varchar(36) | | false | | | References workflow_entity.id. |
|
||||
|
|
@ -16,11 +17,13 @@
|
|||
|
||||
| Name | Type | Definition |
|
||||
| ---- | ---- | ---------- |
|
||||
| CHK_workflow_publication_outbox_reason | CHECK | CHECK (((reason)::text = ANY ((ARRAY['publish'::character varying, 'startup'::character varying, 'leadership-takeover'::character varying, 'reconcile'::character varying])::text[]))) |
|
||||
| CHK_workflow_publication_outbox_status | CHECK | CHECK (((status)::text = ANY ((ARRAY['pending'::character varying, 'in_progress'::character varying, 'completed'::character varying, 'partial_success'::character varying, 'failed'::character varying])::text[]))) |
|
||||
| PK_b3e2eeee36a4bd044d56468d311 | PRIMARY KEY | PRIMARY KEY (id) |
|
||||
| workflow_publication_outbox_createdAt_not_null | n | NOT NULL "createdAt" |
|
||||
| workflow_publication_outbox_id_not_null | n | NOT NULL id |
|
||||
| workflow_publication_outbox_publishedVersionId_not_null | n | NOT NULL "publishedVersionId" |
|
||||
| workflow_publication_outbox_reason_not_null | n | NOT NULL reason |
|
||||
| workflow_publication_outbox_status_not_null | n | NOT NULL status |
|
||||
| workflow_publication_outbox_updatedAt_not_null | n | NOT NULL "updatedAt" |
|
||||
| workflow_publication_outbox_workflowId_not_null | n | NOT NULL "workflowId" |
|
||||
|
|
@ -43,6 +46,7 @@ erDiagram
|
|||
text errorMessage
|
||||
integer id
|
||||
varchar_36_ publishedVersionId
|
||||
varchar_20_ reason
|
||||
varchar_20_ status
|
||||
timestamp_3__with_time_zone updatedAt
|
||||
varchar_36_ workflowId
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand.
|
|||
| [workflow_dependency](workflow_dependency.md) | 9 | | table |
|
||||
| [workflow_entity](workflow_entity.md) | 20 | | table |
|
||||
| [workflow_history](workflow_history.md) | 11 | | table |
|
||||
| [workflow_publication_outbox](workflow_publication_outbox.md) | 7 | | table |
|
||||
| [workflow_publication_outbox](workflow_publication_outbox.md) | 8 | | table |
|
||||
| [workflow_publication_trigger_status](workflow_publication_trigger_status.md) | 8 | | table |
|
||||
| [workflow_publish_history](workflow_publish_history.md) | 6 | | table |
|
||||
| [workflow_published_version](workflow_published_version.md) | 4 | | table |
|
||||
|
|
@ -1467,6 +1467,7 @@ erDiagram
|
|||
TEXT errorMessage
|
||||
INTEGER id
|
||||
varchar_36_ publishedVersionId
|
||||
varchar_20_ reason
|
||||
varchar_20_ status
|
||||
datetime_3_ updatedAt
|
||||
varchar_36_ workflowId
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
<summary><strong>Table Definition</strong></summary>
|
||||
|
||||
```sql
|
||||
CREATE TABLE "workflow_publication_outbox" ("id" integer PRIMARY KEY NOT NULL, "workflowId" varchar(36) NOT NULL, "publishedVersionId" varchar(36) NOT NULL, "status" varchar(20) NOT NULL, "errorMessage" text, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "CHK_workflow_publication_outbox_status" CHECK ("status" IN ('pending', 'in_progress', 'completed', 'partial_success', 'failed')))
|
||||
CREATE TABLE "workflow_publication_outbox" ("id" integer PRIMARY KEY NOT NULL, "workflowId" varchar(36) NOT NULL, "publishedVersionId" varchar(36) NOT NULL, "status" varchar(20) NOT NULL, "errorMessage" text, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "reason" varchar(20) NOT NULL DEFAULT ('publish'), CONSTRAINT "CHK_workflow_publication_outbox_status" CHECK (("status" IN ('pending', 'in_progress', 'completed', 'partial_success', 'failed'))), CONSTRAINT "CHK_workflow_publication_outbox_reason" CHECK ("reason" IN ('publish', 'startup', 'leadership-takeover', 'reconcile')))
|
||||
```
|
||||
|
||||
</details>
|
||||
|
|
@ -19,6 +19,7 @@ CREATE TABLE "workflow_publication_outbox" ("id" integer PRIMARY KEY NOT NULL, "
|
|||
| errorMessage | TEXT | | true | | | |
|
||||
| id | INTEGER | | false | | | |
|
||||
| publishedVersionId | varchar(36) | | false | | | |
|
||||
| reason | varchar(20) | 'publish' | false | | | |
|
||||
| status | varchar(20) | | false | | | |
|
||||
| updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
|
||||
| workflowId | varchar(36) | | false | | | |
|
||||
|
|
@ -27,7 +28,8 @@ CREATE TABLE "workflow_publication_outbox" ("id" integer PRIMARY KEY NOT NULL, "
|
|||
|
||||
| Name | Type | Definition |
|
||||
| ---- | ---- | ---------- |
|
||||
| - | CHECK | CHECK ("status" IN ('pending', 'in_progress', 'completed', 'partial_success', 'failed')) |
|
||||
| - | CHECK | CHECK (("status" IN ('pending', 'in_progress', 'completed', 'partial_success', 'failed'))) |
|
||||
| - | CHECK | CHECK ("reason" IN ('publish', 'startup', 'leadership-takeover', 'reconcile')) |
|
||||
| id | PRIMARY KEY | PRIMARY KEY (id) |
|
||||
|
||||
## Indexes
|
||||
|
|
@ -47,6 +49,7 @@ erDiagram
|
|||
TEXT errorMessage
|
||||
INTEGER id
|
||||
varchar_36_ publishedVersionId
|
||||
varchar_20_ reason
|
||||
varchar_20_ status
|
||||
datetime_3_ updatedAt
|
||||
varchar_36_ workflowId
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n-monorepo",
|
||||
"version": "2.35.0",
|
||||
"version": "2.35.5",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=22.22",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/agents",
|
||||
"version": "0.20.0",
|
||||
"version": "0.20.4",
|
||||
"description": "AI agent SDK for n8n's code-first execution engine",
|
||||
"engines": {
|
||||
"node": ">=22.22"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/ai-node-sdk",
|
||||
"version": "0.25.0",
|
||||
"version": "0.25.4",
|
||||
"description": "SDK for building AI nodes in n8n",
|
||||
"types": "dist/esm/index.d.ts",
|
||||
"module": "dist/esm/index.js",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/ai-utilities",
|
||||
"version": "0.28.0",
|
||||
"version": "0.28.4",
|
||||
"description": "Utilities for building AI nodes in n8n",
|
||||
"types": "dist/esm/index.d.ts",
|
||||
"module": "dist/esm/index.js",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/ai-workflow-builder",
|
||||
"version": "1.35.0",
|
||||
"version": "1.35.4",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"typecheck": "tsc --noEmit",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/api-types",
|
||||
"version": "1.35.0",
|
||||
"version": "1.35.4",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -352,6 +352,8 @@ export type FrontendModuleSettings = {
|
|||
sandboxUnavailableReason: string | null;
|
||||
/** When true, orchestrator LLM step / workflow code debug is captured (`N8N_INSTANCE_AI_RUN_DEBUG_ENABLED`). */
|
||||
runDebugEnabled: boolean;
|
||||
/** Whether this instance is in the activation-capped trial cohort (`N8N_INSTANCE_AI_ACTIVATION_CAPPED`). Optional. */
|
||||
activationCapped?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -286,6 +286,8 @@ export {
|
|||
|
||||
export {
|
||||
buildRunWorkflowSessionGrantKey,
|
||||
buildUpdateWorkflowSessionGrantKey,
|
||||
buildDataTablesSessionGrantKey,
|
||||
buildFetchUrlGrantKey,
|
||||
FETCH_URL_ALLOW_ALL_GRANT_KEY,
|
||||
WEB_SEARCH_GRANT_KEY,
|
||||
|
|
@ -381,6 +383,7 @@ export {
|
|||
instanceAiEvalSeedAgentSchema,
|
||||
findUnbackedSeedWorkflowTools,
|
||||
applyBranchReadOnlyOverrides,
|
||||
deriveInstanceAiSetupState,
|
||||
INSTANCE_AI_THREAD_SOURCES,
|
||||
INSTANCE_AI_THREAD_SOURCE_FALLBACK,
|
||||
} from './schemas/instance-ai.schema';
|
||||
|
|
@ -392,6 +395,11 @@ export type {
|
|||
InstanceAiCatalogProvider,
|
||||
InstanceAiCatalogModel,
|
||||
InstanceAiModelCatalogResponse,
|
||||
InstanceAiCredits,
|
||||
InstanceAiComponentSource,
|
||||
InstanceAiWebSearchSource,
|
||||
InstanceAiSetupState,
|
||||
InstanceAiSetupStateInput,
|
||||
} from './schemas/instance-ai.schema';
|
||||
|
||||
export type {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { ToolCategory } from '../schemas/instance-ai.schema';
|
||||
import type { InstanceAiCredits, ToolCategory } from '../schemas/instance-ai.schema';
|
||||
|
||||
export type InstanceAiPushMessage =
|
||||
| {
|
||||
|
|
@ -20,9 +20,7 @@ export type InstanceAiPushMessage =
|
|||
}
|
||||
| {
|
||||
type: 'updateInstanceAiCredits';
|
||||
data: {
|
||||
creditsQuota: number;
|
||||
creditsClaimed: number;
|
||||
data: InstanceAiCredits & {
|
||||
// Present only on a per-message claim that carries the acting thread's
|
||||
// running total (decimal). Grouped so the thread id and its total always
|
||||
// travel together — never one without the other.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,115 @@
|
|||
import { deriveInstanceAiSetupState, type InstanceAiSetupStateInput } from '../instance-ai.schema';
|
||||
|
||||
const unconfigured: InstanceAiSetupStateInput = {
|
||||
modelEnvConfigured: false,
|
||||
modelCredentialId: null,
|
||||
modelName: null,
|
||||
sandboxEnabled: false,
|
||||
sandboxEnvConfigured: false,
|
||||
sandboxProvider: 'n8n-sandbox',
|
||||
daytonaCredentialId: null,
|
||||
n8nSandboxCredentialId: null,
|
||||
searchCredentialId: null,
|
||||
searchEnvConfigured: false,
|
||||
searchDisabled: false,
|
||||
};
|
||||
|
||||
describe('deriveInstanceAiSetupState', () => {
|
||||
it('reports everything as unconfigured and setup incomplete by default', () => {
|
||||
expect(deriveInstanceAiSetupState(unconfigured)).toEqual({
|
||||
modelSource: 'none',
|
||||
sandboxSource: 'none',
|
||||
sandboxType: null,
|
||||
sandboxCredentialId: null,
|
||||
webSearchSource: 'none',
|
||||
setupCompleted: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports UI sources and completion when every component has a credential', () => {
|
||||
expect(
|
||||
deriveInstanceAiSetupState({
|
||||
...unconfigured,
|
||||
modelCredentialId: 'model-cred',
|
||||
modelName: 'gpt-4',
|
||||
sandboxEnabled: true,
|
||||
sandboxProvider: 'daytona',
|
||||
daytonaCredentialId: 'day-cred',
|
||||
searchCredentialId: 'search-cred',
|
||||
}),
|
||||
).toEqual({
|
||||
modelSource: 'ui',
|
||||
sandboxSource: 'ui',
|
||||
sandboxType: 'daytona',
|
||||
sandboxCredentialId: 'day-cred',
|
||||
webSearchSource: 'ui',
|
||||
setupCompleted: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports env sources and completion for an env-var-only setup', () => {
|
||||
expect(
|
||||
deriveInstanceAiSetupState({
|
||||
...unconfigured,
|
||||
modelEnvConfigured: true,
|
||||
sandboxEnabled: true,
|
||||
sandboxEnvConfigured: true,
|
||||
searchEnvConfigured: true,
|
||||
}),
|
||||
).toEqual({
|
||||
modelSource: 'env',
|
||||
sandboxSource: 'env',
|
||||
sandboxType: 'n8n-sandbox',
|
||||
sandboxCredentialId: null,
|
||||
webSearchSource: 'env',
|
||||
setupCompleted: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not count a model credential without a model name', () => {
|
||||
const state = deriveInstanceAiSetupState({
|
||||
...unconfigured,
|
||||
modelCredentialId: 'model-cred',
|
||||
});
|
||||
|
||||
expect(state.modelSource).toBe('none');
|
||||
expect(state.setupCompleted).toBe(false);
|
||||
});
|
||||
|
||||
it('does not count a sandbox credential for the unselected provider', () => {
|
||||
const state = deriveInstanceAiSetupState({
|
||||
...unconfigured,
|
||||
sandboxEnabled: true,
|
||||
sandboxProvider: 'daytona',
|
||||
n8nSandboxCredentialId: 'sandbox-cred',
|
||||
});
|
||||
|
||||
expect(state.sandboxSource).toBe('none');
|
||||
expect(state.sandboxCredentialId).toBeNull();
|
||||
});
|
||||
|
||||
it('does not count a disabled sandbox as configured', () => {
|
||||
const state = deriveInstanceAiSetupState({
|
||||
...unconfigured,
|
||||
sandboxEnabled: false,
|
||||
daytonaCredentialId: 'day-cred',
|
||||
sandboxProvider: 'daytona',
|
||||
});
|
||||
|
||||
expect(state.sandboxSource).toBe('none');
|
||||
expect(state.sandboxType).toBeNull();
|
||||
});
|
||||
|
||||
it('treats an explicit web search opt-out as a decision that completes setup', () => {
|
||||
const state = deriveInstanceAiSetupState({
|
||||
...unconfigured,
|
||||
modelEnvConfigured: true,
|
||||
sandboxEnabled: true,
|
||||
sandboxEnvConfigured: true,
|
||||
searchDisabled: true,
|
||||
});
|
||||
|
||||
expect(state.webSearchSource).toBe('disabled');
|
||||
expect(state.setupCompleted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import {
|
||||
AI_GATEWAY_MANAGED_TAG,
|
||||
applyBranchReadOnlyOverrides,
|
||||
buildDataTablesSessionGrantKey,
|
||||
buildUpdateWorkflowSessionGrantKey,
|
||||
buildFetchUrlGrantKey,
|
||||
DEFAULT_INSTANCE_AI_PERMISSIONS,
|
||||
errorPayloadSchema,
|
||||
|
|
@ -431,6 +433,19 @@ describe('instance-ai launch schema', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('data-tables session grant keys', () => {
|
||||
it('builds action-scoped keys matching the frontend always-allow format', () => {
|
||||
expect(buildDataTablesSessionGrantKey('create')).toBe('data-tables:create');
|
||||
expect(buildDataTablesSessionGrantKey('insert-rows')).toBe('data-tables:insert-rows');
|
||||
});
|
||||
});
|
||||
|
||||
describe('workflow update session grant keys', () => {
|
||||
it('builds per-workflow keys matching the frontend always-allow format', () => {
|
||||
expect(buildUpdateWorkflowSessionGrantKey('wf-1')).toBe('workflows:update:wf-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('domain-access grant keys', () => {
|
||||
it('builds and parses per-host grant keys round-trip', () => {
|
||||
const key = buildFetchUrlGrantKey('example.com');
|
||||
|
|
|
|||
|
|
@ -16,6 +16,20 @@ import { TimeZoneSchema } from './timezone.schema';
|
|||
*/
|
||||
export const UNLIMITED_CREDITS = -1;
|
||||
|
||||
/**
|
||||
* The instance's AI Assistant credit standing, as reported by `GET /instance-ai/credits`, by the
|
||||
* `updateInstanceAiCredits` push and by the internal callers that pass it around.
|
||||
*
|
||||
* `creditsQuota` is {@link UNLIMITED_CREDITS} when credits are not metered — either the proxy is
|
||||
* disabled, or the amounts are deliberately withheld from a cohort that must not see a balance.
|
||||
*/
|
||||
export type InstanceAiCredits = {
|
||||
creditsQuota: number;
|
||||
creditsClaimed: number;
|
||||
/** Whether the pool has been locked by the activation cap. */
|
||||
quotaLocked?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Transient setup-state tag for an AI Gateway managed credential selection.
|
||||
* Handlers convert this tag to the Ai Gateway managed credential shape.
|
||||
|
|
@ -38,6 +52,27 @@ export function buildRunWorkflowSessionGrantKey(workflowId: string): string {
|
|||
return `executions:run:${workflowId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the thread-level grant key for updating a specific workflow without HITL.
|
||||
*
|
||||
* Written automatically when the agent creates a workflow in this thread, so follow-up
|
||||
* edits to that same artifact (same run or later runs in the session) skip the update
|
||||
* approval prompt. Foreign workflows still require approval unless the admin policy is
|
||||
* `always_allow`.
|
||||
*/
|
||||
export function buildUpdateWorkflowSessionGrantKey(workflowId: string): string {
|
||||
return `workflows:update:${workflowId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the thread-level "always allow" grant key for a data-tables action
|
||||
* (e.g. `create`, `insert-rows`). Must match the frontend key
|
||||
* `${toolName}:${action}` so UI auto-approve and persisted grants stay aligned.
|
||||
*/
|
||||
export function buildDataTablesSessionGrantKey(action: string): string {
|
||||
return `data-tables:${action}`;
|
||||
}
|
||||
|
||||
// --- Domain-access grants ("always allow" for web access) ---
|
||||
// These keys mirror the research tool's action names (`fetch-url`, `web-search`) the same
|
||||
// way `executions:run:<id>` mirrors the executions `run` action, so a persisted grant row
|
||||
|
|
@ -585,7 +620,12 @@ export const confirmationRequestPayloadSchema = z.object({
|
|||
.array(workflowSetupNodeSchema)
|
||||
.optional()
|
||||
.describe('Per-node setup cards for workflow credential/parameter configuration'),
|
||||
workflowId: z.string().optional().describe('Workflow ID for setup-workflow tool'),
|
||||
workflowId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Workflow ID for setup cards and per-workflow edit approvals (build-workflow / workflows update)',
|
||||
),
|
||||
resourceDecision: gatewayConfirmationRequiredPayloadSchema
|
||||
.optional()
|
||||
.describe('Gateway resource-access decision data (inputType=resource-decision)'),
|
||||
|
|
@ -1558,6 +1598,79 @@ export interface InstanceAiAdminSettingsResponse {
|
|||
browserUseEnabled: boolean;
|
||||
}
|
||||
|
||||
export type InstanceAiComponentSource = 'ui' | 'env' | 'none';
|
||||
export type InstanceAiWebSearchSource = InstanceAiComponentSource | 'disabled';
|
||||
|
||||
export type InstanceAiSetupStateInput = Pick<
|
||||
InstanceAiAdminSettingsResponse,
|
||||
| 'modelEnvConfigured'
|
||||
| 'modelCredentialId'
|
||||
| 'modelName'
|
||||
| 'sandboxEnabled'
|
||||
| 'sandboxEnvConfigured'
|
||||
| 'sandboxProvider'
|
||||
| 'daytonaCredentialId'
|
||||
| 'n8nSandboxCredentialId'
|
||||
| 'searchCredentialId'
|
||||
| 'searchEnvConfigured'
|
||||
| 'searchDisabled'
|
||||
>;
|
||||
|
||||
export interface InstanceAiSetupState {
|
||||
modelSource: InstanceAiComponentSource;
|
||||
sandboxSource: InstanceAiComponentSource;
|
||||
sandboxType: InstanceAiSandboxProvider | null;
|
||||
/** Credential assigned for the selected sandbox provider; a credential for the other provider does not count. */
|
||||
sandboxCredentialId: string | null;
|
||||
webSearchSource: InstanceAiWebSearchSource;
|
||||
/** Model and sandbox configured, and web search decided (configured or explicitly disabled). */
|
||||
setupCompleted: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* How each AI Assistant setup component is configured, derived from the admin
|
||||
* settings response. Single source of truth for the setup gate and the setup
|
||||
* telemetry snapshot, on both backend and frontend. The response already
|
||||
* resolves precedence (credential ids are null when env config wins), so env
|
||||
* before ui here does not shadow a UI selection.
|
||||
*/
|
||||
export function deriveInstanceAiSetupState(
|
||||
settings: InstanceAiSetupStateInput,
|
||||
): InstanceAiSetupState {
|
||||
const modelSource: InstanceAiComponentSource = settings.modelEnvConfigured
|
||||
? 'env'
|
||||
: settings.modelCredentialId && settings.modelName
|
||||
? 'ui'
|
||||
: 'none';
|
||||
const sandboxCredentialId =
|
||||
settings.sandboxProvider === 'daytona'
|
||||
? settings.daytonaCredentialId
|
||||
: settings.n8nSandboxCredentialId;
|
||||
const sandboxSource: InstanceAiComponentSource = !settings.sandboxEnabled
|
||||
? 'none'
|
||||
: settings.sandboxEnvConfigured
|
||||
? 'env'
|
||||
: sandboxCredentialId
|
||||
? 'ui'
|
||||
: 'none';
|
||||
const webSearchSource: InstanceAiWebSearchSource = settings.searchCredentialId
|
||||
? 'ui'
|
||||
: settings.searchEnvConfigured
|
||||
? 'env'
|
||||
: settings.searchDisabled
|
||||
? 'disabled'
|
||||
: 'none';
|
||||
return {
|
||||
modelSource,
|
||||
sandboxSource,
|
||||
sandboxType: sandboxSource === 'none' ? null : settings.sandboxProvider,
|
||||
sandboxCredentialId,
|
||||
webSearchSource,
|
||||
setupCompleted:
|
||||
modelSource !== 'none' && sandboxSource !== 'none' && webSearchSource !== 'none',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline provider-connection payload: the credential type plus its field
|
||||
* values. `null` clears the connection (and falls back to env config).
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/backend-common",
|
||||
"version": "1.35.0",
|
||||
"version": "1.35.4",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/backend-network",
|
||||
"version": "1.9.0",
|
||||
"version": "1.9.4",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
import type { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import type http from 'node:http';
|
||||
|
||||
import { makeSsrfBridge } from '../../ssrf/__tests__/mock-ssrf-bridge';
|
||||
import { installProxyConnectionGuard } from '../connection-guards';
|
||||
|
||||
type ConnectOpts = Parameters<HttpsProxyAgent<string>['connect']>[1];
|
||||
|
||||
describe('installProxyConnectionGuard', () => {
|
||||
function guarded(connectOpts: { host?: string | null; hostname?: string | null }) {
|
||||
const connect = vi.fn().mockResolvedValue('SOCKET');
|
||||
const agent = { connectOpts, connect };
|
||||
return { agent, connect };
|
||||
}
|
||||
|
||||
it.each([
|
||||
['host', { host: 'proxy.internal' }],
|
||||
['hostname', { hostname: 'proxy.internal' }],
|
||||
])(
|
||||
'reads the proxy host from `%s` and delegates when the policy allows it',
|
||||
async (_label, connectOpts) => {
|
||||
const bridge = makeSsrfBridge();
|
||||
const { agent, connect } = guarded(connectOpts);
|
||||
installProxyConnectionGuard(agent, bridge);
|
||||
const req = {} as http.ClientRequest;
|
||||
const opts = {} as ConnectOpts;
|
||||
|
||||
await expect(agent.connect(req, opts)).resolves.toBe('SOCKET');
|
||||
|
||||
expect(bridge.validateConnectionHost).toHaveBeenCalledWith('proxy.internal');
|
||||
expect(connect).toHaveBeenCalledWith(req, opts);
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects the connection when the policy denies the proxy host', async () => {
|
||||
const error = new Error('blocked');
|
||||
const bridge = makeSsrfBridge({
|
||||
validateConnectionHost: vi.fn().mockReturnValue({ ok: false, error }),
|
||||
});
|
||||
const { agent, connect } = guarded({ host: '169.254.169.254' });
|
||||
installProxyConnectionGuard(agent, bridge);
|
||||
|
||||
await expect(agent.connect({} as http.ClientRequest, {} as ConnectOpts)).rejects.toBe(error);
|
||||
|
||||
expect(connect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('validates the proxy host on every connection, not once at installation', async () => {
|
||||
const bridge = makeSsrfBridge();
|
||||
const { agent } = guarded({ host: 'proxy.internal' });
|
||||
installProxyConnectionGuard(agent, bridge);
|
||||
|
||||
await agent.connect({} as http.ClientRequest, {} as ConnectOpts);
|
||||
agent.connectOpts.host = 'other-proxy.internal';
|
||||
await agent.connect({} as http.ClientRequest, {} as ConnectOpts);
|
||||
|
||||
expect(bridge.validateConnectionHost).toHaveBeenNthCalledWith(1, 'proxy.internal');
|
||||
expect(bridge.validateConnectionHost).toHaveBeenNthCalledWith(2, 'other-proxy.internal');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['missing', {}],
|
||||
['null', { host: null, hostname: null }],
|
||||
['empty', { host: '' }],
|
||||
['empty while `hostname` is set', { host: '', hostname: 'proxy.internal' }],
|
||||
])('rejects the connection when the proxy host is %s', async (_label, connectOpts) => {
|
||||
const bridge = makeSsrfBridge();
|
||||
const { agent, connect } = guarded(connectOpts);
|
||||
installProxyConnectionGuard(agent, bridge);
|
||||
|
||||
await expect(agent.connect({} as http.ClientRequest, {} as ConnectOpts)).rejects.toThrow(
|
||||
'Cannot determine the host for this connection',
|
||||
);
|
||||
|
||||
expect(connect).not.toHaveBeenCalled();
|
||||
expect(bridge.validateConnectionHost).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,24 +1,47 @@
|
|||
import http from 'node:http';
|
||||
import type { LookupFunction } from 'node:net';
|
||||
import type { MockInstance } from 'vitest';
|
||||
|
||||
import { makeLookupFn } from '../../ssrf/__tests__/mock-ssrf-bridge';
|
||||
import { EnvProxyHttpAgent } from '../env-proxy-http-agent';
|
||||
|
||||
// Routing/caching is covered in env-proxy-router.test.ts; here we only assert
|
||||
// the agent's wiring: delegate to the resolved proxy agent, else dispatch
|
||||
// directly via `super.addRequest`. The proxy agent is mocked and `getProxyForUrl`
|
||||
// drives which branch runs, so nothing hits the network.
|
||||
const { getProxyForUrl, proxyAddRequest } = vi.hoisted(() => ({
|
||||
const { getProxyForUrl, proxyAddRequest, proxyConnect, proxyAgents } = vi.hoisted(() => ({
|
||||
getProxyForUrl: vi.fn<(url: string) => string>(),
|
||||
proxyAddRequest: vi.fn(),
|
||||
proxyConnect: vi.fn(),
|
||||
proxyAgents: [] as unknown[],
|
||||
}));
|
||||
|
||||
vi.mock('proxy-from-env', () => ({ getProxyForUrl }));
|
||||
vi.mock('http-proxy-agent', () => ({
|
||||
HttpProxyAgent: class {
|
||||
connectOpts: { host: string };
|
||||
|
||||
addRequest = proxyAddRequest;
|
||||
|
||||
constructor(
|
||||
proxyUrl: string,
|
||||
readonly agentOptions?: { lookup?: unknown },
|
||||
) {
|
||||
this.connectOpts = { host: new URL(proxyUrl).hostname };
|
||||
proxyAgents.push(this);
|
||||
}
|
||||
|
||||
async connect(...args: unknown[]): Promise<unknown> {
|
||||
return await proxyConnect(...args);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
type CreatedProxyAgent = {
|
||||
agentOptions?: { lookup?: unknown };
|
||||
connect: (req: unknown, opts: unknown) => Promise<unknown>;
|
||||
};
|
||||
|
||||
const req = {} as http.ClientRequest;
|
||||
const options = (o: Partial<http.RequestOptions>): http.RequestOptions => o as http.RequestOptions;
|
||||
|
||||
|
|
@ -28,6 +51,9 @@ describe('EnvProxyHttpAgent', () => {
|
|||
beforeEach(() => {
|
||||
getProxyForUrl.mockReset();
|
||||
proxyAddRequest.mockReset();
|
||||
proxyConnect.mockReset();
|
||||
proxyConnect.mockResolvedValue('SOCKET');
|
||||
proxyAgents.length = 0;
|
||||
// `super.addRequest` is the only path that would open a real socket.
|
||||
// `addRequest` is an internal Agent method untyped on the public types.
|
||||
superAddRequest = vi
|
||||
|
|
@ -57,4 +83,37 @@ describe('EnvProxyHttpAgent', () => {
|
|||
expect(proxyAddRequest).not.toHaveBeenCalled();
|
||||
expect(superAddRequest).toHaveBeenCalledWith(req, opts);
|
||||
});
|
||||
|
||||
describe('SSRF scope of the resolved proxy agent', () => {
|
||||
beforeEach(() => {
|
||||
getProxyForUrl.mockReturnValue('http://proxy.internal:3128');
|
||||
});
|
||||
|
||||
function resolvedProxyAgent(lookup?: LookupFunction): CreatedProxyAgent {
|
||||
new EnvProxyHttpAgent(lookup).addRequest(req, options({ host: 'a.example', port: 80 }));
|
||||
return proxyAgents[0] as CreatedProxyAgent;
|
||||
}
|
||||
|
||||
it.each([
|
||||
['a lookup is given', () => makeLookupFn()],
|
||||
['no lookup is given', () => undefined],
|
||||
])('opens the connection to the proxy unchecked when %s', async (_label, makeLookup) => {
|
||||
const lookup = makeLookup();
|
||||
const proxyAgent = resolvedProxyAgent(lookup);
|
||||
|
||||
await expect(proxyAgent.connect(req, {})).resolves.toBe('SOCKET');
|
||||
|
||||
expect(proxyAgent.agentOptions?.lookup).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps the secure lookup on its own direct pool', () => {
|
||||
const lookupFn = makeLookupFn();
|
||||
|
||||
const agent = new EnvProxyHttpAgent(lookupFn) as unknown as {
|
||||
options: { lookup?: unknown };
|
||||
};
|
||||
|
||||
expect(agent.options.lookup).toBe(lookupFn);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,25 +1,48 @@
|
|||
import type http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import type { LookupFunction } from 'node:net';
|
||||
import type { MockInstance } from 'vitest';
|
||||
|
||||
import { makeLookupFn } from '../../ssrf/__tests__/mock-ssrf-bridge';
|
||||
import { EnvProxyHttpsAgent } from '../env-proxy-https-agent';
|
||||
|
||||
// Routing/caching is covered in env-proxy-router.test.ts; here we only assert
|
||||
// the agent's wiring: delegate to the resolved proxy agent, else dispatch
|
||||
// directly via `super.addRequest`. The proxy agent is mocked and `getProxyForUrl`
|
||||
// drives which branch runs, so nothing hits the network.
|
||||
const { getProxyForUrl, proxyAddRequest } = vi.hoisted(() => ({
|
||||
const { getProxyForUrl, proxyAddRequest, proxyConnect, proxyAgents } = vi.hoisted(() => ({
|
||||
getProxyForUrl: vi.fn<(url: string) => string>(),
|
||||
proxyAddRequest: vi.fn(),
|
||||
proxyConnect: vi.fn(),
|
||||
proxyAgents: [] as unknown[],
|
||||
}));
|
||||
|
||||
vi.mock('proxy-from-env', () => ({ getProxyForUrl }));
|
||||
vi.mock('https-proxy-agent', () => ({
|
||||
HttpsProxyAgent: class {
|
||||
connectOpts: { host: string };
|
||||
|
||||
addRequest = proxyAddRequest;
|
||||
|
||||
constructor(
|
||||
proxyUrl: string,
|
||||
readonly agentOptions?: { lookup?: unknown },
|
||||
) {
|
||||
this.connectOpts = { host: new URL(proxyUrl).hostname };
|
||||
proxyAgents.push(this);
|
||||
}
|
||||
|
||||
async connect(...args: unknown[]): Promise<unknown> {
|
||||
return await proxyConnect(...args);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
type CreatedProxyAgent = {
|
||||
agentOptions?: { lookup?: unknown };
|
||||
connect: (req: unknown, opts: unknown) => Promise<unknown>;
|
||||
};
|
||||
|
||||
const req = {} as http.ClientRequest;
|
||||
const options = (o: Partial<https.RequestOptions>): https.RequestOptions =>
|
||||
o as https.RequestOptions;
|
||||
|
|
@ -30,6 +53,9 @@ describe('EnvProxyHttpsAgent', () => {
|
|||
beforeEach(() => {
|
||||
getProxyForUrl.mockReset();
|
||||
proxyAddRequest.mockReset();
|
||||
proxyConnect.mockReset();
|
||||
proxyConnect.mockResolvedValue('SOCKET');
|
||||
proxyAgents.length = 0;
|
||||
// `super.addRequest` is the only path that would open a real socket.
|
||||
// `addRequest` is an internal Agent method untyped on the public types.
|
||||
superAddRequest = vi
|
||||
|
|
@ -59,4 +85,37 @@ describe('EnvProxyHttpsAgent', () => {
|
|||
expect(proxyAddRequest).not.toHaveBeenCalled();
|
||||
expect(superAddRequest).toHaveBeenCalledWith(req, opts);
|
||||
});
|
||||
|
||||
describe('SSRF scope of the resolved proxy agent', () => {
|
||||
beforeEach(() => {
|
||||
getProxyForUrl.mockReturnValue('http://proxy.internal:3128');
|
||||
});
|
||||
|
||||
function resolvedProxyAgent(lookup?: LookupFunction): CreatedProxyAgent {
|
||||
new EnvProxyHttpsAgent(lookup).addRequest(req, options({ host: 'a.example', port: 443 }));
|
||||
return proxyAgents[0] as CreatedProxyAgent;
|
||||
}
|
||||
|
||||
it.each([
|
||||
['a lookup is given', () => makeLookupFn()],
|
||||
['no lookup is given', () => undefined],
|
||||
])('opens the connection to the proxy unchecked when %s', async (_label, makeLookup) => {
|
||||
const lookup = makeLookup();
|
||||
const proxyAgent = resolvedProxyAgent(lookup);
|
||||
|
||||
await expect(proxyAgent.connect(req, {})).resolves.toBe('SOCKET');
|
||||
|
||||
expect(proxyAgent.agentOptions?.lookup).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps the secure lookup on its own direct pool', () => {
|
||||
const lookupFn = makeLookupFn();
|
||||
|
||||
const agent = new EnvProxyHttpsAgent(lookupFn) as unknown as {
|
||||
options: { lookup?: unknown };
|
||||
};
|
||||
|
||||
expect(agent.options.lookup).toBe(lookupFn);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,13 +1,21 @@
|
|||
import { HttpProxyAgent } from 'http-proxy-agent';
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import dns from 'node:dns';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import type { LookupFunction } from 'node:net';
|
||||
|
||||
import type { SsrfBridge } from '../../ssrf';
|
||||
import { makeLookupFn, makeSsrfBridge } from '../../ssrf/__tests__/mock-ssrf-bridge';
|
||||
import {
|
||||
makeDenyingLookup,
|
||||
makeLookupFn,
|
||||
makeSsrfBridge,
|
||||
} from '../../ssrf/__tests__/mock-ssrf-bridge';
|
||||
import { installConnectionGuard, UnknownConnectionHostError } from '../connection-guards';
|
||||
import { EnvProxyHttpAgent } from '../env-proxy-http-agent';
|
||||
import { EnvProxyHttpsAgent } from '../env-proxy-https-agent';
|
||||
import { buildNodeAgents, installConnectionGuard } from '../node-agents';
|
||||
import { type LocalServer, startServer } from '../local-server';
|
||||
import { buildNodeAgents, type ProxyUrl } from '../node-agents';
|
||||
|
||||
// HttpsProxyAgent stores `lookup` in `connectOpts` rather than `options`
|
||||
// (unlike http.Agent and HttpProxyAgent which use `options`).
|
||||
|
|
@ -68,6 +76,22 @@ describe('buildNodeAgents', () => {
|
|||
|
||||
expect(getAgentOptions(httpAgent).keepAlive).toBe(true);
|
||||
});
|
||||
|
||||
it('proxy: explicit URL → keeps the target TLS options off the proxy connection', () => {
|
||||
const agents = buildNodeAgents('https://proxy.internal:3128', 'disabled', {
|
||||
servername: 'target.example.com',
|
||||
ca: 'TARGET_CA',
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
|
||||
for (const agent of [agents.httpAgent, agents.httpsAgent]) {
|
||||
const { connectOpts } = agent as unknown as { connectOpts: Record<string, unknown> };
|
||||
expect(connectOpts).toMatchObject({ host: 'proxy.internal', port: 3128 });
|
||||
expect(connectOpts).not.toHaveProperty('servername');
|
||||
expect(connectOpts).not.toHaveProperty('ca');
|
||||
expect(connectOpts).not.toHaveProperty('rejectUnauthorized');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('rejects a caller-provided lookup (managed by the SSRF policy)', () => {
|
||||
|
|
@ -87,39 +111,25 @@ describe('buildNodeAgents', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('SSRF lookup placement (direct connections only)', () => {
|
||||
it('proxy: false → injects the secure lookup on both agents', () => {
|
||||
describe('SSRF lookup placement', () => {
|
||||
const modes: Array<[string, false | 'env' | ProxyUrl]> = [
|
||||
['proxy: false', false],
|
||||
['proxy: env', 'env'],
|
||||
['proxy: explicit URL', 'http://proxy.internal:3128'],
|
||||
];
|
||||
|
||||
it.each(modes)('%s → injects the secure lookup on both agents', (_label, proxy) => {
|
||||
const lookupFn = makeLookupFn();
|
||||
const bridge = makeSsrfBridge({ createSecureLookup: vi.fn().mockReturnValue(lookupFn) });
|
||||
|
||||
const { httpAgent, httpsAgent } = buildNodeAgents(false, bridge);
|
||||
const { httpAgent, httpsAgent } = buildNodeAgents(proxy, bridge);
|
||||
|
||||
expect(getAgentLookup(httpAgent)).toBe(lookupFn);
|
||||
expect(getAgentLookup(httpsAgent)).toBe(lookupFn);
|
||||
});
|
||||
|
||||
it('proxy: env → injects the secure lookup for the direct path', () => {
|
||||
const lookupFn = makeLookupFn();
|
||||
const bridge = makeSsrfBridge({ createSecureLookup: vi.fn().mockReturnValue(lookupFn) });
|
||||
|
||||
const { httpAgent, httpsAgent } = buildNodeAgents('env', bridge);
|
||||
|
||||
expect(getAgentLookup(httpAgent)).toBe(lookupFn);
|
||||
expect(getAgentLookup(httpsAgent)).toBe(lookupFn);
|
||||
});
|
||||
|
||||
it('proxy: explicit URL → does NOT inject the lookup (proxy validates the target)', () => {
|
||||
const lookupFn = makeLookupFn();
|
||||
const bridge = makeSsrfBridge({ createSecureLookup: vi.fn().mockReturnValue(lookupFn) });
|
||||
|
||||
const { httpAgent, httpsAgent } = buildNodeAgents('http://proxy.internal:3128', bridge);
|
||||
|
||||
expect(getAgentLookup(httpAgent)).toBeUndefined();
|
||||
expect(getAgentLookup(httpsAgent)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ssrf disabled → no lookup on the direct path', () => {
|
||||
const { httpAgent, httpsAgent } = buildNodeAgents('env', 'disabled');
|
||||
it.each(modes)('%s with SSRF disabled → no lookup on either agent', (_label, proxy) => {
|
||||
const { httpAgent, httpsAgent } = buildNodeAgents(proxy, 'disabled');
|
||||
|
||||
expect(getAgentLookup(httpAgent)).toBeUndefined();
|
||||
expect(getAgentLookup(httpsAgent)).toBeUndefined();
|
||||
|
|
@ -169,6 +179,24 @@ describe('buildNodeAgents', () => {
|
|||
expect(socket).toBe('SOCKET');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['missing', {}],
|
||||
['null', { host: null, hostname: null }],
|
||||
['empty', { host: '' }],
|
||||
['empty while `hostname` is set', { host: '', hostname: 'target.example' }],
|
||||
])('rejects the connection when the host is %s', (_label, options) => {
|
||||
const bridge = makeSsrfBridge();
|
||||
const { createConnection, original } = guarded(bridge);
|
||||
const onCreate = vi.fn();
|
||||
|
||||
const result = createConnection(options, onCreate);
|
||||
|
||||
expect(onCreate).toHaveBeenCalledWith(expect.any(UnknownConnectionHostError));
|
||||
expect(original).not.toHaveBeenCalled();
|
||||
expect(result).toBeUndefined();
|
||||
expect(bridge.validateConnectionHost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes the raw host through to the bridge (normalization is the service’s job)', () => {
|
||||
const bridge = makeSsrfBridge();
|
||||
const { createConnection } = guarded(bridge);
|
||||
|
|
@ -198,4 +226,107 @@ describe('buildNodeAgents', () => {
|
|||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('proxy host validation (explicit proxy URL)', () => {
|
||||
let proxyServer: LocalServer;
|
||||
let proxyByIp: ProxyUrl;
|
||||
let proxyByHostname: ProxyUrl;
|
||||
|
||||
const realLookup = () => dns.lookup as unknown as LookupFunction;
|
||||
|
||||
async function getThroughAgent(agent: http.Agent): Promise<string> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const req = http.get('http://proxied-target.invalid/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'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
proxyServer = await startServer((_req, res) => {
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
res.end('proxied');
|
||||
});
|
||||
const { port } = new URL(proxyServer.url);
|
||||
proxyByIp = `http://127.0.0.1:${port}`;
|
||||
proxyByHostname = `http://localhost:${port}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await proxyServer.close();
|
||||
});
|
||||
|
||||
it('rejects a proxy host the SSRF policy denies, without reaching the proxy', async () => {
|
||||
const error = new Error(
|
||||
'The request was blocked because it resolves to a restricted IP address',
|
||||
);
|
||||
const bridge = makeSsrfBridge({
|
||||
createSecureLookup: realLookup,
|
||||
validateConnectionHost: vi.fn().mockReturnValue({ ok: false, error }),
|
||||
});
|
||||
const { httpAgent } = buildNodeAgents(proxyByIp, bridge);
|
||||
|
||||
await expect(getThroughAgent(httpAgent)).rejects.toThrow(error.message);
|
||||
|
||||
expect(bridge.validateConnectionHost).toHaveBeenCalledWith('127.0.0.1');
|
||||
expect(proxyServer.captured).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(['httpAgent', 'httpsAgent'] as const)(
|
||||
'validates the proxy host before the %s opens its connection',
|
||||
async (which) => {
|
||||
const error = new Error('blocked');
|
||||
const bridge = makeSsrfBridge({
|
||||
validateConnectionHost: vi.fn().mockReturnValue({ ok: false, error }),
|
||||
});
|
||||
const agents = buildNodeAgents('http://proxy.internal:3128', bridge);
|
||||
const agent = agents[which] as unknown as {
|
||||
connect: (req: unknown, opts: unknown) => Promise<unknown>;
|
||||
};
|
||||
|
||||
await expect(agent.connect({}, {})).rejects.toBe(error);
|
||||
|
||||
expect(bridge.validateConnectionHost).toHaveBeenCalledWith('proxy.internal');
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects a proxy hostname that resolves to a restricted address', async () => {
|
||||
const error = new Error(
|
||||
'The request was blocked because it resolves to a restricted IP address',
|
||||
);
|
||||
const lookup = makeDenyingLookup(error);
|
||||
const bridge = makeSsrfBridge({ createSecureLookup: () => lookup });
|
||||
const { httpAgent } = buildNodeAgents(proxyByHostname, bridge);
|
||||
|
||||
await expect(getThroughAgent(httpAgent)).rejects.toThrow(error.message);
|
||||
|
||||
expect(lookup).toHaveBeenCalledWith('localhost', expect.any(Object), expect.any(Function));
|
||||
expect(proxyServer.captured).toEqual([]);
|
||||
});
|
||||
|
||||
it('connects through a proxy host the SSRF policy allows', async () => {
|
||||
const bridge = makeSsrfBridge({ createSecureLookup: realLookup });
|
||||
const { httpAgent } = buildNodeAgents(proxyByIp, bridge);
|
||||
|
||||
await expect(getThroughAgent(httpAgent)).resolves.toBe('proxied');
|
||||
|
||||
expect(bridge.validateConnectionHost).toHaveBeenCalledWith('127.0.0.1');
|
||||
expect(proxyServer.captured).toEqual(['http://proxied-target.invalid/x']);
|
||||
});
|
||||
|
||||
it('connects through the proxy unchanged when SSRF protection is disabled', async () => {
|
||||
const { httpAgent } = buildNodeAgents(proxyByHostname, 'disabled');
|
||||
|
||||
await expect(getThroughAgent(httpAgent)).resolves.toBe('proxied');
|
||||
|
||||
expect(proxyServer.captured).toEqual(['http://proxied-target.invalid/x']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import type { Logger } from '@n8n/backend-common';
|
||||
import { HttpProxyAgent } from 'http-proxy-agent';
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import type http from 'node:http';
|
||||
import type https from 'node:https';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { SsrfProtectionService } from '../../ssrf';
|
||||
|
|
@ -30,15 +30,9 @@ function getAgentLookup(agent: http.Agent | https.Agent): unknown {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('getNodeAgent', () => {
|
||||
it('proxy: false → plain http/https.Agent (no proxy class)', () => {
|
||||
const { httpAgent, httpsAgent } = makeFacade().transport({ proxy: false }).getNodeAgent();
|
||||
|
||||
expect(httpAgent).toBeInstanceOf(http.Agent);
|
||||
expect(httpsAgent).toBeInstanceOf(https.Agent);
|
||||
expect(httpAgent).not.toBeInstanceOf(HttpProxyAgent);
|
||||
expect(httpsAgent).not.toBeInstanceOf(HttpsProxyAgent);
|
||||
});
|
||||
|
||||
// Proxy-mode → agent-class matrix is owned by `node-agents.test.ts`
|
||||
// (`buildNodeAgents` → `describe('agent classes per proxy mode')`); this is a
|
||||
// representative case proving the facade forwards to it correctly.
|
||||
it('proxy: explicit URL → HttpProxyAgent / HttpsProxyAgent', () => {
|
||||
const { httpAgent, httpsAgent } = makeFacade()
|
||||
.transport({ proxy: 'http://proxy.internal:3128' })
|
||||
|
|
@ -48,13 +42,6 @@ describe('getNodeAgent', () => {
|
|||
expect(httpsAgent).toBeInstanceOf(HttpsProxyAgent);
|
||||
});
|
||||
|
||||
it('proxy: env → custom env-routing agents (http/https.Agent subclasses)', () => {
|
||||
const { httpAgent, httpsAgent } = makeFacade().transport({ proxy: 'env' }).getNodeAgent();
|
||||
|
||||
expect(httpAgent).toBeInstanceOf(http.Agent);
|
||||
expect(httpsAgent).toBeInstanceOf(https.Agent);
|
||||
});
|
||||
|
||||
it('returns the same agent instances on repeated calls', () => {
|
||||
const client = makeFacade().transport();
|
||||
const a1 = client.getNodeAgent();
|
||||
|
|
@ -79,62 +66,29 @@ describe('getNodeAgent', () => {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('getNodeAgent SSRF lookup injection', () => {
|
||||
describe('proxy: false', () => {
|
||||
it('injects createSecureLookup when SSRF is enabled', () => {
|
||||
const lookupFn = makeLookupFn();
|
||||
const bridge = makeSsrfBridge({
|
||||
createSecureLookup: vi.fn().mockReturnValue(lookupFn),
|
||||
});
|
||||
const { httpAgent, httpsAgent } = makeFacade()
|
||||
.transport({ ssrf: bridge, proxy: false })
|
||||
.getNodeAgent();
|
||||
|
||||
expect(bridge.createSecureLookup).toHaveBeenCalledTimes(1);
|
||||
expect(getAgentLookup(httpAgent)).toBe(lookupFn);
|
||||
expect(getAgentLookup(httpsAgent)).toBe(lookupFn);
|
||||
// The full proxy-mode matrix is owned by `node-agents.test.ts`
|
||||
// (`buildNodeAgents` → `describe('SSRF lookup placement')`); this is a
|
||||
// representative case proving the facade forwards to it correctly.
|
||||
it('the agents carry the lookup created by the SSRF bridge', () => {
|
||||
const lookupFn = makeLookupFn();
|
||||
const bridge = makeSsrfBridge({
|
||||
createSecureLookup: vi.fn().mockReturnValue(lookupFn),
|
||||
});
|
||||
|
||||
it('does NOT inject lookup when SSRF is disabled', () => {
|
||||
const { httpAgent, httpsAgent } = makeFacade()
|
||||
.transport({ ssrf: 'disabled', proxy: false })
|
||||
.getNodeAgent();
|
||||
const { httpAgent, httpsAgent } = makeFacade()
|
||||
.transport({ ssrf: bridge, proxy: false })
|
||||
.getNodeAgent();
|
||||
|
||||
expect(getAgentLookup(httpAgent)).toBeUndefined();
|
||||
expect(getAgentLookup(httpsAgent)).toBeUndefined();
|
||||
});
|
||||
expect(getAgentLookup(httpAgent)).toBe(lookupFn);
|
||||
expect(getAgentLookup(httpsAgent)).toBe(lookupFn);
|
||||
});
|
||||
|
||||
describe('proxy: explicit URL', () => {
|
||||
it('does NOT inject lookup behind an explicit proxy (proxy validates the target)', () => {
|
||||
const lookupFn = makeLookupFn();
|
||||
const bridge = makeSsrfBridge({
|
||||
createSecureLookup: vi.fn().mockReturnValue(lookupFn),
|
||||
});
|
||||
const { httpAgent, httpsAgent } = makeFacade()
|
||||
.transport({ ssrf: bridge, proxy: 'http://proxy.internal:3128' })
|
||||
.getNodeAgent();
|
||||
it('no lookup when SSRF is disabled', () => {
|
||||
const { httpAgent, httpsAgent } = makeFacade()
|
||||
.transport({ ssrf: 'disabled', proxy: false })
|
||||
.getNodeAgent();
|
||||
|
||||
// SSRF lookup is applied to direct connections only. Behind a proxy it
|
||||
// would resolve the proxy host, not the final target, so it is omitted.
|
||||
expect(getAgentLookup(httpAgent)).toBeUndefined();
|
||||
expect(getAgentLookup(httpsAgent)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('proxy: env', () => {
|
||||
it('injects createSecureLookup when SSRF is enabled', () => {
|
||||
const lookupFn = makeLookupFn();
|
||||
const bridge = makeSsrfBridge({
|
||||
createSecureLookup: vi.fn().mockReturnValue(lookupFn),
|
||||
});
|
||||
const { httpAgent, httpsAgent } = makeFacade()
|
||||
.transport({ ssrf: bridge, proxy: 'env' })
|
||||
.getNodeAgent();
|
||||
|
||||
expect(bridge.createSecureLookup).toHaveBeenCalledTimes(1);
|
||||
// EnvProxy* agents inherit from http/https.Agent and pass lookup to super()
|
||||
expect(getAgentLookup(httpAgent)).toBe(lookupFn);
|
||||
expect(getAgentLookup(httpsAgent)).toBe(lookupFn);
|
||||
});
|
||||
expect(getAgentLookup(httpAgent)).toBeUndefined();
|
||||
expect(getAgentLookup(httpsAgent)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,13 +5,18 @@ import type { Dispatcher } from 'undici';
|
|||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { SsrfBridge, SsrfProtectionService } from '../../ssrf';
|
||||
import { makeLookupFn, makeSsrfBridge } from '../../ssrf/__tests__/mock-ssrf-bridge';
|
||||
import {
|
||||
makeDenyingLookup,
|
||||
makeSsrfBridge,
|
||||
useCleanProxyEnv,
|
||||
} from '../../ssrf/__tests__/mock-ssrf-bridge';
|
||||
import { type LocalServer, startServer } from '../local-server';
|
||||
import { OutboundHttp } from '../outbound-http';
|
||||
import { createSsrfInterceptor } from '../undici/transport';
|
||||
|
||||
// SSRF enforcement lives in a single place: the dispatcher interceptor.
|
||||
// This file proves it at two levels:
|
||||
// SSRF enforcement on the dispatch path lives in the dispatcher interceptor
|
||||
// (an explicit proxy URI is additionally checked when the dispatcher is built).
|
||||
// This file proves the interceptor at two levels:
|
||||
// (a) a direct unit test of `createSsrfInterceptor`, and
|
||||
// (b) end-to-end tests against a real local server (no mocked `fetch`), so the
|
||||
// interceptor actually runs and we assert that a 30x cannot smuggle a
|
||||
|
|
@ -49,6 +54,8 @@ function makeOpts(path: string, origin?: string) {
|
|||
return { path, origin } as unknown as Dispatcher.DispatchOptions;
|
||||
}
|
||||
|
||||
useCleanProxyEnv();
|
||||
|
||||
describe('createSsrfInterceptor', () => {
|
||||
it('validates the reconstructed target URL and dispatches when allowed', async () => {
|
||||
const bridge = makeSsrfBridge();
|
||||
|
|
@ -89,7 +96,7 @@ describe('createSsrfInterceptor', () => {
|
|||
|
||||
expect(bridge.validateUrl).not.toHaveBeenCalled();
|
||||
expect(innerDispatch).not.toHaveBeenCalled();
|
||||
expect(handler.onResponseError).toHaveBeenCalled();
|
||||
expect(handler.onResponseError).toHaveBeenCalledWith(null, expect.any(TypeError));
|
||||
});
|
||||
|
||||
it('falls back to onError when onResponseError is unavailable', async () => {
|
||||
|
|
@ -309,14 +316,123 @@ describe('connect-time secure lookup (DNS rebinding)', () => {
|
|||
await expect(fetchFn('http://rebind.example/')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('does not derive a connect-time lookup behind an explicit proxy', () => {
|
||||
const createSecureLookup = vi.fn(makeLookupFn);
|
||||
const bridge = makeSsrfBridge({ createSecureLookup });
|
||||
it('resolves the proxy host through the secure lookup behind an explicit proxy', async () => {
|
||||
const denied = new Error('blocked: restricted IP address');
|
||||
const lookupSpy = makeDenyingLookup(denied);
|
||||
const bridge = makeSsrfBridge({ createSecureLookup: () => lookupSpy });
|
||||
const fetchFn = makeTransport({
|
||||
ssrf: bridge,
|
||||
proxy: 'http://proxy.internal:3128',
|
||||
}).asCustomFetch();
|
||||
|
||||
// Forces the lazy dispatcher to build. The proxy resolves the target, so
|
||||
// the secure lookup must not be consulted on our side.
|
||||
makeTransport({ ssrf: bridge, proxy: 'http://proxy.invalid:3128' }).getDispatcher();
|
||||
const rejection = await fetchFn('http://target.invalid/x').catch((e: unknown) => e);
|
||||
|
||||
expect(createSecureLookup).not.toHaveBeenCalled();
|
||||
expect(lookupSpy).toHaveBeenCalledWith('proxy.internal', expect.anything(), expect.anything());
|
||||
expect(rootCauseMessage(rejection)).toBe(denied.message);
|
||||
});
|
||||
});
|
||||
|
||||
describe('proxy host validation', () => {
|
||||
const realLookup = () => dns.lookup as unknown as LookupFunction;
|
||||
|
||||
function denyingBridge(error: Error) {
|
||||
return makeSsrfBridge({
|
||||
createSecureLookup: realLookup,
|
||||
validateConnectionHost: vi.fn().mockReturnValue({ ok: false, error }),
|
||||
});
|
||||
}
|
||||
|
||||
function bridgeDenyingProxyHost(deniedHostname: string) {
|
||||
const error = new Error('The proxy host is not permitted by policy');
|
||||
const bridge = makeSsrfBridge({
|
||||
createSecureLookup: realLookup,
|
||||
validateUrl: vi.fn(async (url: string | URL) => {
|
||||
const hostname = typeof url === 'string' ? new URL(url).hostname : url.hostname;
|
||||
return await Promise.resolve(
|
||||
hostname === deniedHostname
|
||||
? { ok: false as const, error }
|
||||
: { ok: true as const, result: undefined },
|
||||
);
|
||||
}),
|
||||
});
|
||||
return { bridge, error };
|
||||
}
|
||||
|
||||
it('rejects an explicit proxy host the policy denies', () => {
|
||||
const error = new Error('The proxy host is not permitted by policy');
|
||||
const bridge = denyingBridge(error);
|
||||
|
||||
expect(() =>
|
||||
makeTransport({ ssrf: bridge, proxy: 'http://127.0.0.1:3128' }).getDispatcher(),
|
||||
).toThrow(error.message);
|
||||
expect(bridge.validateConnectionHost).toHaveBeenCalledWith('127.0.0.1');
|
||||
});
|
||||
|
||||
it('surfaces the rejection of an explicit proxy host through fetch', async () => {
|
||||
const error = new Error('The proxy host is not permitted by policy');
|
||||
const fetchFn = makeTransport({
|
||||
ssrf: denyingBridge(error),
|
||||
proxy: 'http://127.0.0.1:3128',
|
||||
}).asCustomFetch();
|
||||
|
||||
await expect(fetchFn('http://target.invalid/x')).rejects.toThrow(error.message);
|
||||
});
|
||||
|
||||
// The environment's proxies describe the deployment, so the policy that decides
|
||||
// which targets a workflow may reach does not decide them.
|
||||
it.each([
|
||||
['HTTP_PROXY', 'http://target.invalid/x'],
|
||||
['HTTPS_PROXY', 'https://target.invalid/x'],
|
||||
] as const)('leaves a proxy configured through %s unchecked', async (envKey, target) => {
|
||||
process.env[envKey] = 'http://127.0.0.1:3128';
|
||||
const lookupSpy = makeDenyingLookup(new Error('blocked: restricted IP address'));
|
||||
const { bridge } = bridgeDenyingProxyHost('127.0.0.1');
|
||||
bridge.createSecureLookup = () => lookupSpy;
|
||||
const transport = makeTransport({ ssrf: bridge, proxy: 'env' });
|
||||
|
||||
expect(() => transport.getDispatcher()).not.toThrow();
|
||||
await transport
|
||||
.asCustomFetch()(target)
|
||||
.catch(() => undefined);
|
||||
|
||||
const validated = vi.mocked(bridge.validateUrl).mock.calls.map(([url]) => String(url));
|
||||
expect(validated).toEqual([target]);
|
||||
expect(bridge.validateConnectionHost).not.toHaveBeenCalled();
|
||||
expect(lookupSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves an explicit proxy unchecked when SSRF protection is disabled', () => {
|
||||
const bridge = denyingBridge(new Error('The proxy host is not permitted by policy'));
|
||||
|
||||
expect(() =>
|
||||
makeTransport({ ssrf: 'disabled', proxy: 'http://127.0.0.1:3128' }).getDispatcher(),
|
||||
).not.toThrow();
|
||||
expect(bridge.validateConnectionHost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('serves a target the environment exempts from the direct path', async () => {
|
||||
const server = await startServer((_req, res) => {
|
||||
res.writeHead(200, { 'content-type': 'text/plain' });
|
||||
res.end('ok');
|
||||
});
|
||||
process.env.HTTP_PROXY = 'http://proxy.internal:3128';
|
||||
process.env.NO_PROXY = '127.0.0.1';
|
||||
const { bridge } = bridgeDenyingProxyHost('proxy.internal');
|
||||
const fetchFn = makeTransport({ ssrf: bridge, proxy: 'env' }).asCustomFetch();
|
||||
|
||||
try {
|
||||
const res = await fetchFn(`${server.url}/x`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves the dispatcher untouched when no proxy is configured in the environment', () => {
|
||||
const bridge = makeSsrfBridge({ createSecureLookup: realLookup });
|
||||
|
||||
expect(() => makeTransport({ ssrf: bridge, proxy: 'env' }).getDispatcher()).not.toThrow();
|
||||
expect(bridge.validateConnectionHost).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import dns from 'node:dns';
|
|||
import http from 'node:http';
|
||||
import type { LookupFunction } from 'node:net';
|
||||
|
||||
import { makeSsrfBridge } from '../../ssrf/__tests__/mock-ssrf-bridge';
|
||||
import { makeSsrfBridge, useCleanProxyEnv } from '../../ssrf/__tests__/mock-ssrf-bridge';
|
||||
import { getBeforeRedirectFn, setAxiosAgents } from '../axios/utils';
|
||||
import { type LocalServer, startServer } from '../local-server';
|
||||
import { buildNodeAgents } from '../node-agents';
|
||||
|
|
@ -31,9 +31,10 @@ async function httpGetWithAgent(url: string, agent: http.Agent): Promise<string>
|
|||
}
|
||||
|
||||
describe('outbound transport integration', () => {
|
||||
useCleanProxyEnv();
|
||||
|
||||
let target: LocalServer;
|
||||
let proxy: LocalServer;
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
|
||||
beforeAll(async () => {
|
||||
target = await startServer((req, res) => {
|
||||
|
|
@ -58,18 +59,10 @@ describe('outbound transport integration', () => {
|
|||
});
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.HTTP_PROXY;
|
||||
delete process.env.HTTPS_PROXY;
|
||||
delete process.env.NO_PROXY;
|
||||
delete process.env.ALL_PROXY;
|
||||
target.clear();
|
||||
proxy.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
});
|
||||
|
||||
describe('setAxiosAgents routing', () => {
|
||||
it('routes through an explicit custom proxy', async () => {
|
||||
const config: AxiosRequestConfig = { url: `${target.url}/x`, method: 'GET', proxy: false };
|
||||
|
|
@ -120,7 +113,7 @@ describe('outbound transport integration', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('SSRF secure lookup is applied to direct connections only', () => {
|
||||
describe('SSRF secure lookup placement', () => {
|
||||
it('invokes the secure lookup for a direct (hostname) connection', async () => {
|
||||
const lookupSpy = vi.fn((hostname: string, options: dns.LookupOptions, onResult: unknown) =>
|
||||
dns.lookup(hostname, options, onResult as never),
|
||||
|
|
@ -142,8 +135,17 @@ describe('outbound transport integration', () => {
|
|||
expect(lookupSpy).toHaveBeenCalledWith('localhost', expect.anything(), expect.anything());
|
||||
});
|
||||
|
||||
it('does NOT invoke the secure lookup when routed through a proxy', async () => {
|
||||
process.env.HTTP_PROXY = proxy.url;
|
||||
// An explicit proxy URL can come from a request, so its host is resolved
|
||||
// through the policy; the environment's is part of the deployment and is not.
|
||||
it.each([
|
||||
['leaves the env proxy host to the platform resolver', true],
|
||||
['invokes the secure lookup for an explicit proxy host', false],
|
||||
])('%s', async (_case, fromEnv) => {
|
||||
const { port } = new URL(proxy.url);
|
||||
const proxyUrl = `http://localhost:${port}`;
|
||||
if (fromEnv) {
|
||||
process.env.HTTP_PROXY = proxyUrl;
|
||||
}
|
||||
const lookupSpy = vi.fn((hostname: string, options: dns.LookupOptions, onResult: unknown) =>
|
||||
dns.lookup(hostname, options, onResult as never),
|
||||
);
|
||||
|
|
@ -156,14 +158,21 @@ describe('outbound transport integration', () => {
|
|||
method: 'GET',
|
||||
proxy: false,
|
||||
};
|
||||
setAxiosAgents(config, undefined, undefined, bridge);
|
||||
setAxiosAgents(config, undefined, fromEnv ? undefined : proxyUrl, bridge);
|
||||
|
||||
const res = await axios<{ message: string }>(config);
|
||||
|
||||
expect(res.data.message).toBe('proxied');
|
||||
// The proxy host is an IP (no lookup) and the proxy resolves the final
|
||||
// target, so the secure lookup is never consulted on our side.
|
||||
expect(lookupSpy).not.toHaveBeenCalled();
|
||||
if (fromEnv) {
|
||||
expect(lookupSpy).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect(lookupSpy).toHaveBeenCalledWith('localhost', expect.anything(), expect.anything());
|
||||
}
|
||||
expect(lookupSpy).not.toHaveBeenCalledWith(
|
||||
'proxied-target.invalid',
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -165,6 +165,16 @@ describe('buildAxiosConfigFromLegacyRequest', () => {
|
|||
expect((axiosOptions.httpsAgent as HttpsAgent).options.servername).toEqual('example.de');
|
||||
});
|
||||
|
||||
// SNI takes a hostname, never an IP literal (RFC 6066 §3).
|
||||
test.each([
|
||||
['IPv4', 'https://185.90.154.8/foo'],
|
||||
['IPv6', 'https://[2001:db8::1]/foo'],
|
||||
])('should set no SNI when the host is an %s literal', async (_label, url) => {
|
||||
const axiosOptions = await buildAxiosConfigFromLegacyRequest({ url });
|
||||
|
||||
expect((axiosOptions.httpsAgent as HttpsAgent).options.servername).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('should set SSL certificates', () => {
|
||||
const agentOptions: SecureContextOptions = {
|
||||
ca: TEST_CA_CERT,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import type { Logger } from '@n8n/backend-common';
|
||||
import dns from 'node:dns';
|
||||
import type { LookupFunction } from 'node:net';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { SsrfBridge } from '../../../ssrf';
|
||||
import { makeSsrfBridge } from '../../../ssrf/__tests__/mock-ssrf-bridge';
|
||||
import { makeSsrfBridge, useCleanProxyEnv } from '../../../ssrf/__tests__/mock-ssrf-bridge';
|
||||
import { executeLegacyRequest } from '../../legacy-request';
|
||||
import { type LocalServer, startServer } from '../../local-server';
|
||||
import { configureGlobalAxiosDefaults } from '../config';
|
||||
|
|
@ -16,8 +18,6 @@ import { httpRequest } from '../request';
|
|||
|
||||
configureGlobalAxiosDefaults();
|
||||
|
||||
const PROXY_ENV_KEYS = ['HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY'] as const;
|
||||
|
||||
async function startRedirectServer(): Promise<LocalServer> {
|
||||
let serverUrl = '';
|
||||
const server = await startServer((req, res) => {
|
||||
|
|
@ -67,13 +67,11 @@ function makeBridge(blockedPath: string): { bridge: SsrfBridge; error: Error } {
|
|||
}
|
||||
|
||||
describe('httpRequest manual redirect following with SSRF + proxy', () => {
|
||||
useCleanProxyEnv();
|
||||
|
||||
let server: LocalServer;
|
||||
const savedEnv: Record<string, string | undefined> = {};
|
||||
|
||||
beforeEach(async () => {
|
||||
for (const key of PROXY_ENV_KEYS) {
|
||||
savedEnv[key] = process.env[key];
|
||||
}
|
||||
// A proxy is configured (engages the manual follower), but loopback is
|
||||
// exempt so the request connects directly to the local server.
|
||||
process.env.HTTP_PROXY = 'http://127.0.0.1:1';
|
||||
|
|
@ -84,13 +82,6 @@ describe('httpRequest manual redirect following with SSRF + proxy', () => {
|
|||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const key of PROXY_ENV_KEYS) {
|
||||
if (savedEnv[key] === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = savedEnv[key];
|
||||
}
|
||||
}
|
||||
await server.close();
|
||||
});
|
||||
|
||||
|
|
@ -218,6 +209,177 @@ describe('httpRequest manual redirect following with SSRF + proxy', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('proxy host validation across redirect hops', () => {
|
||||
let proxyServer: LocalServer;
|
||||
const START_URL = 'http://redirect-target.invalid/start';
|
||||
const INTERNAL_URL = 'http://redirect-target.invalid/internal';
|
||||
|
||||
beforeEach(async () => {
|
||||
proxyServer = await startServer((req, res) => {
|
||||
if (req.url === START_URL) {
|
||||
res.writeHead(302, { Location: INTERNAL_URL });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'content-type': 'text/plain' });
|
||||
res.end('reached:proxied');
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await proxyServer.close();
|
||||
});
|
||||
|
||||
const proxyConfig = () => ({
|
||||
host: 'localhost',
|
||||
port: Number(new URL(proxyServer.url).port),
|
||||
protocol: 'http',
|
||||
});
|
||||
|
||||
it('follows the redirect through a proxy the policy allows', async () => {
|
||||
const bridge = makeSsrfBridge({
|
||||
createSecureLookup: () => dns.lookup as unknown as LookupFunction,
|
||||
});
|
||||
|
||||
const response = await httpRequest(
|
||||
{ method: 'GET', url: START_URL, proxy: proxyConfig() },
|
||||
bridge,
|
||||
);
|
||||
|
||||
expect(response).toBe('reached:proxied');
|
||||
expect(proxyServer.captured).toEqual([START_URL, INTERNAL_URL]);
|
||||
});
|
||||
|
||||
it('blocks the redirect hop when the proxy host is no longer allowed', async () => {
|
||||
const denied = new Error(
|
||||
'The request was blocked because it resolves to a restricted IP address',
|
||||
);
|
||||
const lookup = ((hostname: string, options: dns.LookupOptions, onResult: unknown) => {
|
||||
if (proxyServer.captured.length > 0) {
|
||||
(onResult as (error: Error | null, address?: unknown, family?: number) => void)(
|
||||
denied,
|
||||
options.all ? [] : '',
|
||||
undefined,
|
||||
);
|
||||
return;
|
||||
}
|
||||
dns.lookup(hostname, options, onResult as never);
|
||||
}) as unknown as LookupFunction;
|
||||
const bridge = makeSsrfBridge({ createSecureLookup: () => lookup });
|
||||
|
||||
await expect(
|
||||
httpRequest({ method: 'GET', url: START_URL, proxy: proxyConfig() }, bridge),
|
||||
).rejects.toThrow(denied.message);
|
||||
|
||||
expect(proxyServer.captured).toEqual([START_URL]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('proxy host validation before the request is sent', () => {
|
||||
let proxyServer: LocalServer;
|
||||
const TARGET_URL = 'http://proxied-target.invalid/x';
|
||||
|
||||
beforeEach(async () => {
|
||||
proxyServer = await startServer((_req, res) => {
|
||||
res.writeHead(200, { 'content-type': 'text/plain' });
|
||||
res.end('reached:proxied');
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await proxyServer.close();
|
||||
});
|
||||
|
||||
const proxyUrl = () => `http://localhost:${new URL(proxyServer.url).port}`;
|
||||
|
||||
const proxyConfig = () => ({
|
||||
host: 'localhost',
|
||||
port: Number(new URL(proxyServer.url).port),
|
||||
protocol: 'http',
|
||||
});
|
||||
|
||||
function bridgeDenying(hostname: string, error: Error): SsrfBridge {
|
||||
return makeSsrfBridge({
|
||||
createSecureLookup: () => dns.lookup as unknown as LookupFunction,
|
||||
validateUrl: vi.fn(async (url: string | URL) => {
|
||||
const host = typeof url === 'string' ? new URL(url).hostname : url.hostname;
|
||||
return await Promise.resolve(
|
||||
host === hostname
|
||||
? { ok: false as const, error }
|
||||
: { ok: true as const, result: undefined },
|
||||
);
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const requestPaths = [
|
||||
[
|
||||
'httpRequest',
|
||||
async (bridge: SsrfBridge) =>
|
||||
await httpRequest({ method: 'GET', url: TARGET_URL, proxy: proxyConfig() }, bridge),
|
||||
],
|
||||
[
|
||||
'executeLegacyRequest',
|
||||
async (bridge: SsrfBridge) =>
|
||||
await executeLegacyRequest(
|
||||
{ uri: TARGET_URL, proxy: proxyUrl() },
|
||||
bridge,
|
||||
mock<Logger>(),
|
||||
),
|
||||
],
|
||||
] as const;
|
||||
|
||||
it.each(requestPaths)(
|
||||
'blocks %s when the policy denies the proxy host',
|
||||
async (_name, send) => {
|
||||
const error = new Error('The proxy host is not permitted by policy');
|
||||
|
||||
await expect(send(bridgeDenying('localhost', error))).rejects.toBe(error);
|
||||
|
||||
expect(proxyServer.captured).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(requestPaths)('sends %s when the policy allows the proxy host', async (_name, send) => {
|
||||
const bridge = bridgeDenying('never-matches.invalid', new Error('unused'));
|
||||
|
||||
expect(await send(bridge)).toBe('reached:proxied');
|
||||
|
||||
expect(bridge.validateUrl).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ hostname: 'localhost' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('reports the target URL denial before checking the proxy host', async () => {
|
||||
const targetError = new Error('The target host is not permitted by policy');
|
||||
const bridge = makeSsrfBridge({
|
||||
createSecureLookup: () => dns.lookup as unknown as LookupFunction,
|
||||
validateUrl: vi.fn(async (url: string | URL) => {
|
||||
const host = typeof url === 'string' ? new URL(url).hostname : url.hostname;
|
||||
return await Promise.resolve({
|
||||
ok: false as const,
|
||||
error: host === 'localhost' ? new Error('proxy denied') : targetError,
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
httpRequest({ method: 'GET', url: TARGET_URL, proxy: proxyConfig() }, bridge),
|
||||
).rejects.toBe(targetError);
|
||||
});
|
||||
|
||||
it('sends the request through the proxy when SSRF protection is disabled', async () => {
|
||||
const response = await httpRequest({
|
||||
method: 'GET',
|
||||
url: TARGET_URL,
|
||||
proxy: proxyConfig(),
|
||||
});
|
||||
|
||||
expect(response).toBe('reached:proxied');
|
||||
expect(proxyServer.captured).toEqual([TARGET_URL]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('credential headers across redirects', () => {
|
||||
let origin: LocalServer;
|
||||
let crossOrigin: LocalServer;
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
searchForHeader,
|
||||
setAxiosAgents,
|
||||
tryParseUrl,
|
||||
validateProxySsrf,
|
||||
} from '../utils';
|
||||
|
||||
// Agent construction is owned by `buildNodeAgents` (./factory).
|
||||
|
|
@ -431,6 +432,66 @@ describe('getUrlFromProxyConfig', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('validateProxySsrf', () => {
|
||||
const denyingBridge = (deniedHostname: string, error: Error) =>
|
||||
makeSsrfBridge({
|
||||
validateUrl: vi.fn(async (url: string | URL) => {
|
||||
const hostname = typeof url === 'string' ? new URL(url).hostname : url.hostname;
|
||||
return await Promise.resolve(
|
||||
hostname === deniedHostname
|
||||
? { ok: false as const, error }
|
||||
: { ok: true as const, result: undefined },
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a proxy URL string', 'http://proxy.example.com:8080'],
|
||||
['a proxy config object', { host: 'proxy.example.com', port: 8080 }],
|
||||
])('throws when the policy denies the host of %s', async (_label, proxyConfig) => {
|
||||
const error = new Error('The proxy host is not permitted by policy');
|
||||
|
||||
await expect(
|
||||
validateProxySsrf(proxyConfig, denyingBridge('proxy.example.com', error)),
|
||||
).rejects.toBe(error);
|
||||
});
|
||||
|
||||
it('validates the URL composed from a proxy config object', async () => {
|
||||
const bridge = makeSsrfBridge();
|
||||
|
||||
await validateProxySsrf(
|
||||
{
|
||||
protocol: 'https',
|
||||
host: 'proxy.example.com',
|
||||
port: 9443,
|
||||
auth: { username: 'user', password: 'pass' },
|
||||
},
|
||||
bridge,
|
||||
);
|
||||
|
||||
expect(bridge.validateUrl).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ href: 'https://user:pass@proxy.example.com:9443/' }),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['no proxy is configured', undefined],
|
||||
['the proxy config has no host', { host: '', port: 8080 }],
|
||||
['the proxy scheme is not supported', 'socks5://proxy.example.com:1080'],
|
||||
['the proxy value is not a URL', 'not-a-url'],
|
||||
])('does not consult the policy when %s', async (_label, proxyConfig) => {
|
||||
const bridge = makeSsrfBridge();
|
||||
|
||||
await validateProxySsrf(proxyConfig, bridge);
|
||||
|
||||
expect(bridge.validateUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not consult the policy when no bridge is provided', async () => {
|
||||
await expect(validateProxySsrf('http://proxy.example.com:8080')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildTargetUrl', () => {
|
||||
it('should return url as-is when no baseURL', () => {
|
||||
expect(buildTargetUrl('https://example.com/path')).toBe('https://example.com/path');
|
||||
|
|
@ -701,4 +762,14 @@ describe('buildAgentOptions', () => {
|
|||
expect(options.keepAlive).toBe(true);
|
||||
expect(options.servername).toBe('api.example.com');
|
||||
});
|
||||
|
||||
// SNI takes a hostname, never an IP literal (RFC 6066 §3).
|
||||
it.each([
|
||||
['IPv4', 'https://185.90.154.8/v1'],
|
||||
['IPv6', 'https://[2001:db8::1]/v1'],
|
||||
])('sets no servername when the host is an %s literal', (_label, url) => {
|
||||
const options = buildAgentOptions({ method: 'GET', url });
|
||||
|
||||
expect(options.servername).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
resolveLegacyRequestTarget,
|
||||
searchForHeader,
|
||||
setAxiosAgents,
|
||||
sniFor,
|
||||
} from './utils';
|
||||
import type { SsrfBridge } from '../../ssrf';
|
||||
|
||||
|
|
@ -32,10 +33,10 @@ import type { SsrfBridge } from '../../ssrf';
|
|||
* @deprecated Backs the deprecated `request` helpers.
|
||||
*/
|
||||
export function buildLegacyAgentOptions(requestObject: IRequestOptions): AgentOptions {
|
||||
const host = getHostFromRequestObject(requestObject);
|
||||
const servername = sniFor(getHostFromRequestObject(requestObject));
|
||||
const agentOptions: AgentOptions = { ...requestObject.agentOptions };
|
||||
if (host) {
|
||||
agentOptions.servername = host;
|
||||
if (servername) {
|
||||
agentOptions.servername = servername;
|
||||
}
|
||||
if (requestObject.rejectUnauthorized === false) {
|
||||
agentOptions.rejectUnauthorized = false;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
isProxyPotentiallyActive,
|
||||
isRedirectStatus,
|
||||
resolveProxyOption,
|
||||
sniFor,
|
||||
throwIfDomainNotAllowed,
|
||||
tryParseUrl,
|
||||
validateUrlSsrf,
|
||||
|
|
@ -120,12 +121,11 @@ function buildRedirectHopAgents(
|
|||
nextUrl: string,
|
||||
policy: SsrfRedirectPolicy,
|
||||
): Pick<AxiosRequestConfig, 'httpAgent' | 'httpsAgent'> {
|
||||
const host = tryParseUrl(nextUrl)?.hostname;
|
||||
const customProxyUrl = policy.proxyConfig ? getUrlFromProxyConfig(policy.proxyConfig) : null;
|
||||
const proxy = resolveProxyOption(customProxyUrl);
|
||||
return buildNodeAgents(proxy, policy.ssrf, {
|
||||
...policy.agentOptions,
|
||||
...(host ? { servername: host } : {}),
|
||||
servername: sniFor(tryParseUrl(nextUrl)?.hostname),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
searchForHeader,
|
||||
setAxiosAgents,
|
||||
throwIfDomainNotAllowed,
|
||||
validateProxySsrf,
|
||||
validateUrlSsrf,
|
||||
} from './utils';
|
||||
import type { SsrfBridge } from '../../ssrf';
|
||||
|
|
@ -187,6 +188,7 @@ export async function httpRequest(
|
|||
|
||||
const url = buildTargetUrl(requestOptions.url, requestOptions.baseURL) ?? requestOptions.url;
|
||||
await validateUrlSsrf(url, ssrfBridge);
|
||||
await validateProxySsrf(requestOptions.proxy, ssrfBridge);
|
||||
|
||||
const axiosRequest = convertN8nRequestToAxios(requestOptions, ssrfBridge);
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
type IRequestOptions,
|
||||
type IgnoreStatusErrorConfig,
|
||||
} from 'n8n-workflow';
|
||||
import net from 'node:net';
|
||||
|
||||
import { hasProxyEnvironmentVariables } from '../../proxy/proxy-resolution';
|
||||
import type { SsrfBridge } from '../../ssrf';
|
||||
|
|
@ -57,6 +58,24 @@ export function searchForHeader(config: AxiosRequestConfig, headerName: string)
|
|||
return headerNames.find((thisHeader) => thisHeader.toLowerCase() === headerName);
|
||||
}
|
||||
|
||||
/**
|
||||
* The SNI to announce when connecting to `host`.
|
||||
*
|
||||
* @returns the host, or `undefined` when it carries no name to announce: SNI takes a
|
||||
* hostname and never an IP literal (RFC 6066 §3), and Node ignores IP values.
|
||||
*/
|
||||
export function sniFor(host: string | null | undefined): string | undefined {
|
||||
if (!host) {
|
||||
return undefined;
|
||||
}
|
||||
// A URL hostname keeps the brackets of an IPv6 literal; `net.isIP` expects it bare.
|
||||
const unbracketed = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
|
||||
if (net.isIP(unbracketed)) {
|
||||
return undefined;
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
/** Extracts the hostname from a request object's URL or URI. */
|
||||
export const getHostFromRequestObject = (
|
||||
requestObject: Partial<{
|
||||
|
|
@ -115,13 +134,11 @@ export const getBeforeRedirectFn =
|
|||
|
||||
const redirectAgentOptions: AgentOptions = {
|
||||
...agentOptions,
|
||||
servername: redirectedRequest.hostname,
|
||||
servername: sniFor(redirectedRequest.hostname as string | undefined),
|
||||
};
|
||||
const customProxyUrl = proxyConfig ? getUrlFromProxyConfig(proxyConfig) : null;
|
||||
const proxy = resolveProxyOption(customProxyUrl);
|
||||
|
||||
// SSRF lookup is applied to direct connections only; behind a proxy the
|
||||
// proxy validates the final target.
|
||||
const targetUrl = redirectedRequest.href;
|
||||
const { httpAgent, httpsAgent } = buildNodeAgents(proxy, ssrf, redirectAgentOptions);
|
||||
|
||||
|
|
@ -248,10 +265,10 @@ export function isFormDataInstance(data: unknown): data is FormData {
|
|||
* Shared by the axios config builder and the manual redirect follower so both derive agents the same way.
|
||||
*/
|
||||
export function buildAgentOptions(n8nRequest: IHttpRequestOptions): AgentOptions {
|
||||
const host = getHostFromRequestObject(n8nRequest);
|
||||
const servername = sniFor(getHostFromRequestObject(n8nRequest));
|
||||
const agentOptions: AgentOptions = { ...n8nRequest.agentOptions };
|
||||
if (host) {
|
||||
agentOptions.servername = host;
|
||||
if (servername) {
|
||||
agentOptions.servername = servername;
|
||||
}
|
||||
if (n8nRequest.skipSslCertificateValidation === true) {
|
||||
agentOptions.rejectUnauthorized = false;
|
||||
|
|
@ -378,8 +395,6 @@ export function setAxiosAgents(
|
|||
const customProxyUrl = proxyConfig ? getUrlFromProxyConfig(proxyConfig) : null;
|
||||
const proxy = resolveProxyOption(customProxyUrl);
|
||||
|
||||
// SSRF lookup is applied to direct connections only; behind a proxy the
|
||||
// proxy validates the final target.
|
||||
const { httpAgent, httpsAgent } = buildNodeAgents(proxy, ssrf, agentOptions);
|
||||
config.httpAgent = httpAgent;
|
||||
config.httpsAgent = httpsAgent;
|
||||
|
|
@ -401,6 +416,16 @@ export async function validateUrlSsrf(
|
|||
}
|
||||
}
|
||||
|
||||
export async function validateProxySsrf(
|
||||
proxyConfig: IHttpRequestOptions['proxy'] | string | undefined,
|
||||
ssrfBridge?: SsrfBridge,
|
||||
): Promise<void> {
|
||||
const proxyUrl = getUrlFromProxyConfig(proxyConfig);
|
||||
if (!isSupportedProxyUrl(proxyUrl)) return;
|
||||
|
||||
await validateUrlSsrf(proxyUrl, ssrfBridge);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the raw target of a legacy request object, i.e. the value the axios
|
||||
* config carries as its `url`.
|
||||
|
|
|
|||
85
packages/@n8n/backend-network/src/http/connection-guards.ts
Normal file
85
packages/@n8n/backend-network/src/http/connection-guards.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import type { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import { UnexpectedError } from 'n8n-workflow';
|
||||
import type http from 'node:http';
|
||||
import type net from 'node:net';
|
||||
|
||||
import type { SsrfBridge } from '../ssrf';
|
||||
|
||||
/** Per-hop connect options of a proxy agent, as `agent-base` hands them to `connect()`. */
|
||||
type ProxyConnectOpts = Parameters<HttpsProxyAgent<string>['connect']>[1];
|
||||
|
||||
export class UnknownConnectionHostError extends UnexpectedError {
|
||||
constructor() {
|
||||
super('Cannot determine the host for this connection');
|
||||
}
|
||||
}
|
||||
|
||||
/** Subset of an agent's connection options we read to find the target host. */
|
||||
type ConnectionOptions = { host?: string | null; hostname?: string | null };
|
||||
|
||||
/**
|
||||
* Runtime-only `createConnection` method of Node's http(s) agents.
|
||||
*/
|
||||
type CreateConnection = (
|
||||
options: ConnectionOptions,
|
||||
onConnect?: (error: Error | null, stream?: unknown) => void,
|
||||
) => unknown;
|
||||
|
||||
interface ProxyConnectionAgent {
|
||||
connectOpts: ConnectionOptions;
|
||||
connect(req: http.ClientRequest, opts: ProxyConnectOpts): Promise<net.Socket>;
|
||||
}
|
||||
|
||||
function connectionHostError(ssrf: SsrfBridge, options: ConnectionOptions): Error | undefined {
|
||||
const host = options.host ?? options.hostname ?? undefined;
|
||||
if (typeof host !== 'string' || host.length === 0) {
|
||||
return new UnknownConnectionHostError();
|
||||
}
|
||||
const result = ssrf.validateConnectionHost(host);
|
||||
return result.ok ? undefined : result.error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the proxy's own host before the socket to it opens.
|
||||
*
|
||||
* `agent-base` opens that socket inside `connect()`, from `connectOpts`.
|
||||
* The agent's `createConnection` receives the target's options and never sees the proxy.
|
||||
* This guard decides IP literals only.
|
||||
* A hostname proxy relies on the secure lookup in the agent's options, which validates
|
||||
* the address that hostname resolves to.
|
||||
*/
|
||||
export function installProxyConnectionGuard(agent: ProxyConnectionAgent, ssrf: SsrfBridge): void {
|
||||
const connect = agent.connect.bind(agent);
|
||||
agent.connect = async (req, opts) => {
|
||||
const error = connectionHostError(ssrf, agent.connectOpts);
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
return await connect(req, opts);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps an agent's `createConnection` to validate the connection target before the socket opens.
|
||||
*
|
||||
* Node invokes the custom `lookup` to resolve hostnames only.
|
||||
* An IP-literal target therefore reaches the socket unresolved, and needs this check
|
||||
* at connection time.
|
||||
*/
|
||||
export function installConnectionGuard(
|
||||
target: { createConnection: CreateConnection },
|
||||
ssrf: SsrfBridge,
|
||||
): void {
|
||||
const createConnection = target.createConnection.bind(target);
|
||||
target.createConnection = (options, onConnect) => {
|
||||
const error = connectionHostError(ssrf, options);
|
||||
if (error) {
|
||||
if (onConnect) {
|
||||
onConnect(error);
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return createConnection(options, onConnect);
|
||||
};
|
||||
}
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
import { HttpProxyAgent } from 'http-proxy-agent';
|
||||
import type { HttpProxyAgent } from 'http-proxy-agent';
|
||||
import http from 'node:http';
|
||||
import type { LookupFunction } from 'node:net';
|
||||
|
||||
import { EnvProxyRouter } from './env-proxy-router';
|
||||
import type { NodeAgentOptions } from './node-agents';
|
||||
import { createProxiedHttpAgent } from '../proxy/proxied-agents';
|
||||
|
||||
type HttpAddRequestArgs = Parameters<HttpProxyAgent<string>['addRequest']>;
|
||||
type HttpProxyClientReq = HttpAddRequestArgs[0];
|
||||
|
|
@ -12,8 +13,10 @@ type HttpProxyReqOpts = HttpAddRequestArgs[1];
|
|||
/**
|
||||
* `http.Agent` that delegates per-request env-proxy routing and caching to a shared {@link EnvProxyRouter}.
|
||||
*
|
||||
* The optional SSRF `lookup` is applied to the direct path only
|
||||
* (behind a proxy it would resolve the proxy host, so the proxy validates the target).
|
||||
* The optional SSRF `lookup` is applied to the direct path only.
|
||||
* A proxy named by the environment belongs to the deployment rather than to a request.
|
||||
* The policy that decides which targets a workflow may reach does not decide such a
|
||||
* proxy (see `buildNodeAgents`).
|
||||
*
|
||||
* Also backs `installGlobalProxyAgent` (http-proxy.ts), keeping a single env-proxy agent implementation.
|
||||
*/
|
||||
|
|
@ -22,10 +25,8 @@ export class EnvProxyHttpAgent extends http.Agent {
|
|||
|
||||
constructor(lookup?: LookupFunction, agentOptions?: NodeAgentOptions) {
|
||||
super({ ...agentOptions, lookup });
|
||||
this.router = new EnvProxyRouter(
|
||||
'http',
|
||||
80,
|
||||
(proxyUrl) => new HttpProxyAgent(proxyUrl, { ...agentOptions }),
|
||||
this.router = new EnvProxyRouter('http', 80, (proxyUrl) =>
|
||||
createProxiedHttpAgent(proxyUrl, agentOptions),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import type { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import type http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import type { LookupFunction } from 'node:net';
|
||||
|
||||
import { EnvProxyRouter } from './env-proxy-router';
|
||||
import type { NodeAgentOptions } from './node-agents';
|
||||
import { createProxiedHttpsAgent } from '../proxy/proxied-agents';
|
||||
|
||||
type HttpsProxyReqOpts = Parameters<HttpsProxyAgent<string>['addRequest']>[1];
|
||||
|
||||
|
|
@ -16,10 +17,8 @@ export class EnvProxyHttpsAgent extends https.Agent {
|
|||
|
||||
constructor(lookup?: LookupFunction, agentOptions?: NodeAgentOptions) {
|
||||
super({ ...agentOptions, lookup });
|
||||
this.router = new EnvProxyRouter(
|
||||
'https',
|
||||
443,
|
||||
(proxyUrl) => new HttpsProxyAgent(proxyUrl, { ...agentOptions }),
|
||||
this.router = new EnvProxyRouter('https', 443, (proxyUrl) =>
|
||||
createProxiedHttpsAgent(proxyUrl, agentOptions),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,12 @@ import type { SsrfBridge } from '../ssrf';
|
|||
import { invokeAxios } from './axios/invoke';
|
||||
import { buildAxiosConfigFromLegacyRequest, buildLegacyAgentOptions } from './axios/legacy';
|
||||
import { followSsrfRedirects, shouldFollowRedirectsManually } from './axios/redirect';
|
||||
import { resolveLegacyRequestUrl, throwIfDomainNotAllowed, validateUrlSsrf } from './axios/utils';
|
||||
import {
|
||||
resolveLegacyRequestUrl,
|
||||
throwIfDomainNotAllowed,
|
||||
validateProxySsrf,
|
||||
validateUrlSsrf,
|
||||
} from './axios/utils';
|
||||
import { binaryToString } from './binary-string';
|
||||
import { parseIncomingMessage } from './parse-incoming-message';
|
||||
|
||||
|
|
@ -56,6 +61,7 @@ export async function executeLegacyRequest(
|
|||
|
||||
const url = resolveLegacyRequestUrl(requestObject);
|
||||
await validateUrlSsrf(url, ssrfBridge);
|
||||
await validateProxySsrf(requestObject.proxy, ssrfBridge);
|
||||
|
||||
axiosConfig = Object.assign(
|
||||
axiosConfig,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { HttpProxyAgent } from 'http-proxy-agent';
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import { UnexpectedError } from 'n8n-workflow';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import type { LookupFunction } from 'node:net';
|
||||
|
||||
import { installConnectionGuard, installProxyConnectionGuard } from './connection-guards';
|
||||
import { EnvProxyHttpAgent } from './env-proxy-http-agent';
|
||||
import { EnvProxyHttpsAgent } from './env-proxy-https-agent';
|
||||
import { createProxiedHttpAgent, createProxiedHttpsAgent } from '../proxy/proxied-agents';
|
||||
import type { SsrfBridge } from '../ssrf';
|
||||
|
||||
/**
|
||||
|
|
@ -55,14 +55,34 @@ export type NodeAgentOptions = https.AgentOptions;
|
|||
* undici factory (`undici/factory.ts`), the axios transport layer
|
||||
* (`axios/utils.ts`) and the global proxy agents (`http-proxy.ts`).
|
||||
*
|
||||
* SSRF lookup is injected only for **direct** connections. Behind a proxy the
|
||||
* lookup resolves the proxy host, not the final target, so it is omitted there
|
||||
* and the proxy validates the final target.
|
||||
* The SSRF policy covers every host this builder opens a socket to, with one
|
||||
* exception: a proxy named by the environment.
|
||||
* `HTTP_PROXY` / `HTTPS_PROXY` describe the deployment rather than a request, and
|
||||
* such a proxy commonly sits on a private address the policy blocks for targets.
|
||||
* Setting them already requires control of the process.
|
||||
* An explicit {@link ProxyUrl} can come from a request, so the policy decides its
|
||||
* host like any other.
|
||||
*
|
||||
* The lookup resolves the target on a direct connection, and the proxy's own host
|
||||
* behind an explicit proxy.
|
||||
* Behind any proxy the final target never reaches these agents, so validating it
|
||||
* belongs to the caller:
|
||||
* - the axios entry points (`httpRequest`, `executeLegacyRequest`) check it before
|
||||
* the request, and on each hop with the manual redirect follower
|
||||
* - callers taking the agents on their own (`HttpTransport.getNodeAgent`) get
|
||||
* neither check, and validate the targets they send through them
|
||||
*
|
||||
* A pre-request check does not pin an address to the socket, so a target the proxy
|
||||
* resolves stays open to a rebind between the check and the connection.
|
||||
*
|
||||
* The `lookup` is owned by this builder: it is derived from the SSRF policy and
|
||||
* always overrides anything in `agentOptions`. Passing `agentOptions.lookup`
|
||||
* therefore has no effect and is rejected to avoid a false sense of control over
|
||||
* DNS resolution.
|
||||
*
|
||||
* `agentOptions` describes the connection to the **target**. Behind a proxy its TLS
|
||||
* options travel with the tunnelled session rather than the proxy handshake
|
||||
* (see `proxy/proxied-agents.ts`).
|
||||
*/
|
||||
export function buildNodeAgents(
|
||||
proxy: ProxyOption,
|
||||
|
|
@ -98,24 +118,18 @@ export function buildNodeAgents(
|
|||
);
|
||||
}
|
||||
|
||||
// Explicit proxy URL. No direct path, so no SSRF lookup is injected.
|
||||
return {
|
||||
httpAgent: new HttpProxyAgent(proxy as string, { ...agentOptions }),
|
||||
httpsAgent: new HttpsProxyAgent(proxy as string, { ...agentOptions }),
|
||||
// Explicit proxy URL. No direct path, so the lookup only guards the proxy host.
|
||||
const agents = {
|
||||
httpAgent: createProxiedHttpAgent(proxy, { ...agentOptions, lookup }),
|
||||
httpsAgent: createProxiedHttpsAgent(proxy, { ...agentOptions, lookup }),
|
||||
};
|
||||
if (ssrf !== 'disabled') {
|
||||
installProxyConnectionGuard(agents.httpAgent, ssrf);
|
||||
installProxyConnectionGuard(agents.httpsAgent, ssrf);
|
||||
}
|
||||
return agents;
|
||||
}
|
||||
|
||||
/** Subset of an agent's connection options we read to find the target host. */
|
||||
type ConnectionOptions = { host?: string | null; hostname?: string | null };
|
||||
|
||||
/**
|
||||
* Runtime-only `createConnection` method of Node's http(s) agents.
|
||||
*/
|
||||
type CreateConnection = (
|
||||
options: ConnectionOptions,
|
||||
onConnect?: (error: Error | null, stream?: unknown) => void,
|
||||
) => unknown;
|
||||
|
||||
/**
|
||||
* Installs {@link installConnectionGuard} on a direct-path agent pair when SSRF protection is active.
|
||||
*/
|
||||
|
|
@ -129,28 +143,3 @@ function applyConnectionGuard(
|
|||
}
|
||||
return agents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps an agent's `createConnection` to validate the connection target before the socket opens.
|
||||
* Node invokes the custom `lookup` only to resolve hostnames, so it also requires a check at connection time.
|
||||
*/
|
||||
export function installConnectionGuard(
|
||||
target: { createConnection: CreateConnection },
|
||||
ssrf: SsrfBridge,
|
||||
): void {
|
||||
const createConnection = target.createConnection.bind(target);
|
||||
target.createConnection = (options, onConnect) => {
|
||||
const host = options.host ?? options.hostname ?? undefined;
|
||||
if (typeof host === 'string') {
|
||||
const result = ssrf.validateConnectionHost(host);
|
||||
if (!result.ok) {
|
||||
if (onConnect) {
|
||||
onConnect(result.error);
|
||||
return undefined;
|
||||
}
|
||||
throw result.error;
|
||||
}
|
||||
}
|
||||
return createConnection(options, onConnect);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -139,8 +139,13 @@ export interface HttpRequestClient {
|
|||
* SSRF coverage is identical for `asCustomFetch()` and `getDispatcher()` (same
|
||||
* underlying dispatcher): every dispatched request — initial and each redirect
|
||||
* hop — is validated, and direct connections also carry a connect-time secure
|
||||
* DNS lookup that defeats DNS-rebinding (TOCTOU). `getNodeAgent()` enforces the
|
||||
* same connect-time secure lookup for direct connections.
|
||||
* DNS lookup that defeats DNS-rebinding (TOCTOU). `getNodeAgent()` carries that
|
||||
* same lookup, applied to whichever host its socket opens to: the target on a
|
||||
* direct connection, the proxy behind an explicit one.
|
||||
* It does **not** validate the targets sent through it.
|
||||
* Behind any proxy the proxy resolves the final target, so no client-side
|
||||
* connect-time check of that target is possible.
|
||||
* Callers taking the agents validate those targets themselves (see `buildNodeAgents`).
|
||||
*/
|
||||
export interface HttpTransport {
|
||||
asCustomFetch(): CustomFetch;
|
||||
|
|
|
|||
|
|
@ -108,11 +108,53 @@ function buildDispatcherFromProxy(
|
|||
return new Agent({ ...agentOptions, ...secureConnect(ssrf) });
|
||||
}
|
||||
if (proxy === 'env') {
|
||||
// The environment's proxies are part of the deployment, not of a request,
|
||||
// so the policy does not decide them (see `buildNodeAgents`).
|
||||
return new EnvHttpProxyAgent({ ...agentOptions, ...secureConnect(ssrf) });
|
||||
}
|
||||
// Explicit proxy URL: no direct path, so no connect-time lookup is injected —
|
||||
// the proxy resolves the target. Mirrors `buildNodeAgents`.
|
||||
return new ProxyAgent({ uri: proxy, ...agentOptions });
|
||||
assertProxyHostAllowed(ssrf, proxy);
|
||||
return new ProxyAgent({
|
||||
uri: proxy,
|
||||
...agentOptions,
|
||||
...secureProxyConnect(ssrf),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A connect-time secure DNS lookup for the socket opened to a proxy.
|
||||
*
|
||||
* undici builds the connector for the proxy socket from `proxyTls`.
|
||||
* A caller's `connect` does not reach it: `ProxyAgent` overwrites that with its own
|
||||
* tunnel handshake.
|
||||
*/
|
||||
function secureProxyConnect(ssrf: SsrfOption) {
|
||||
return ssrf === 'disabled' ? {} : { proxyTls: { lookup: ssrf.createSecureLookup() } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Catches an IP-literal proxy host, which no lookup sees.
|
||||
* Applies to an explicit proxy URI, the one form that can come from a request.
|
||||
*/
|
||||
function assertProxyHostAllowed(ssrf: SsrfOption, proxyUri: string): void {
|
||||
if (ssrf === 'disabled') {
|
||||
return;
|
||||
}
|
||||
const hostname = proxyHostname(proxyUri);
|
||||
if (hostname === undefined) {
|
||||
return;
|
||||
}
|
||||
const result = ssrf.validateConnectionHost(hostname);
|
||||
if (!result.ok) {
|
||||
throw result.error;
|
||||
}
|
||||
}
|
||||
|
||||
function proxyHostname(uri: string): string | undefined {
|
||||
try {
|
||||
return new URL(uri).hostname;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -174,8 +216,11 @@ function lazyValue<T>(factory: () => T): () => T {
|
|||
* `fetch` re-dispatches through this dispatcher for each redirect hop, so this
|
||||
* validates the initial request **and** every redirect target (both hostname
|
||||
* and direct-IP targets), unlike a connect-time DNS lookup which never fires for
|
||||
* IP-literal targets. Validation runs against the request target, never the
|
||||
* proxy, so it is proxy-agnostic.
|
||||
* IP-literal targets.
|
||||
*
|
||||
* This interceptor does not validate a proxy's own host.
|
||||
* That host is fixed for the dispatcher rather than per request, so
|
||||
* {@link buildDispatcherFromProxy} decides it where the dispatcher is built.
|
||||
*/
|
||||
export function createSsrfInterceptor(bridge: SsrfBridge): Dispatcher.DispatcherComposeInterceptor {
|
||||
return (dispatch) => (opts, handler) => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import type http from 'node:http';
|
||||
import type https from 'node:https';
|
||||
import type net from 'node:net';
|
||||
import type { MockInstance } from 'vitest';
|
||||
|
||||
import { createProxiedHttpAgent, createProxiedHttpsAgent } from '../proxied-agents';
|
||||
|
||||
const PROXY_URL = 'https://proxy.internal:3128';
|
||||
|
||||
const TARGET_TLS: https.AgentOptions = {
|
||||
servername: 'target.example.com',
|
||||
ca: 'TARGET_CA',
|
||||
cert: 'TARGET_CERT',
|
||||
key: 'TARGET_KEY',
|
||||
passphrase: 'TARGET_PASSPHRASE',
|
||||
};
|
||||
|
||||
type ConnectOpts = Parameters<HttpsProxyAgent<string>['connect']>[1];
|
||||
|
||||
const targetRequestOpts = {
|
||||
host: 'target.example.com',
|
||||
port: 443,
|
||||
secureEndpoint: true,
|
||||
} as ConnectOpts;
|
||||
|
||||
describe('createProxiedHttpsAgent', () => {
|
||||
it('keeps the target SNI and trust material off the connection to the proxy', () => {
|
||||
const agent = createProxiedHttpsAgent(PROXY_URL, TARGET_TLS);
|
||||
|
||||
expect(agent.connectOpts).toMatchObject({ host: 'proxy.internal', port: 3128 });
|
||||
expect(agent.connectOpts).not.toHaveProperty('servername');
|
||||
expect(agent.connectOpts).not.toHaveProperty('ca');
|
||||
expect(agent.connectOpts).not.toHaveProperty('cert');
|
||||
expect(agent.connectOpts).not.toHaveProperty('key');
|
||||
expect(agent.connectOpts).not.toHaveProperty('passphrase');
|
||||
});
|
||||
|
||||
it('verifies the proxy certificate even when the request opts out of verification', () => {
|
||||
const agent = createProxiedHttpsAgent(PROXY_URL, {
|
||||
...TARGET_TLS,
|
||||
rejectUnauthorized: false,
|
||||
secureOptions: 4,
|
||||
});
|
||||
|
||||
expect(agent.connectOpts).not.toHaveProperty('rejectUnauthorized');
|
||||
expect(agent.connectOpts).not.toHaveProperty('secureOptions');
|
||||
});
|
||||
|
||||
it('keeps a target TLS policy from reshaping the proxy handshake', () => {
|
||||
const agent = createProxiedHttpsAgent(PROXY_URL, {
|
||||
ciphers: 'TARGET_CIPHERS',
|
||||
minVersion: 'TLSv1.1',
|
||||
checkServerIdentity: () => undefined,
|
||||
});
|
||||
|
||||
expect(agent.connectOpts).not.toHaveProperty('ciphers');
|
||||
expect(agent.connectOpts).not.toHaveProperty('minVersion');
|
||||
expect(agent.connectOpts).not.toHaveProperty('checkServerIdentity');
|
||||
});
|
||||
|
||||
it('forwards pool and socket options to the connection to the proxy', () => {
|
||||
const agent = createProxiedHttpsAgent(PROXY_URL, { keepAlive: true, timeout: 5000 });
|
||||
|
||||
expect(agent.connectOpts).toMatchObject({ keepAlive: true, timeout: 5000 });
|
||||
});
|
||||
|
||||
describe('tunnelled session', () => {
|
||||
let parentConnect: MockInstance<HttpsProxyAgent<string>['connect']>;
|
||||
|
||||
beforeEach(() => {
|
||||
parentConnect = vi
|
||||
.spyOn(HttpsProxyAgent.prototype, 'connect')
|
||||
.mockResolvedValue({} as net.Socket);
|
||||
});
|
||||
|
||||
afterEach(() => parentConnect.mockRestore());
|
||||
|
||||
it('carries the target trust material', async () => {
|
||||
const agent = createProxiedHttpsAgent(PROXY_URL, {
|
||||
...TARGET_TLS,
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
|
||||
await agent.connect({} as http.ClientRequest, targetRequestOpts);
|
||||
|
||||
expect(parentConnect).toHaveBeenCalledWith(
|
||||
{},
|
||||
expect.objectContaining({
|
||||
host: 'target.example.com',
|
||||
port: 443,
|
||||
secureEndpoint: true,
|
||||
ca: 'TARGET_CA',
|
||||
cert: 'TARGET_CERT',
|
||||
key: 'TARGET_KEY',
|
||||
passphrase: 'TARGET_PASSPHRASE',
|
||||
rejectUnauthorized: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves the SNI to be derived from the host of each hop', async () => {
|
||||
const agent = createProxiedHttpsAgent(PROXY_URL, TARGET_TLS);
|
||||
|
||||
await agent.connect({} as http.ClientRequest, targetRequestOpts);
|
||||
|
||||
expect(parentConnect.mock.calls[0][1]).not.toHaveProperty('servername');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createProxiedHttpAgent', () => {
|
||||
it('keeps the target SNI and trust material off the connection to the proxy', () => {
|
||||
const agent = createProxiedHttpAgent(PROXY_URL, TARGET_TLS);
|
||||
|
||||
expect(agent.connectOpts).toMatchObject({ host: 'proxy.internal', port: 3128 });
|
||||
expect(agent.connectOpts).not.toHaveProperty('servername');
|
||||
expect(agent.connectOpts).not.toHaveProperty('ca');
|
||||
expect(agent.connectOpts).not.toHaveProperty('cert');
|
||||
expect(agent.connectOpts).not.toHaveProperty('key');
|
||||
expect(agent.connectOpts).not.toHaveProperty('passphrase');
|
||||
});
|
||||
});
|
||||
96
packages/@n8n/backend-network/src/proxy/proxied-agents.ts
Normal file
96
packages/@n8n/backend-network/src/proxy/proxied-agents.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { HttpProxyAgent } from 'http-proxy-agent';
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import type http from 'node:http';
|
||||
import type https from 'node:https';
|
||||
import type net from 'node:net';
|
||||
|
||||
/** Per-hop connect options of a proxy agent, as `agent-base` hands them to `connect()`. */
|
||||
type ProxyConnectOpts = Parameters<HttpsProxyAgent<string>['connect']>[1];
|
||||
|
||||
/**
|
||||
* How a socket is opened and pooled, as opposed to how the peer on the other end is
|
||||
* authenticated. Every TLS option in a request describes the target, including the
|
||||
* caller's willingness to accept a certificate it cannot verify, so a proxy borrows
|
||||
* these and nothing else: it is a different peer, and its certificate is always
|
||||
* verified against the proxy hostname. A private proxy CA is trusted through the
|
||||
* process trust store (`NODE_EXTRA_CA_CERTS`), not through a per-request option.
|
||||
*/
|
||||
const CONNECTION_OPTION_KEYS = [
|
||||
'keepAlive',
|
||||
'keepAliveMsecs',
|
||||
'keepAliveInitialDelay',
|
||||
'maxSockets',
|
||||
'maxTotalSockets',
|
||||
'maxFreeSockets',
|
||||
'scheduling',
|
||||
'timeout',
|
||||
'noDelay',
|
||||
'family',
|
||||
'hints',
|
||||
'localAddress',
|
||||
'localPort',
|
||||
'lookup',
|
||||
'autoSelectFamily',
|
||||
'autoSelectFamilyAttemptTimeout',
|
||||
] as const satisfies ReadonlyArray<keyof https.AgentOptions>;
|
||||
|
||||
function pickKeys(agentOptions: https.AgentOptions, keys: readonly string[]): https.AgentOptions {
|
||||
return Object.fromEntries(
|
||||
Object.entries(agentOptions).filter(([key]) => keys.includes(key)),
|
||||
) as https.AgentOptions;
|
||||
}
|
||||
|
||||
function omitKeys(agentOptions: https.AgentOptions, keys: readonly string[]): https.AgentOptions {
|
||||
return Object.fromEntries(
|
||||
Object.entries(agentOptions).filter(([key]) => !keys.includes(key)),
|
||||
) as https.AgentOptions;
|
||||
}
|
||||
|
||||
function forProxyConnection(agentOptions: https.AgentOptions = {}): https.AgentOptions {
|
||||
return pickKeys(agentOptions, CONNECTION_OPTION_KEYS);
|
||||
}
|
||||
|
||||
function forTunnelledConnection(agentOptions: https.AgentOptions = {}): https.AgentOptions {
|
||||
return omitKeys(agentOptions, ['servername']);
|
||||
}
|
||||
|
||||
class TunnellingHttpsProxyAgent extends HttpsProxyAgent<string> {
|
||||
private readonly tunnelOptions: https.AgentOptions;
|
||||
|
||||
constructor(proxyUrl: string, agentOptions?: https.AgentOptions) {
|
||||
super(proxyUrl, forProxyConnection(agentOptions));
|
||||
this.tunnelOptions = forTunnelledConnection(agentOptions);
|
||||
}
|
||||
|
||||
async connect(req: http.ClientRequest, opts: ProxyConnectOpts): Promise<net.Socket> {
|
||||
return await super.connect(req, { ...opts, ...this.tunnelOptions });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the agent routing plain-HTTP targets through `proxyUrl`.
|
||||
* Such a target has no TLS session of its own, so its TLS options are dropped.
|
||||
*
|
||||
* @param agentOptions options describing the connection to the **target**; only its
|
||||
* connection-management options reach the proxy
|
||||
*/
|
||||
export function createProxiedHttpAgent(
|
||||
proxyUrl: string,
|
||||
agentOptions?: https.AgentOptions,
|
||||
): HttpProxyAgent<string> {
|
||||
return new HttpProxyAgent(proxyUrl, forProxyConnection(agentOptions));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the agent tunnelling HTTPS targets through `proxyUrl`, applying the
|
||||
* target's TLS options to the tunnelled session rather than to the proxy handshake.
|
||||
*
|
||||
* @param agentOptions options describing the connection to the **target**; only its
|
||||
* connection-management options reach the proxy
|
||||
*/
|
||||
export function createProxiedHttpsAgent(
|
||||
proxyUrl: string,
|
||||
agentOptions?: https.AgentOptions,
|
||||
): HttpsProxyAgent<string> {
|
||||
return new TunnellingHttpsProxyAgent(proxyUrl, agentOptions);
|
||||
}
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
import http from 'http';
|
||||
import { HttpProxyAgent } from 'http-proxy-agent';
|
||||
import https from 'https';
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import { getProxyForUrl } from 'proxy-from-env';
|
||||
|
||||
import { createProxiedHttpAgent, createProxiedHttpsAgent } from './proxied-agents';
|
||||
|
||||
/**
|
||||
* Resolves the proxy URL configured via environment variables
|
||||
* (HTTP_PROXY, HTTPS_PROXY, NO_PROXY, etc.) for a given target URL.
|
||||
|
|
@ -40,7 +40,7 @@ export function createHttpProxyAgent(
|
|||
const proxyUrl = customProxyUrl ?? getProxyForUrl(targetUrl);
|
||||
|
||||
if (proxyUrl) {
|
||||
return new HttpProxyAgent(proxyUrl, options);
|
||||
return createProxiedHttpAgent(proxyUrl, options);
|
||||
}
|
||||
|
||||
return new http.Agent(options);
|
||||
|
|
@ -62,7 +62,7 @@ export function createHttpsProxyAgent(
|
|||
const proxyUrl = customProxyUrl ?? getProxyForUrl(targetUrl);
|
||||
|
||||
if (proxyUrl) {
|
||||
return new HttpsProxyAgent(proxyUrl, options);
|
||||
return createProxiedHttpsAgent(proxyUrl, options);
|
||||
}
|
||||
|
||||
return new https.Agent(options);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type dns from 'node:dns';
|
||||
import type { LookupFunction } from 'node:net';
|
||||
import { vi } from 'vitest';
|
||||
import { afterEach, beforeEach, vi, type Mock } from 'vitest';
|
||||
|
||||
import type { SsrfBridge } from '..';
|
||||
|
||||
|
|
@ -7,6 +8,50 @@ export function makeLookupFn(): LookupFunction {
|
|||
return vi.fn() as unknown as LookupFunction;
|
||||
}
|
||||
|
||||
/** A `lookup` that fails every resolution with `error`. */
|
||||
export function makeDenyingLookup(error: Error): LookupFunction & Mock {
|
||||
return vi.fn((_hostname: string, options: dns.LookupOptions, onResult: unknown) => {
|
||||
(onResult as (error: Error | null, address?: unknown, family?: number) => void)(
|
||||
error,
|
||||
options.all ? [] : '',
|
||||
undefined,
|
||||
);
|
||||
}) as unknown as LookupFunction & Mock;
|
||||
}
|
||||
|
||||
const PROXY_ENV_KEYS = [
|
||||
'HTTP_PROXY',
|
||||
'http_proxy',
|
||||
'HTTPS_PROXY',
|
||||
'https_proxy',
|
||||
'NO_PROXY',
|
||||
'no_proxy',
|
||||
'ALL_PROXY',
|
||||
'all_proxy',
|
||||
] as const;
|
||||
|
||||
/** Clears the proxy environment around each test and restores it afterwards. */
|
||||
export function useCleanProxyEnv(): void {
|
||||
const savedEnv: Record<string, string | undefined> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of PROXY_ENV_KEYS) {
|
||||
savedEnv[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of PROXY_ENV_KEYS) {
|
||||
if (savedEnv[key] === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = savedEnv[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function makeSsrfBridge(overrides?: Partial<SsrfBridge>): SsrfBridge {
|
||||
return {
|
||||
validateUrl: vi.fn().mockResolvedValue({ ok: true, result: undefined }),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/backend-test-utils",
|
||||
"version": "1.35.0",
|
||||
"version": "1.35.5",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/blob-storage",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.4",
|
||||
"description": "Blob storage layer for n8n, with filesystem, S3, and Azure Blob backends",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/chat-hub",
|
||||
"version": "1.28.0",
|
||||
"version": "1.28.4",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/client-oauth2",
|
||||
"version": "1.17.0",
|
||||
"version": "1.17.4",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -296,7 +296,7 @@ describe('ClientOAuth2', () => {
|
|||
expect(httpsAgent.options.rejectUnauthorized).toBe(false);
|
||||
});
|
||||
|
||||
it('should route through an https proxy agent with relaxed TLS when HTTPS_PROXY is set', async () => {
|
||||
it('should route through an https proxy agent when HTTPS_PROXY is set', async () => {
|
||||
process.env.HTTPS_PROXY = 'http://fake-proxy.example';
|
||||
|
||||
const axiosSpy = vi.spyOn(axios, 'request').mockResolvedValue({
|
||||
|
|
@ -313,7 +313,9 @@ describe('ClientOAuth2', () => {
|
|||
const requestConfig = axiosSpy.mock.calls[0][0];
|
||||
const httpsAgent = requestConfig.httpsAgent as HttpsProxyAgent<string>;
|
||||
expect(httpsAgent).toBeInstanceOf(HttpsProxyAgent);
|
||||
expect(httpsAgent.connectOpts.rejectUnauthorized).toBe(false);
|
||||
// 'Ignore SSL issues' relaxes the tunnelled session to the target; the
|
||||
// proxy is a different peer and keeps its certificate verified.
|
||||
expect(httpsAgent.connectOpts.rejectUnauthorized).toBeUndefined();
|
||||
// The ignore-SSL branch must keep axios's own proxy handling disabled
|
||||
// so routing stays with our agent, not double-proxied.
|
||||
expect(requestConfig.proxy).toBe(false);
|
||||
|
|
@ -518,7 +520,7 @@ describe('ClientOAuth2', () => {
|
|||
expect(ssrfBridge.validateUrl).toHaveBeenCalledWith(new URL(config.accessTokenUri));
|
||||
});
|
||||
|
||||
it('should relax TLS through the proxy agent without a lookup when ignoreSSLIssues is set', async () => {
|
||||
it('should route through the proxy agent without a lookup when ignoreSSLIssues is set', async () => {
|
||||
process.env.HTTPS_PROXY = 'http://fake-proxy.example';
|
||||
const ssrfBridge = makeSsrfBridge();
|
||||
const axiosSpy = proxiedTokenResponse();
|
||||
|
|
@ -527,7 +529,7 @@ describe('ClientOAuth2', () => {
|
|||
|
||||
const httpsAgent = axiosSpy.mock.calls[0][0].httpsAgent as HttpsProxyAgent<string>;
|
||||
expect(httpsAgent).toBeInstanceOf(HttpsProxyAgent);
|
||||
expect(httpsAgent.connectOpts.rejectUnauthorized).toBe(false);
|
||||
expect(httpsAgent.connectOpts.rejectUnauthorized).toBeUndefined();
|
||||
expect(httpsAgent.connectOpts.lookup).toBeUndefined();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/config",
|
||||
"version": "2.33.0",
|
||||
"version": "2.33.3",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -30,6 +30,14 @@ class CredentialsOverwrite {
|
|||
*/
|
||||
@Env('N8N_SKIP_CREDENTIAL_OVERWRITE')
|
||||
skipTypes: CommaSeparatedStringArray<string> = [];
|
||||
|
||||
/**
|
||||
* Comma-separated list of managed OAuth credential types whose scope fields
|
||||
* remain user-configurable.
|
||||
* @example `N8N_MANAGED_OAUTH_SHOW_SCOPES=googleOAuth2Api`
|
||||
*/
|
||||
@Env('N8N_MANAGED_OAUTH_SHOW_SCOPES')
|
||||
showScopes: CommaSeparatedStringArray<string> = [];
|
||||
}
|
||||
|
||||
@Config
|
||||
|
|
|
|||
|
|
@ -193,4 +193,17 @@ export class InstanceAiConfig {
|
|||
*/
|
||||
@Env('N8N_INSTANCE_AI_MCP_CONNECTIONS_ENABLED')
|
||||
mcpConnectionsEnabled: boolean = false;
|
||||
|
||||
/**
|
||||
* Activation-capped trial variant for n8n cloud experiment.
|
||||
* Set by the cloud dashboard at deploy time on one signup-experiment cohort only.
|
||||
*/
|
||||
@Env('N8N_INSTANCE_AI_ACTIVATION_CAPPED')
|
||||
activationCapped: boolean = false;
|
||||
|
||||
/**
|
||||
* How many assistant messages the instance must have sent before {@link activationCapped} locking may apply.
|
||||
*/
|
||||
@Env('N8N_INSTANCE_AI_ACTIVATION_LOCK_MESSAGE_THRESHOLD')
|
||||
activationLockMessageThreshold: number = 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ import { positiveIntSchema } from '../schemas';
|
|||
const callerPolicySchema = z.enum(['any', 'none', 'workflowsFromAList', 'workflowsFromSameOwner']);
|
||||
type CallerPolicy = z.infer<typeof callerPolicySchema>;
|
||||
|
||||
// Bounded so lease-derived timeouts stay far below Node's max timer delay (~24.8 days).
|
||||
const outboxLeaseSecondsSchema = positiveIntSchema.max(Time.days.toSeconds);
|
||||
|
||||
@Config
|
||||
export class WorkflowsConfig {
|
||||
/** Default name suggested when creating a new workflow. */
|
||||
|
|
@ -34,8 +37,9 @@ export class WorkflowsConfig {
|
|||
publicationOutboxPollIntervalMs: number = 15 * Time.seconds.toMilliseconds;
|
||||
|
||||
/** Seconds after which an `in_progress` workflow publication outbox record
|
||||
* is considered stale (its leader likely died) and may be reclaimed by a poll cycle. */
|
||||
@Env('N8N_WORKFLOW_PUBLICATION_OUTBOX_LEASE_SECONDS')
|
||||
* is considered stale (its leader likely died) and may be reclaimed by a poll cycle.
|
||||
* Must be at most one day. */
|
||||
@Env('N8N_WORKFLOW_PUBLICATION_OUTBOX_LEASE_SECONDS', outboxLeaseSecondsSchema)
|
||||
publicationOutboxLeaseSeconds: number = 2 * Time.minutes.toSeconds;
|
||||
|
||||
/** Number of workflow publication outbox records the leader processes in parallel per drain. */
|
||||
|
|
|
|||
|
|
@ -135,6 +135,7 @@ describe('GlobalConfig', () => {
|
|||
endpoint: '',
|
||||
endpointAuthToken: '',
|
||||
persistence: false,
|
||||
showScopes: [],
|
||||
skipTypes: [],
|
||||
},
|
||||
},
|
||||
|
|
@ -359,6 +360,8 @@ describe('GlobalConfig', () => {
|
|||
thinkingEnabled: true,
|
||||
durableLog: true,
|
||||
mcpConnectionsEnabled: false,
|
||||
activationCapped: false,
|
||||
activationLockMessageThreshold: 1,
|
||||
},
|
||||
queue: {
|
||||
health: {
|
||||
|
|
@ -740,6 +743,18 @@ describe('GlobalConfig', () => {
|
|||
expect(config.agents.sandboxSnapshot).toBe('n8n/agent-knowledge:1.2.3');
|
||||
});
|
||||
|
||||
it('should parse N8N_MANAGED_OAUTH_SHOW_SCOPES from env variables', () => {
|
||||
process.env = {
|
||||
N8N_MANAGED_OAUTH_SHOW_SCOPES: 'googleOAuth2Api,microsoftOAuth2Api',
|
||||
};
|
||||
const config = Container.get(GlobalConfig);
|
||||
|
||||
expect(config.credentials.overwrite.showScopes).toEqual([
|
||||
'googleOAuth2Api',
|
||||
'microsoftOAuth2Api',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should use values from env variables when defined', () => {
|
||||
process.env = {
|
||||
DB_POSTGRESDB_HOST: 'some-host',
|
||||
|
|
@ -976,6 +991,24 @@ describe('GlobalConfig', () => {
|
|||
const config = Container.get(GlobalConfig);
|
||||
expect(config.workflows.workflowPublicationConcurrency).toBe(3);
|
||||
});
|
||||
|
||||
it('should reject an outbox lease above one day and fall back to the default', () => {
|
||||
process.env = { N8N_WORKFLOW_PUBLICATION_OUTBOX_LEASE_SECONDS: `${100 * 24 * 60 * 60}` };
|
||||
const config = Container.get(GlobalConfig);
|
||||
expect(config.workflows.publicationOutboxLeaseSeconds).toBe(120);
|
||||
expect(consoleWarnMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('N8N_WORKFLOW_PUBLICATION_OUTBOX_LEASE_SECONDS'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject an outbox lease of 0 and fall back to the default', () => {
|
||||
process.env = { N8N_WORKFLOW_PUBLICATION_OUTBOX_LEASE_SECONDS: '0' };
|
||||
const config = Container.get(GlobalConfig);
|
||||
expect(config.workflows.publicationOutboxLeaseSeconds).toBe(120);
|
||||
expect(consoleWarnMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('N8N_WORKFLOW_PUBLICATION_OUTBOX_LEASE_SECONDS'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should clamp password min length to valid range', () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/create-node",
|
||||
"version": "0.43.0",
|
||||
"version": "0.43.4",
|
||||
"description": "Official CLI to create new community nodes for n8n",
|
||||
"bin": {
|
||||
"create-node": "bin/create-node.cjs"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/db",
|
||||
"version": "1.35.0",
|
||||
"version": "1.35.5",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo .tmp-schema-docs",
|
||||
"gen:migration-index": "node scripts/generate-migration-index.mjs",
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ import {
|
|||
UNPUBLISH_VERSION_SENTINEL,
|
||||
WorkflowPublicationOutbox,
|
||||
WorkflowPublicationOutboxStatus,
|
||||
WorkflowPublicationReason,
|
||||
} from './workflow-publication-outbox';
|
||||
import {
|
||||
WorkflowPublicationTriggerStatus,
|
||||
|
|
@ -142,6 +143,7 @@ export {
|
|||
WorkflowHistory,
|
||||
WorkflowPublicationOutbox,
|
||||
WorkflowPublicationOutboxStatus,
|
||||
WorkflowPublicationReason,
|
||||
UNPUBLISH_VERSION_SENTINEL,
|
||||
WorkflowPublicationTriggerStatus,
|
||||
type WorkflowPublicationTriggerStatusType,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,25 @@ export const WorkflowPublicationOutboxStatus = {
|
|||
export type WorkflowPublicationOutboxStatus =
|
||||
(typeof WorkflowPublicationOutboxStatus)[keyof typeof WorkflowPublicationOutboxStatus];
|
||||
|
||||
/**
|
||||
* Why a publication record was enqueued. The applier translates it into the
|
||||
* `WorkflowActivateMode` reported to trigger nodes, so e.g. the n8n Trigger's
|
||||
* "Instance Started" event fires only for the leader's startup pass.
|
||||
*/
|
||||
export const WorkflowPublicationReason = {
|
||||
/** A user published or unpublished the workflow. */
|
||||
Publish: 'publish',
|
||||
/** The leader's initial pass after the process booted. */
|
||||
Startup: 'startup',
|
||||
/** A running instance became leader without restarting. */
|
||||
LeadershipTakeover: 'leadership-takeover',
|
||||
/** A periodic reconciliation pass healing detected drift. */
|
||||
Reconcile: 'reconcile',
|
||||
} as const;
|
||||
|
||||
export type WorkflowPublicationReason =
|
||||
(typeof WorkflowPublicationReason)[keyof typeof WorkflowPublicationReason];
|
||||
|
||||
@Entity({ name: 'workflow_publication_outbox' })
|
||||
@Index('IDX_workflow_publication_outbox_active_workflow_status', ['workflowId', 'status'], {
|
||||
unique: true,
|
||||
|
|
@ -39,6 +58,9 @@ export class WorkflowPublicationOutbox extends WithTimestamps {
|
|||
@Column({ type: 'varchar', length: 20 })
|
||||
status: WorkflowPublicationOutboxStatus;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'publish' })
|
||||
reason: WorkflowPublicationReason;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
const tableName = 'workflow_publication_outbox';
|
||||
const columnName = 'reason';
|
||||
|
||||
/**
|
||||
* Adds `reason` so each publication record carries why it was enqueued: a user
|
||||
* publish/unpublish, the leader's startup pass, a leadership takeover, or the
|
||||
* reconciler healing drift. The applier translates it into the activation mode
|
||||
* reported to trigger nodes (e.g. the n8n Trigger's "Instance Started" event
|
||||
* fires only for `startup`).
|
||||
*
|
||||
* NOT NULL with a `publish` default so rows inserted by pre-upgrade instances
|
||||
* during a rolling deploy keep today's behavior (activation mode `update`).
|
||||
*/
|
||||
export class AddReasonToWorkflowPublicationOutbox1786519946974 implements ReversibleMigration {
|
||||
async up({ schemaBuilder: { addColumns, column } }: MigrationContext) {
|
||||
await addColumns(
|
||||
tableName,
|
||||
[
|
||||
column(columnName)
|
||||
.varchar(20)
|
||||
.notNull.default("'publish'")
|
||||
.withEnumCheck(['publish', 'startup', 'leadership-takeover', 'reconcile'])
|
||||
.comment(
|
||||
'Why this publication was enqueued: user publish/unpublish, leader startup pass, leadership takeover, or reconciler drift-heal',
|
||||
),
|
||||
],
|
||||
{ recreatesOnSqlite: true },
|
||||
);
|
||||
}
|
||||
|
||||
async down({ schemaBuilder: { dropColumns, dropEnumCheck } }: MigrationContext) {
|
||||
await dropEnumCheck(tableName, columnName, { recreatesOnSqlite: true });
|
||||
await dropColumns(tableName, [columnName], { recreatesOnSqlite: true });
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,24 @@ export class SettingsRepository extends BaseRepository<Settings> {
|
|||
await this.managerFor(ctx).upsert(Settings, { key, value, loadOnStartup }, ['key']);
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time claim of a key: sets the value only when the key was never
|
||||
* claimed before, and reports whether this call was the claimant. Concurrent
|
||||
* claimants (multi-main boots, parallel saves) resolve to exactly one winner
|
||||
* because only one conditional update can flip the empty marker.
|
||||
*/
|
||||
async claimKey(key: string, value: string): Promise<boolean> {
|
||||
await this.manager
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.into(Settings)
|
||||
.values({ key, value: '', loadOnStartup: false })
|
||||
.orIgnore()
|
||||
.execute();
|
||||
const result = await this.manager.update(Settings, { key, value: '' }, { value });
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
async findByKeys(keys: string[]): Promise<Settings[]> {
|
||||
return await this.findBy({ key: In(keys) });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,11 +8,12 @@ import {
|
|||
UNPUBLISH_VERSION_SENTINEL,
|
||||
WorkflowPublicationOutbox,
|
||||
WorkflowPublicationOutboxStatus as Status,
|
||||
type WorkflowPublicationReason,
|
||||
} from '../entities/workflow-publication-outbox';
|
||||
import { isUniqueConstraintError } from '../utils/is-unique-constraint-error';
|
||||
|
||||
/** Sqlite bound-variable budget per statement; safely under every build's cap. */
|
||||
const SQLITE_ENQUEUE_CHUNK_SIZE = 999;
|
||||
/** Sqlite bound-variable budget per statement (ids + the reason); safely under every build's cap. */
|
||||
const SQLITE_ENQUEUE_CHUNK_SIZE = 998;
|
||||
|
||||
@Service()
|
||||
export class WorkflowPublicationOutboxRepository extends Repository<WorkflowPublicationOutbox> {
|
||||
|
|
@ -47,23 +48,33 @@ export class WorkflowPublicationOutboxRepository extends Repository<WorkflowPubl
|
|||
*
|
||||
* Pass `trx` to run the UPSERT inside an existing transaction, e.g. to make
|
||||
* the enqueue atomic with a `workflow_entity` update.
|
||||
*
|
||||
* The conflict path also supersedes `reason`: a fresher enqueue's intent
|
||||
* wins (e.g. a user publish replacing a pending startup record).
|
||||
*/
|
||||
async enqueue(
|
||||
workflowId: string,
|
||||
publishedVersionId: string,
|
||||
reason: WorkflowPublicationReason,
|
||||
trx?: EntityManager,
|
||||
): Promise<void> {
|
||||
if (this.globalConfig.database.type === 'postgresdb') {
|
||||
await this.enqueueWithPostgresUpsert(workflowId, publishedVersionId, trx ?? this.manager);
|
||||
await this.enqueueWithPostgresUpsert(
|
||||
workflowId,
|
||||
publishedVersionId,
|
||||
reason,
|
||||
trx ?? this.manager,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.enqueueWithSqliteUpsert(workflowId, publishedVersionId, trx ?? this.manager);
|
||||
await this.enqueueWithSqliteUpsert(workflowId, publishedVersionId, reason, trx ?? this.manager);
|
||||
}
|
||||
|
||||
private async enqueueWithPostgresUpsert(
|
||||
workflowId: string,
|
||||
publishedVersionId: string,
|
||||
reason: WorkflowPublicationReason,
|
||||
trx: EntityManager,
|
||||
): Promise<void> {
|
||||
const tableName = this.getTableName('workflow_publication_outbox');
|
||||
|
|
@ -71,27 +82,28 @@ export class WorkflowPublicationOutboxRepository extends Repository<WorkflowPubl
|
|||
// `createdAt`/`updatedAt` carry DB-level defaults, so the insert omits
|
||||
// them; the conflict path bumps `updatedAt` explicitly.
|
||||
await trx.query(
|
||||
`INSERT INTO ${tableName} ("workflowId", "publishedVersionId", "status")
|
||||
VALUES ($1, $2, '${Status.Pending}')
|
||||
`INSERT INTO ${tableName} ("workflowId", "publishedVersionId", "status", "reason")
|
||||
VALUES ($1, $2, '${Status.Pending}', $3)
|
||||
ON CONFLICT ("workflowId", "status") WHERE "status" IN ('${Status.Pending}', '${Status.InProgress}')
|
||||
DO UPDATE SET "publishedVersionId" = EXCLUDED."publishedVersionId", "updatedAt" = CURRENT_TIMESTAMP(3)`,
|
||||
[workflowId, publishedVersionId],
|
||||
DO UPDATE SET "publishedVersionId" = EXCLUDED."publishedVersionId", "reason" = EXCLUDED."reason", "updatedAt" = CURRENT_TIMESTAMP(3)`,
|
||||
[workflowId, publishedVersionId, reason],
|
||||
);
|
||||
}
|
||||
|
||||
private async enqueueWithSqliteUpsert(
|
||||
workflowId: string,
|
||||
publishedVersionId: string,
|
||||
reason: WorkflowPublicationReason,
|
||||
trx: EntityManager,
|
||||
): Promise<void> {
|
||||
const tableName = this.getTableName('workflow_publication_outbox');
|
||||
|
||||
await trx.query(
|
||||
`INSERT INTO ${tableName} ("workflowId", "publishedVersionId", "status")
|
||||
VALUES (?, ?, '${Status.Pending}')
|
||||
`INSERT INTO ${tableName} ("workflowId", "publishedVersionId", "status", "reason")
|
||||
VALUES (?, ?, '${Status.Pending}', ?)
|
||||
ON CONFLICT ("workflowId", "status") WHERE "status" IN ('${Status.Pending}', '${Status.InProgress}')
|
||||
DO UPDATE SET "publishedVersionId" = excluded."publishedVersionId", "updatedAt" = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')`,
|
||||
[workflowId, publishedVersionId],
|
||||
DO UPDATE SET "publishedVersionId" = excluded."publishedVersionId", "reason" = excluded."reason", "updatedAt" = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')`,
|
||||
[workflowId, publishedVersionId, reason],
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -104,18 +116,24 @@ export class WorkflowPublicationOutboxRepository extends Repository<WorkflowPubl
|
|||
* an unpublish record that clears their stale trigger-status rows. Idempotent
|
||||
* via the same partial-unique-index upsert as {@link enqueue}.
|
||||
*/
|
||||
async enqueueByWorkflowIds(workflowIds: string[]): Promise<void> {
|
||||
async enqueueByWorkflowIds(
|
||||
workflowIds: string[],
|
||||
reason: WorkflowPublicationReason,
|
||||
): Promise<void> {
|
||||
if (workflowIds.length === 0) return;
|
||||
|
||||
if (this.globalConfig.database.type === 'postgresdb') {
|
||||
await this.enqueueByWorkflowIdsWithPostgresUpsert(workflowIds);
|
||||
await this.enqueueByWorkflowIdsWithPostgresUpsert(workflowIds, reason);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.enqueueByWorkflowIdsWithSqliteUpsert(workflowIds);
|
||||
await this.enqueueByWorkflowIdsWithSqliteUpsert(workflowIds, reason);
|
||||
}
|
||||
|
||||
private async enqueueByWorkflowIdsWithPostgresUpsert(workflowIds: string[]): Promise<void> {
|
||||
private async enqueueByWorkflowIdsWithPostgresUpsert(
|
||||
workflowIds: string[],
|
||||
reason: WorkflowPublicationReason,
|
||||
): Promise<void> {
|
||||
const outboxTableName = this.getTableName('workflow_publication_outbox');
|
||||
const workflowTableName = this.getTableName('workflow_entity');
|
||||
|
||||
|
|
@ -131,17 +149,20 @@ export class WorkflowPublicationOutboxRepository extends Repository<WorkflowPubl
|
|||
// earlier snapshot). Such a record is at least as fresh as this statement's
|
||||
// snapshot — overwriting it could roll the workflow back to a stale version.
|
||||
await this.query(
|
||||
`INSERT INTO ${outboxTableName} ("workflowId", "publishedVersionId", "status")
|
||||
SELECT w."id", COALESCE(w."activeVersionId", '${UNPUBLISH_VERSION_SENTINEL}'), '${Status.Pending}'
|
||||
`INSERT INTO ${outboxTableName} ("workflowId", "publishedVersionId", "status", "reason")
|
||||
SELECT w."id", COALESCE(w."activeVersionId", '${UNPUBLISH_VERSION_SENTINEL}'), '${Status.Pending}', $2
|
||||
FROM ${workflowTableName} w
|
||||
WHERE w."id" = ANY($1)
|
||||
ON CONFLICT ("workflowId", "status") WHERE "status" IN ('${Status.Pending}', '${Status.InProgress}')
|
||||
DO NOTHING`,
|
||||
[workflowIds],
|
||||
[workflowIds, reason],
|
||||
);
|
||||
}
|
||||
|
||||
private async enqueueByWorkflowIdsWithSqliteUpsert(workflowIds: string[]): Promise<void> {
|
||||
private async enqueueByWorkflowIdsWithSqliteUpsert(
|
||||
workflowIds: string[],
|
||||
reason: WorkflowPublicationReason,
|
||||
): Promise<void> {
|
||||
const outboxTableName = this.getTableName('workflow_publication_outbox');
|
||||
const workflowTableName = this.getTableName('workflow_entity');
|
||||
|
||||
|
|
@ -154,13 +175,13 @@ export class WorkflowPublicationOutboxRepository extends Repository<WorkflowPubl
|
|||
const placeholders = chunk.map(() => '?').join(', ');
|
||||
|
||||
await this.query(
|
||||
`INSERT INTO ${outboxTableName} ("workflowId", "publishedVersionId", "status")
|
||||
SELECT w."id", COALESCE(w."activeVersionId", '${UNPUBLISH_VERSION_SENTINEL}'), '${Status.Pending}'
|
||||
`INSERT INTO ${outboxTableName} ("workflowId", "publishedVersionId", "status", "reason")
|
||||
SELECT w."id", COALESCE(w."activeVersionId", '${UNPUBLISH_VERSION_SENTINEL}'), '${Status.Pending}', ?
|
||||
FROM ${workflowTableName} w
|
||||
WHERE w."id" IN (${placeholders})
|
||||
ON CONFLICT ("workflowId", "status") WHERE "status" IN ('${Status.Pending}', '${Status.InProgress}')
|
||||
DO NOTHING`,
|
||||
chunk,
|
||||
[reason, ...chunk],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/decorators",
|
||||
"version": "1.35.0",
|
||||
"version": "1.35.4",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/engine",
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.3",
|
||||
"description": "n8n workflow execution engine (v2)",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo compiled",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/expression-runtime",
|
||||
"version": "0.26.0",
|
||||
"version": "0.26.1",
|
||||
"description": "Secure, isolated expression evaluation runtime for n8n",
|
||||
"main": "dist/cjs/index.js",
|
||||
"module": "dist/esm/index.js",
|
||||
|
|
|
|||
|
|
@ -277,7 +277,7 @@ export class IsolatedVmBridge implements RuntimeBridge {
|
|||
}
|
||||
|
||||
/**
|
||||
* Create an ivm.Reference callback for getting value/metadata at a path.
|
||||
* Create an ivm.Callback for getting value/metadata at a path.
|
||||
*
|
||||
* Used by createDeepLazyProxy when accessing properties. Returns metadata
|
||||
* markers for arrays and objects, or the primitive value directly.
|
||||
|
|
@ -291,8 +291,8 @@ export class IsolatedVmBridge implements RuntimeBridge {
|
|||
* @param data - Current workflow data to use for callback responses
|
||||
* @private
|
||||
*/
|
||||
private createGetValueAtPathRef(data: WorkflowData): ivm.Reference {
|
||||
return new (getIvm().Reference)((path: string[]) => {
|
||||
private createGetValueAtPathRef(data: WorkflowData): ivm.Callback {
|
||||
return new (getIvm().Callback)((path: string[]) => {
|
||||
try {
|
||||
// Navigate to value
|
||||
// Special-case: paths starting with ['$item', index] call data.$item(index)
|
||||
|
|
@ -361,15 +361,15 @@ export class IsolatedVmBridge implements RuntimeBridge {
|
|||
}
|
||||
|
||||
/**
|
||||
* Create an ivm.Reference callback for getting array elements at an index.
|
||||
* Create an ivm.Callback for getting array elements at an index.
|
||||
*
|
||||
* Used by array proxy when accessing numeric indices.
|
||||
*
|
||||
* @param data - Current workflow data to use for callback responses
|
||||
* @private
|
||||
*/
|
||||
private createGetArrayElementRef(data: WorkflowData): ivm.Reference {
|
||||
return new (getIvm().Reference)((path: string[], index: number) => {
|
||||
private createGetArrayElementRef(data: WorkflowData): ivm.Callback {
|
||||
return new (getIvm().Callback)((path: string[], index: number) => {
|
||||
try {
|
||||
// Navigate to array
|
||||
// Special-case: paths starting with ['$item', index] call data.$item(index)
|
||||
|
|
@ -462,11 +462,17 @@ export class IsolatedVmBridge implements RuntimeBridge {
|
|||
* in this switch; the `type` field selects a static branch in source,
|
||||
* not a property lookup on a runtime object.
|
||||
*
|
||||
* Return-value note: handlers must return plain, structured-clone-able
|
||||
* data. Results cross into the isolate through an ivm.Callback, which copies
|
||||
* them via the structured-clone algorithm — return JSON-shaped values, not
|
||||
* isolated-vm objects (`Reference`/`ExternalCopy`) or other non-cloneable
|
||||
* values.
|
||||
*
|
||||
* @param data - Current workflow data
|
||||
* @private
|
||||
*/
|
||||
private createCallHostRef(data: WorkflowData): ivm.Reference {
|
||||
return new (getIvm().Reference)((rawMsg: unknown) => {
|
||||
private createCallHostRef(data: WorkflowData): ivm.Callback {
|
||||
return new (getIvm().Callback)((rawMsg: unknown) => {
|
||||
try {
|
||||
const msg = bridgeMessageSchema.parse(rawMsg);
|
||||
switch (msg.type) {
|
||||
|
|
@ -695,7 +701,7 @@ export class IsolatedVmBridge implements RuntimeBridge {
|
|||
* Execute JavaScript code in the isolated context.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Create three ivm.Reference callbacks scoped to the current data:
|
||||
* 1. Create three ivm.Callback instances scoped to the current data:
|
||||
* `getValueAtPath`, `getArrayElement`, `callHost`.
|
||||
* 2. Use evalClosureSync to run the code in a closure where `$0`/`$1`/`$2`
|
||||
* are the callback references — no global mutable state.
|
||||
|
|
@ -715,6 +721,10 @@ export class IsolatedVmBridge implements RuntimeBridge {
|
|||
throw new Error('Bridge not initialized. Call initialize() first.');
|
||||
}
|
||||
|
||||
// Host callbacks are ivm.Callback instances: inside the isolate they
|
||||
// arrive as plain functions with structured-clone marshaling, so the
|
||||
// runtime invokes them directly. Callbacks are GC-managed; there is no
|
||||
// release() to call in `finally`.
|
||||
const getValueAtPath = this.createGetValueAtPathRef(data);
|
||||
const getArrayElement = this.createGetArrayElementRef(data);
|
||||
const callHost = this.createCallHostRef(data);
|
||||
|
|
@ -796,10 +806,6 @@ try {
|
|||
);
|
||||
}
|
||||
throw new Error(`Expression evaluation failed: ${errorMessage}`);
|
||||
} finally {
|
||||
getValueAtPath.release();
|
||||
getArrayElement.release();
|
||||
callHost.release();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import { buildContext } from '../context';
|
||||
|
||||
function makeRef(impl: (args: unknown[]) => unknown) {
|
||||
return {
|
||||
applySync: (_thisArg: unknown, args: unknown[]) => impl(args),
|
||||
};
|
||||
return (...args: unknown[]) => impl(args);
|
||||
}
|
||||
|
||||
describe('buildContext proxy', () => {
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@ import { createDeepLazyProxy, isLazyProxy, getProxyPath } from '../lazy-proxy';
|
|||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ivmCallOpts = { arguments: { copy: true }, result: { copy: true } };
|
||||
|
||||
function mockApplySync(returnValue: unknown = undefined) {
|
||||
return vi.fn().mockReturnValue(returnValue);
|
||||
}
|
||||
|
|
@ -22,8 +20,8 @@ function createMockCallbacks(
|
|||
const getArrayElement = overrides.getArrayElement ?? mockApplySync();
|
||||
|
||||
const callbacks = {
|
||||
getValueAtPath: { applySync: getValueAtPath },
|
||||
getArrayElement: { applySync: getArrayElement },
|
||||
getValueAtPath,
|
||||
getArrayElement,
|
||||
};
|
||||
|
||||
return { getValueAtPath, getArrayElement, callbacks };
|
||||
|
|
@ -231,7 +229,7 @@ describe('createDeepLazyProxy', () => {
|
|||
const p = proxy(['data']);
|
||||
mocks.getArrayElement.mockReturnValue('val');
|
||||
p.list[3];
|
||||
expect(mocks.getArrayElement).toHaveBeenCalledWith(null, [['data', 'list'], 3], ivmCallOpts);
|
||||
expect(mocks.getArrayElement).toHaveBeenCalledWith(['data', 'list'], 3);
|
||||
});
|
||||
|
||||
it('returns undefined for non-numeric non-length properties', () => {
|
||||
|
|
@ -272,15 +270,15 @@ describe('createDeepLazyProxy', () => {
|
|||
// Each level triggers __getValueAtPath and creates a nested proxy
|
||||
// a -> returns object metadata
|
||||
const a = p.a;
|
||||
expect(mocks.getValueAtPath).toHaveBeenLastCalledWith(null, [['a']], ivmCallOpts);
|
||||
expect(mocks.getValueAtPath).toHaveBeenLastCalledWith(['a']);
|
||||
|
||||
// a.b -> returns object metadata
|
||||
const b = a.b;
|
||||
expect(mocks.getValueAtPath).toHaveBeenLastCalledWith(null, [['a', 'b']], ivmCallOpts);
|
||||
expect(mocks.getValueAtPath).toHaveBeenLastCalledWith(['a', 'b']);
|
||||
|
||||
// a.b.c -> returns object metadata
|
||||
const c = b.c;
|
||||
expect(mocks.getValueAtPath).toHaveBeenLastCalledWith(null, [['a', 'b', 'c']], ivmCallOpts);
|
||||
expect(mocks.getValueAtPath).toHaveBeenLastCalledWith(['a', 'b', 'c']);
|
||||
expect(getProxyPath(c)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
|
|
@ -303,7 +301,7 @@ describe('createDeepLazyProxy', () => {
|
|||
mocks.getValueAtPath.mockReturnValue('val');
|
||||
const p = proxy(['$json']);
|
||||
p.user;
|
||||
expect(mocks.getValueAtPath).toHaveBeenCalledWith(null, [['$json', 'user']], ivmCallOpts);
|
||||
expect(mocks.getValueAtPath).toHaveBeenCalledWith(['$json', 'user']);
|
||||
});
|
||||
|
||||
it('nested proxies inherit full path', () => {
|
||||
|
|
@ -315,11 +313,7 @@ describe('createDeepLazyProxy', () => {
|
|||
// Accessing a property on the nested proxy should build the full path
|
||||
mocks.getValueAtPath.mockReturnValue('Alice');
|
||||
user.name;
|
||||
expect(mocks.getValueAtPath).toHaveBeenLastCalledWith(
|
||||
null,
|
||||
[['$json', 'user', 'name']],
|
||||
ivmCallOpts,
|
||||
);
|
||||
expect(mocks.getValueAtPath).toHaveBeenLastCalledWith(['$json', 'user', 'name']);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -369,7 +363,7 @@ describe('createDeepLazyProxy', () => {
|
|||
mocks.getValueAtPath.mockReturnValue('val');
|
||||
const p = proxy(['$json']);
|
||||
'foo' in p;
|
||||
expect(mocks.getValueAtPath).toHaveBeenCalledWith(null, [['$json', 'foo']], ivmCallOpts);
|
||||
expect(mocks.getValueAtPath).toHaveBeenCalledWith(['$json', 'foo']);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -461,7 +455,7 @@ describe('createDeepLazyProxy', () => {
|
|||
const p = arrayProxy(['arr'], 3);
|
||||
mocks.getArrayElement.mockReturnValue('first');
|
||||
expect(p[0]).toBe('first');
|
||||
expect(mocks.getArrayElement).toHaveBeenCalledWith(null, [['arr'], 0], ivmCallOpts);
|
||||
expect(mocks.getArrayElement).toHaveBeenCalledWith(['arr'], 0);
|
||||
});
|
||||
|
||||
it('nested array element returns an array-shaped child proxy', () => {
|
||||
|
|
|
|||
|
|
@ -52,30 +52,23 @@ type InputRpcType = (typeof INPUT_RPC_TYPES)[keyof typeof INPUT_RPC_TYPES];
|
|||
// ============================================================================
|
||||
|
||||
/**
|
||||
* The subset of `ivm.Reference` shape the in-isolate runtime relies on.
|
||||
* Declared locally rather than importing from `isolated-vm` because this
|
||||
* module is bundled into the isolate IIFE, where the native module is
|
||||
* unavailable. The host wires real `ivm.Reference` instances which
|
||||
* structurally satisfy this interface.
|
||||
* A bridge callback as seen from inside the isolate: a plain function.
|
||||
* The host wires these as `ivm.Callback` instances, which arrive in the
|
||||
* isolate as ordinary functions whose arguments and return value are copied
|
||||
* via the structured-clone algorithm.
|
||||
*/
|
||||
interface BridgeCallback {
|
||||
applySync(
|
||||
thisArg: unknown,
|
||||
args: unknown[],
|
||||
options?: { arguments?: { copy?: boolean }; result?: { copy?: boolean } },
|
||||
): unknown;
|
||||
}
|
||||
type BridgeCallback = (...args: unknown[]) => unknown;
|
||||
|
||||
/**
|
||||
* Bridge callbacks the in-isolate runtime can invoke synchronously via
|
||||
* `ivm.Reference.applySync`.
|
||||
* Bridge callbacks the in-isolate runtime invokes synchronously as plain
|
||||
* functions (host-side `ivm.Callback` instances).
|
||||
*
|
||||
* - `getValueAtPath`, `getArrayElement`: data-access primitives used by the
|
||||
* lazy-proxy system. Hot path; one ivm.Reference each for minimum overhead.
|
||||
* lazy-proxy system. Hot path; one ivm.Callback each for minimum overhead.
|
||||
* - `callHost`: typed-RPC dispatcher. The in-isolate runtime constructs
|
||||
* an envelope (e.g. `{ type: 'getNodeFirst', nodeName, ... }`) and the
|
||||
* host-side dispatcher validates it with zod before routing to a handler.
|
||||
* A single ivm.Reference covers every typed operation; new operations
|
||||
* A single ivm.Callback covers every typed operation; new operations
|
||||
* are new schemas in `bridge/bridge-messages.ts` + new cases in the
|
||||
* dispatcher switch. The name reflects what this is: a synchronous
|
||||
* host RPC, not a postMessage-style async send.
|
||||
|
|
@ -198,11 +191,7 @@ export function buildContext(
|
|||
const lazyProxy = createDeepLazyProxy(['$', nodeName], undefined, callbacks);
|
||||
const sendNodeMethod = (type: NodeRpcType) => {
|
||||
return (branchIndex?: number, runIndex?: number) => {
|
||||
const result = callbacks.callHost.applySync(
|
||||
null,
|
||||
[{ type, nodeName, branchIndex, runIndex }],
|
||||
{ arguments: { copy: true }, result: { copy: true } },
|
||||
);
|
||||
const result = callbacks.callHost({ type, nodeName, branchIndex, runIndex });
|
||||
throwIfErrorSentinel(result);
|
||||
return result;
|
||||
};
|
||||
|
|
@ -217,18 +206,12 @@ export function buildContext(
|
|||
type: 'getNodePairedItem' | 'getNodeItemMatching',
|
||||
itemIndex?: number,
|
||||
) => {
|
||||
const result = callbacks.callHost.applySync(null, [{ type, nodeName, itemIndex }], {
|
||||
arguments: { copy: true },
|
||||
result: { copy: true },
|
||||
});
|
||||
const result = callbacks.callHost({ type, nodeName, itemIndex });
|
||||
throwIfErrorSentinel(result);
|
||||
return result;
|
||||
};
|
||||
const sendGetNodeItem = () => {
|
||||
const result = callbacks.callHost.applySync(null, [{ type: 'getNodeItem', nodeName }], {
|
||||
arguments: { copy: true },
|
||||
result: { copy: true },
|
||||
});
|
||||
const result = callbacks.callHost({ type: 'getNodeItem', nodeName });
|
||||
throwIfErrorSentinel(result);
|
||||
return result;
|
||||
};
|
||||
|
|
@ -271,10 +254,7 @@ export function buildContext(
|
|||
const lazyInputProxy = createDeepLazyProxy(['$input'], undefined, callbacks);
|
||||
const sendInputMethod = (type: InputRpcType) => {
|
||||
return () => {
|
||||
const result = callbacks.callHost.applySync(null, [{ type }], {
|
||||
arguments: { copy: true },
|
||||
result: { copy: true },
|
||||
});
|
||||
const result = callbacks.callHost({ type });
|
||||
throwIfErrorSentinel(result);
|
||||
return result;
|
||||
};
|
||||
|
|
@ -295,11 +275,7 @@ export function buildContext(
|
|||
// args, and the host's `WorkflowDataProxy.$items` applies its own
|
||||
// defaults when fields are undefined.
|
||||
target.$items = (nodeName?: string, outputIndex?: number, runIndex?: number) => {
|
||||
const result = callbacks.callHost.applySync(
|
||||
null,
|
||||
[{ type: 'getItems', nodeName, outputIndex, runIndex }],
|
||||
{ arguments: { copy: true }, result: { copy: true } },
|
||||
);
|
||||
const result = callbacks.callHost({ type: 'getItems', nodeName, outputIndex, runIndex });
|
||||
throwIfErrorSentinel(result);
|
||||
return result;
|
||||
};
|
||||
|
|
@ -316,11 +292,13 @@ export function buildContext(
|
|||
valueType?: string,
|
||||
defaultValue?: unknown,
|
||||
) => {
|
||||
const result = callbacks.callHost.applySync(
|
||||
null,
|
||||
[{ type: 'fromAi', name, description, valueType, defaultValue }],
|
||||
{ arguments: { copy: true }, result: { copy: true } },
|
||||
);
|
||||
const result = callbacks.callHost({
|
||||
type: 'fromAi',
|
||||
name,
|
||||
description,
|
||||
valueType,
|
||||
defaultValue,
|
||||
});
|
||||
throwIfErrorSentinel(result);
|
||||
return result;
|
||||
};
|
||||
|
|
@ -333,11 +311,7 @@ export function buildContext(
|
|||
// Under the VM engine this re-enters the bridge on a fresh evaluation;
|
||||
// the legacy engine handles it inline.
|
||||
target.$evaluateExpression = (expression: string, itemIndex?: number) => {
|
||||
const result = callbacks.callHost.applySync(
|
||||
null,
|
||||
[{ type: 'evaluateExpression', expression, itemIndex }],
|
||||
{ arguments: { copy: true }, result: { copy: true } },
|
||||
);
|
||||
const result = callbacks.callHost({ type: 'evaluateExpression', expression, itemIndex });
|
||||
throwIfErrorSentinel(result);
|
||||
return result;
|
||||
};
|
||||
|
|
@ -352,18 +326,12 @@ export function buildContext(
|
|||
incomingSourceData: unknown,
|
||||
initialPairedItem: unknown,
|
||||
) => {
|
||||
const result = callbacks.callHost.applySync(
|
||||
null,
|
||||
[
|
||||
{
|
||||
type: 'getPairedItem',
|
||||
destinationNodeName,
|
||||
incomingSourceData,
|
||||
initialPairedItem,
|
||||
},
|
||||
],
|
||||
{ arguments: { copy: true }, result: { copy: true } },
|
||||
);
|
||||
const result = callbacks.callHost({
|
||||
type: 'getPairedItem',
|
||||
destinationNodeName,
|
||||
incomingSourceData,
|
||||
initialPairedItem,
|
||||
});
|
||||
throwIfErrorSentinel(result);
|
||||
return result;
|
||||
};
|
||||
|
|
@ -374,7 +342,7 @@ export function buildContext(
|
|||
// so each key is fetched at most once per evaluation.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// Track keys we've already probed so we never call applySync twice
|
||||
// Track keys we've already probed so we never call getValueAtPath twice
|
||||
// for the same key — even if the host returned undefined.
|
||||
const probedKeys = new Set<string>();
|
||||
|
||||
|
|
@ -383,10 +351,7 @@ export function buildContext(
|
|||
|
||||
let value: unknown;
|
||||
try {
|
||||
value = callbacks.getValueAtPath.applySync(null, [[key]], {
|
||||
arguments: { copy: true },
|
||||
result: { copy: true },
|
||||
});
|
||||
value = callbacks.getValueAtPath([key]);
|
||||
} catch {
|
||||
// Don't mark as probed — the throw may be transient
|
||||
// (e.g. host data not yet available) and a retry should be allowed.
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ export type ProxyMeta = { kind: 'object'; keys?: string[] } | { kind: 'array'; l
|
|||
*
|
||||
* @param basePath - Current path in object tree (e.g., ['$json', 'user'])
|
||||
* @param meta - Optional shape descriptor (object/array + known keys/length)
|
||||
* @param callbacks - ivm.Reference callbacks for cross-isolate communication
|
||||
* @param callbacks - ivm.Callback functions for cross-isolate communication
|
||||
* @returns Proxy object with lazy loading behavior
|
||||
*/
|
||||
export function createDeepLazyProxy(
|
||||
|
|
@ -133,10 +133,7 @@ export function createDeepLazyProxy(
|
|||
function resolveObjectKeys(): string[] {
|
||||
if (objectKeys) return objectKeys;
|
||||
if (fetchedKeys) return fetchedKeys;
|
||||
const value = getValueAtPath.applySync(null, [basePath], {
|
||||
arguments: { copy: true },
|
||||
result: { copy: true },
|
||||
});
|
||||
const value = getValueAtPath(basePath);
|
||||
throwIfErrorSentinel(value);
|
||||
if (isObjectMetadata(value)) {
|
||||
fetchedKeys = value.__keys;
|
||||
|
|
@ -252,10 +249,7 @@ export function createDeepLazyProxy(
|
|||
if (isArray) {
|
||||
const idx = isInArrayBounds(prop);
|
||||
if (idx === undefined) return undefined;
|
||||
const element = getArrayElement.applySync(null, [basePath, idx], {
|
||||
arguments: { copy: true },
|
||||
result: { copy: true },
|
||||
});
|
||||
const element = getArrayElement(basePath, idx);
|
||||
// Primitives (and null) skip `materializeChild`'s metadata checks.
|
||||
if (element === null || typeof element !== 'object') {
|
||||
targetObj[prop] = element;
|
||||
|
|
@ -269,12 +263,8 @@ export function createDeepLazyProxy(
|
|||
// materializeChild rebuilds it only inside metadata branches.
|
||||
const path = [...basePath, prop];
|
||||
|
||||
// Call back to host to get metadata/value
|
||||
// Note: getValueAtPath is an ivm.Reference passed via callbacks
|
||||
const value = getValueAtPath.applySync(null, [path], {
|
||||
arguments: { copy: true },
|
||||
result: { copy: true },
|
||||
});
|
||||
// Call back to host to get metadata/value.
|
||||
const value = getValueAtPath(path);
|
||||
|
||||
targetObj[prop] = materializeChild(basePath, prop, value);
|
||||
return targetObj[prop];
|
||||
|
|
@ -301,10 +291,7 @@ export function createDeepLazyProxy(
|
|||
|
||||
// Build path and check existence via callback
|
||||
const path = [...basePath, prop];
|
||||
const value = getValueAtPath.applySync(null, [path], {
|
||||
arguments: { copy: true },
|
||||
result: { copy: true },
|
||||
});
|
||||
const value = getValueAtPath(path);
|
||||
|
||||
// Handle errors serialized by host-side callbacks — reconstruct and throw
|
||||
// so the isolate's outer try-catch can serialize them back via __reportError
|
||||
|
|
|
|||
|
|
@ -137,10 +137,10 @@ export interface NodeProxy {
|
|||
* type.
|
||||
*
|
||||
* Return types are `unknown` rather than `INodeExecutionData` / `[]`:
|
||||
* results cross the isolate boundary via `applySync({ result: { copy: true } })`,
|
||||
* which structured-clones the value and erases nominal types. The handlers
|
||||
* pass the clone through verbatim, so a precise return type would be
|
||||
* misleading. Matches the `NodeProxy` return type for the same reason.
|
||||
* `ivm.Callback` structured-clones results as they pass between isolates,
|
||||
* which erases nominal types. The handlers pass the clone through verbatim,
|
||||
* so a precise return type would be misleading. Matches the `NodeProxy`
|
||||
* return type for the same reason.
|
||||
*/
|
||||
export interface InputProxy {
|
||||
first?: () => unknown;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/instance-ai",
|
||||
"version": "1.20.0",
|
||||
"version": "1.20.4",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"typecheck": "tsc --noEmit && tsc -p evaluations/tsconfig.json",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { InstanceAiPermissions } from '@n8n/api-types';
|
||||
import type { Mock } from 'vitest';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { executeTool } from '../../__tests__/tool-test-utils';
|
||||
import type { InstanceAiContext } from '../../types';
|
||||
|
|
@ -40,8 +41,8 @@ function suspendCtx(suspendFn: Mock) {
|
|||
return { resumeData: undefined, suspend: suspendFn } as never;
|
||||
}
|
||||
|
||||
function resumeCtx(approved: boolean) {
|
||||
return { resumeData: { approved } } as never;
|
||||
function resumeCtx(approved: boolean, scope?: 'once' | 'session') {
|
||||
return { resumeData: { approved, ...(scope ? { scope } : {}) } } as never;
|
||||
}
|
||||
|
||||
function noSuspendCtx() {
|
||||
|
|
@ -63,6 +64,21 @@ describe('data-tables tool', () => {
|
|||
expect(tool.description).toContain('load_skill');
|
||||
expect(tool.description).toContain('what data tables do I have?');
|
||||
});
|
||||
|
||||
// The SDK validates resume payloads against this schema (converted to JSON
|
||||
// schema with additionalProperties: false) and replaces resume data with the
|
||||
// parse result — an undeclared `scope` is rejected or silently stripped, so
|
||||
// the "Always allow" grant would never reach the handler.
|
||||
it('resume schema declares scope so the SDK preserves it on resume', () => {
|
||||
const context = createMockContext();
|
||||
const tool = createDataTablesTool(context);
|
||||
|
||||
const parsed: unknown = (tool.resumeSchema as z.ZodTypeAny).parse({
|
||||
approved: true,
|
||||
scope: 'session',
|
||||
});
|
||||
expect(parsed).toEqual({ approved: true, scope: 'session' });
|
||||
});
|
||||
});
|
||||
|
||||
// ── list ────────────────────────────────────────────────────────────────
|
||||
|
|
@ -372,6 +388,48 @@ describe('data-tables tool', () => {
|
|||
expect(result).toEqual({ table });
|
||||
});
|
||||
|
||||
it('should accept scope=session on resume and persist a session grant', async () => {
|
||||
const table = { id: 'dt-new', name: 'Contacts' };
|
||||
const grantSessionToolApproval = vi.fn().mockResolvedValue(undefined);
|
||||
const context = createMockContext({ permissions: {}, grantSessionToolApproval });
|
||||
(context.dataTableService.create as Mock).mockResolvedValue(table);
|
||||
|
||||
const tool = createDataTablesTool(context);
|
||||
const result = await executeTool(tool, createInput as never, resumeCtx(true, 'session'));
|
||||
|
||||
expect(result).toEqual({ table });
|
||||
expect(grantSessionToolApproval).toHaveBeenCalledWith('data-tables:create');
|
||||
});
|
||||
|
||||
it('should not persist a session grant when resume has no scope', async () => {
|
||||
const table = { id: 'dt-new', name: 'Contacts' };
|
||||
const grantSessionToolApproval = vi.fn().mockResolvedValue(undefined);
|
||||
const context = createMockContext({ permissions: {}, grantSessionToolApproval });
|
||||
(context.dataTableService.create as Mock).mockResolvedValue(table);
|
||||
|
||||
const tool = createDataTablesTool(context);
|
||||
await executeTool(tool, createInput as never, resumeCtx(true));
|
||||
|
||||
expect(grantSessionToolApproval).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip HITL when a session grant already exists', async () => {
|
||||
const table = { id: 'dt-new', name: 'Contacts' };
|
||||
const context = createMockContext({
|
||||
permissions: {},
|
||||
sessionApprovedToolKeys: new Set(['data-tables:create']),
|
||||
});
|
||||
(context.dataTableService.create as Mock).mockResolvedValue(table);
|
||||
const suspendFn = vi.fn();
|
||||
|
||||
const tool = createDataTablesTool(context);
|
||||
const result = await executeTool(tool, createInput as never, suspendCtx(suspendFn));
|
||||
|
||||
expect(suspendFn).not.toHaveBeenCalled();
|
||||
expect(context.dataTableService.create).toHaveBeenCalled();
|
||||
expect(result).toEqual({ table });
|
||||
});
|
||||
|
||||
it('should return denied when user denies on resume', async () => {
|
||||
const context = createMockContext({ permissions: {} });
|
||||
|
||||
|
|
|
|||
|
|
@ -760,6 +760,144 @@ describe('workflows tool', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('update action', () => {
|
||||
const workflowPayload = { name: 'Updated WF', nodes: [], connections: {} };
|
||||
|
||||
it('should suspend for approval before updating a foreign workflow', async () => {
|
||||
const context = createMockContext({
|
||||
permissions: { updateWorkflow: 'require_approval' },
|
||||
});
|
||||
(context.workflowService.get as Mock).mockResolvedValue({
|
||||
id: 'wf1',
|
||||
name: 'Foreign WF',
|
||||
});
|
||||
const suspend = vi.fn();
|
||||
|
||||
await executeTool(
|
||||
createWorkflowsTool(context, 'full'),
|
||||
{ action: 'update', workflowId: 'wf1', workflow: workflowPayload },
|
||||
{ suspend } as never,
|
||||
);
|
||||
|
||||
expect(suspend).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'Update workflow "Foreign WF" (ID: wf1)?',
|
||||
severity: 'warning',
|
||||
workflowId: 'wf1',
|
||||
}),
|
||||
);
|
||||
expect(context.workflowService.updateFromWorkflowJSON).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update without approval when the workflow was created in this run', async () => {
|
||||
const context = createMockContext({
|
||||
permissions: { updateWorkflow: 'require_approval' },
|
||||
aiCreatedWorkflowIds: new Set(['wf1']),
|
||||
});
|
||||
(context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue({
|
||||
id: 'wf1',
|
||||
versionId: 'v2',
|
||||
});
|
||||
const suspend = vi.fn();
|
||||
|
||||
const result = await executeTool(
|
||||
createWorkflowsTool(context, 'full'),
|
||||
{ action: 'update', workflowId: 'wf1', workflow: workflowPayload },
|
||||
{ suspend } as never,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ success: true, workflowId: 'wf1' });
|
||||
expect(suspend).not.toHaveBeenCalled();
|
||||
expect(context.workflowService.updateFromWorkflowJSON).toHaveBeenCalledWith(
|
||||
'wf1',
|
||||
workflowPayload,
|
||||
);
|
||||
});
|
||||
|
||||
it('should update without approval when the workflow has a session ownership grant', async () => {
|
||||
const context = createMockContext({
|
||||
permissions: { updateWorkflow: 'require_approval' },
|
||||
sessionApprovedToolKeys: new Set(['workflows:update:wf1']),
|
||||
});
|
||||
(context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue({
|
||||
id: 'wf1',
|
||||
versionId: 'v2',
|
||||
});
|
||||
const suspend = vi.fn();
|
||||
|
||||
const result = await executeTool(
|
||||
createWorkflowsTool(context, 'full'),
|
||||
{ action: 'update', workflowId: 'wf1', workflow: workflowPayload },
|
||||
{ suspend } as never,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ success: true, workflowId: 'wf1' });
|
||||
expect(suspend).not.toHaveBeenCalled();
|
||||
expect(context.workflowService.updateFromWorkflowJSON).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should still block updates when admin policy denies them for owned workflows', async () => {
|
||||
const context = createMockContext({
|
||||
permissions: { updateWorkflow: 'blocked' },
|
||||
aiCreatedWorkflowIds: new Set(['wf1']),
|
||||
});
|
||||
|
||||
const result = await executeTool(
|
||||
createWorkflowsTool(context, 'full'),
|
||||
{ action: 'update', workflowId: 'wf1', workflow: workflowPayload },
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
denied: true,
|
||||
reason: 'Action blocked by admin',
|
||||
});
|
||||
expect(context.workflowService.updateFromWorkflowJSON).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should persist a session update grant when resumed with scope=session', async () => {
|
||||
const grantSessionToolApproval = vi.fn().mockResolvedValue(undefined);
|
||||
const context = createMockContext({
|
||||
permissions: { updateWorkflow: 'require_approval' },
|
||||
grantSessionToolApproval,
|
||||
});
|
||||
(context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue({
|
||||
id: 'wf1',
|
||||
versionId: 'v2',
|
||||
});
|
||||
|
||||
const result = await executeTool(
|
||||
createWorkflowsTool(context, 'full'),
|
||||
{ action: 'update', workflowId: 'wf1', workflow: workflowPayload },
|
||||
{ resumeData: { approved: true, scope: 'session' } } as never,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ success: true, workflowId: 'wf1' });
|
||||
expect(grantSessionToolApproval).toHaveBeenCalledWith('workflows:update:wf1');
|
||||
});
|
||||
|
||||
it('should not persist a grant for a one-time update approval', async () => {
|
||||
const grantSessionToolApproval = vi.fn().mockResolvedValue(undefined);
|
||||
const context = createMockContext({
|
||||
permissions: { updateWorkflow: 'require_approval' },
|
||||
grantSessionToolApproval,
|
||||
});
|
||||
(context.workflowService.updateFromWorkflowJSON as Mock).mockResolvedValue({
|
||||
id: 'wf1',
|
||||
versionId: 'v2',
|
||||
});
|
||||
|
||||
await executeTool(
|
||||
createWorkflowsTool(context, 'full'),
|
||||
{ action: 'update', workflowId: 'wf1', workflow: workflowPayload },
|
||||
{ resumeData: { approved: true } } as never,
|
||||
);
|
||||
|
||||
expect(grantSessionToolApproval).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete action', () => {
|
||||
it('should return denied when permission is blocked', async () => {
|
||||
const context = createMockContext({
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@
|
|||
* add-column, delete-column, rename-column, insert-rows, update-rows, delete-rows.
|
||||
*/
|
||||
import { Tool } from '@n8n/agents';
|
||||
import { instanceAiConfirmationSeveritySchema } from '@n8n/api-types';
|
||||
import {
|
||||
buildDataTablesSessionGrantKey,
|
||||
instanceAiConfirmationSeveritySchema,
|
||||
} from '@n8n/api-types';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { z } from 'zod';
|
||||
|
||||
|
|
@ -49,6 +52,8 @@ const confirmationSuspendSchema = z.object({
|
|||
|
||||
const confirmationResumeSchema = z.object({
|
||||
approved: z.boolean(),
|
||||
/** `'session'` — user chose "always allow"; persist a thread-level grant. */
|
||||
scope: z.enum(['once', 'session']).optional(),
|
||||
});
|
||||
|
||||
type ResumeData = z.infer<typeof confirmationResumeSchema>;
|
||||
|
|
@ -58,6 +63,20 @@ interface ConfirmationToolContext {
|
|||
suspend: (payload: z.infer<typeof confirmationSuspendSchema>) => Promise<never>;
|
||||
}
|
||||
|
||||
function hasSessionGrant(context: InstanceAiContext, action: string): boolean {
|
||||
return context.sessionApprovedToolKeys?.has(buildDataTablesSessionGrantKey(action)) === true;
|
||||
}
|
||||
|
||||
async function persistSessionGrantIfRequested(
|
||||
context: InstanceAiContext,
|
||||
action: string,
|
||||
resumeData: ResumeData | undefined,
|
||||
): Promise<void> {
|
||||
if (resumeData?.approved && resumeData.scope === 'session') {
|
||||
await context.grantSessionToolApproval?.(buildDataTablesSessionGrantKey(action));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error (or its cause chain) is a DataTableNameConflictError.
|
||||
* The error class lives in packages/cli so we can't import it directly —
|
||||
|
|
@ -337,7 +356,8 @@ async function handleCreate(
|
|||
return { denied: true, reason: 'Action blocked by admin' };
|
||||
}
|
||||
|
||||
const needsApproval = context.permissions?.createDataTable !== 'always_allow';
|
||||
const needsApproval =
|
||||
context.permissions?.createDataTable !== 'always_allow' && !hasSessionGrant(context, 'create');
|
||||
|
||||
// State 1: First call — suspend for confirmation (unless always_allow)
|
||||
if (needsApproval && (resumeData === undefined || resumeData === null)) {
|
||||
|
|
@ -359,6 +379,8 @@ async function handleCreate(
|
|||
return { denied: true, reason: 'User denied the action' };
|
||||
}
|
||||
|
||||
await persistSessionGrantIfRequested(context, 'create', resumeData);
|
||||
|
||||
// State 3: Approved or always_allow — execute
|
||||
try {
|
||||
const table = await context.dataTableService.create(input.name, input.columns, {
|
||||
|
|
@ -389,7 +411,8 @@ async function handleDelete(
|
|||
return { success: false, denied: true, reason: 'Action blocked by admin' };
|
||||
}
|
||||
|
||||
const needsApproval = context.permissions?.deleteDataTable !== 'always_allow';
|
||||
const needsApproval =
|
||||
context.permissions?.deleteDataTable !== 'always_allow' && !hasSessionGrant(context, 'delete');
|
||||
|
||||
// State 1: First call — suspend for confirmation (unless always_allow)
|
||||
if (needsApproval && (resumeData === undefined || resumeData === null)) {
|
||||
|
|
@ -405,6 +428,8 @@ async function handleDelete(
|
|||
return { success: false, denied: true, reason: 'User denied the action' };
|
||||
}
|
||||
|
||||
await persistSessionGrantIfRequested(context, 'delete', resumeData);
|
||||
|
||||
// State 3: Approved or always_allow — execute
|
||||
await context.dataTableService.delete(input.dataTableId, { projectId: input.projectId });
|
||||
return { success: true };
|
||||
|
|
@ -421,7 +446,9 @@ async function handleAddColumn(
|
|||
return { denied: true, reason: 'Action blocked by admin' };
|
||||
}
|
||||
|
||||
const needsApproval = context.permissions?.mutateDataTableSchema !== 'always_allow';
|
||||
const needsApproval =
|
||||
context.permissions?.mutateDataTableSchema !== 'always_allow' &&
|
||||
!hasSessionGrant(context, 'add-column');
|
||||
|
||||
// State 1: First call — suspend for confirmation (unless always_allow)
|
||||
if (needsApproval && (resumeData === undefined || resumeData === null)) {
|
||||
|
|
@ -437,6 +464,8 @@ async function handleAddColumn(
|
|||
return { denied: true, reason: 'User denied the action' };
|
||||
}
|
||||
|
||||
await persistSessionGrantIfRequested(context, 'add-column', resumeData);
|
||||
|
||||
// State 3: Approved or always_allow — execute
|
||||
const column = await context.dataTableService.addColumn(
|
||||
input.dataTableId,
|
||||
|
|
@ -457,7 +486,9 @@ async function handleDeleteColumn(
|
|||
return { success: false, denied: true, reason: 'Action blocked by admin' };
|
||||
}
|
||||
|
||||
const needsApproval = context.permissions?.mutateDataTableSchema !== 'always_allow';
|
||||
const needsApproval =
|
||||
context.permissions?.mutateDataTableSchema !== 'always_allow' &&
|
||||
!hasSessionGrant(context, 'delete-column');
|
||||
|
||||
// State 1: First call — suspend for confirmation (unless always_allow)
|
||||
if (needsApproval && (resumeData === undefined || resumeData === null)) {
|
||||
|
|
@ -473,6 +504,8 @@ async function handleDeleteColumn(
|
|||
return { success: false, denied: true, reason: 'User denied the action' };
|
||||
}
|
||||
|
||||
await persistSessionGrantIfRequested(context, 'delete-column', resumeData);
|
||||
|
||||
// State 3: Approved or always_allow — execute
|
||||
await context.dataTableService.deleteColumn(input.dataTableId, input.columnId, {
|
||||
projectId: input.projectId,
|
||||
|
|
@ -491,7 +524,9 @@ async function handleRenameColumn(
|
|||
return { success: false, denied: true, reason: 'Action blocked by admin' };
|
||||
}
|
||||
|
||||
const needsApproval = context.permissions?.mutateDataTableSchema !== 'always_allow';
|
||||
const needsApproval =
|
||||
context.permissions?.mutateDataTableSchema !== 'always_allow' &&
|
||||
!hasSessionGrant(context, 'rename-column');
|
||||
|
||||
// State 1: First call — suspend for confirmation (unless always_allow)
|
||||
if (needsApproval && (resumeData === undefined || resumeData === null)) {
|
||||
|
|
@ -507,6 +542,8 @@ async function handleRenameColumn(
|
|||
return { success: false, denied: true, reason: 'User denied the action' };
|
||||
}
|
||||
|
||||
await persistSessionGrantIfRequested(context, 'rename-column', resumeData);
|
||||
|
||||
// State 3: Approved or always_allow — execute
|
||||
await context.dataTableService.renameColumn(input.dataTableId, input.columnId, input.newName, {
|
||||
projectId: input.projectId,
|
||||
|
|
@ -525,7 +562,9 @@ async function handleInsertRows(
|
|||
return { denied: true, reason: 'Action blocked by admin' };
|
||||
}
|
||||
|
||||
const needsApproval = context.permissions?.mutateDataTableRows !== 'always_allow';
|
||||
const needsApproval =
|
||||
context.permissions?.mutateDataTableRows !== 'always_allow' &&
|
||||
!hasSessionGrant(context, 'insert-rows');
|
||||
|
||||
// State 1: First call — suspend for confirmation (unless always_allow)
|
||||
if (needsApproval && (resumeData === undefined || resumeData === null)) {
|
||||
|
|
@ -541,6 +580,8 @@ async function handleInsertRows(
|
|||
return { denied: true, reason: 'User denied the action' };
|
||||
}
|
||||
|
||||
await persistSessionGrantIfRequested(context, 'insert-rows', resumeData);
|
||||
|
||||
// State 3: Approved or always_allow — execute
|
||||
return await context.dataTableService.insertRows(input.dataTableId, input.rows, {
|
||||
projectId: input.projectId,
|
||||
|
|
@ -558,7 +599,9 @@ async function handleUpdateRows(
|
|||
return { denied: true, reason: 'Action blocked by admin' };
|
||||
}
|
||||
|
||||
const needsApproval = context.permissions?.mutateDataTableRows !== 'always_allow';
|
||||
const needsApproval =
|
||||
context.permissions?.mutateDataTableRows !== 'always_allow' &&
|
||||
!hasSessionGrant(context, 'update-rows');
|
||||
|
||||
// State 1: First call — suspend for confirmation (unless always_allow)
|
||||
if (needsApproval && (resumeData === undefined || resumeData === null)) {
|
||||
|
|
@ -574,6 +617,8 @@ async function handleUpdateRows(
|
|||
return { denied: true, reason: 'User denied the action' };
|
||||
}
|
||||
|
||||
await persistSessionGrantIfRequested(context, 'update-rows', resumeData);
|
||||
|
||||
// State 3: Approved or always_allow — execute
|
||||
return await context.dataTableService.updateRows(input.dataTableId, input.filter, input.data, {
|
||||
projectId: input.projectId,
|
||||
|
|
@ -591,7 +636,9 @@ async function handleDeleteRows(
|
|||
return { success: false, denied: true, reason: 'Action blocked by admin' };
|
||||
}
|
||||
|
||||
const needsApproval = context.permissions?.mutateDataTableRows !== 'always_allow';
|
||||
const needsApproval =
|
||||
context.permissions?.mutateDataTableRows !== 'always_allow' &&
|
||||
!hasSessionGrant(context, 'delete-rows');
|
||||
|
||||
// State 1: First call — suspend for confirmation (unless always_allow)
|
||||
if (needsApproval && (resumeData === undefined || resumeData === null)) {
|
||||
|
|
@ -616,6 +663,8 @@ async function handleDeleteRows(
|
|||
return { success: false, denied: true, reason: 'User denied the action' };
|
||||
}
|
||||
|
||||
await persistSessionGrantIfRequested(context, 'delete-rows', resumeData);
|
||||
|
||||
// State 3: Approved or always_allow — execute
|
||||
const result = await context.dataTableService.deleteRows(input.dataTableId, input.filter, {
|
||||
projectId: input.projectId,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,10 @@ import {
|
|||
summarizeWorkflowStructure,
|
||||
} from './workflows/summarize-workflow';
|
||||
import { validateWorkflowConfig } from './workflows/validate-workflow.service';
|
||||
import {
|
||||
grantSessionWorkflowUpdate,
|
||||
canSkipWorkflowUpdateHitl,
|
||||
} from './workflows/workflow-build-context';
|
||||
import { refreshWorkflowSourceFileBindingFromWorkflow } from './workflows/workflow-file-bindings';
|
||||
import { getReferencedWorkflowIds } from './workflows/workflow-json-utils';
|
||||
|
||||
|
|
@ -205,16 +209,22 @@ const updateVersionAction = z.object({
|
|||
|
||||
// ── Suspend / resume schemas ────────────────────────────────────────────────
|
||||
|
||||
const confirmationSuspendSchema = setupSuspendSchema.pick({
|
||||
requestId: true,
|
||||
message: true,
|
||||
severity: true,
|
||||
});
|
||||
const confirmationSuspendSchema = setupSuspendSchema
|
||||
.pick({
|
||||
requestId: true,
|
||||
message: true,
|
||||
severity: true,
|
||||
workflowId: true,
|
||||
})
|
||||
.partial({ workflowId: true });
|
||||
|
||||
const suspendSchema = z.union([setupSuspendSchema, confirmationSuspendSchema]);
|
||||
|
||||
// Resume: union of standard confirmation (approved) and setup-specific fields.
|
||||
const resumeSchema = setupResumeSchema;
|
||||
// Resume: setup-specific fields plus optional session scope for generic approvals
|
||||
// (e.g. update "always allow" → persist `workflows:update:<id>`).
|
||||
const resumeSchema = setupResumeSchema.extend({
|
||||
scope: z.enum(['once', 'session']).optional(),
|
||||
});
|
||||
|
||||
interface WorkflowToolContext {
|
||||
resumeData: z.infer<typeof resumeSchema> | undefined;
|
||||
|
|
@ -893,7 +903,10 @@ async function handleUpdate(
|
|||
return { success: false, denied: true, reason: 'Action blocked by admin' };
|
||||
}
|
||||
|
||||
const needsApproval = context.permissions?.updateWorkflow !== 'always_allow';
|
||||
// Skip HITL for session-created or always-allowed workflows; others still need approval.
|
||||
const needsApproval =
|
||||
context.permissions?.updateWorkflow !== 'always_allow' &&
|
||||
!canSkipWorkflowUpdateHitl(context, input.workflowId);
|
||||
|
||||
if (needsApproval && (resumeData === undefined || resumeData === null)) {
|
||||
const workflowName = await resolveWorkflowName(context, input.workflowId);
|
||||
|
|
@ -901,6 +914,9 @@ async function handleUpdate(
|
|||
requestId: nanoid(),
|
||||
message: `Update workflow "${workflowName}" (ID: ${input.workflowId})?`,
|
||||
severity: 'warning' as const,
|
||||
// Carried on the confirmation so the UI can scope "always allow" per workflow
|
||||
// even if tool-call args are incomplete on resume.
|
||||
workflowId: input.workflowId,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -908,6 +924,11 @@ async function handleUpdate(
|
|||
return { success: false, denied: true, reason: 'User denied the action' };
|
||||
}
|
||||
|
||||
// "Always allow" — persist so later edits of this workflow skip HITL.
|
||||
if (resumeData?.approved && resumeData.scope === 'session') {
|
||||
await grantSessionWorkflowUpdate(context, input.workflowId);
|
||||
}
|
||||
|
||||
if (!isWorkflowJson(input.workflow)) {
|
||||
return {
|
||||
success: false,
|
||||
|
|
|
|||
|
|
@ -488,9 +488,11 @@ describe('createBuildWorkflowTool', () => {
|
|||
});
|
||||
|
||||
it('updates a workflow created earlier in the run without requesting approval', async () => {
|
||||
const grantSessionToolApproval = vi.fn().mockResolvedValue(undefined);
|
||||
const { context, filePath } = makeContext({
|
||||
source: 'workflow source',
|
||||
overrides: {
|
||||
grantSessionToolApproval,
|
||||
permissions: {
|
||||
createWorkflow: 'always_allow',
|
||||
updateWorkflow: 'require_approval',
|
||||
|
|
@ -507,6 +509,31 @@ describe('createBuildWorkflowTool', () => {
|
|||
expect(created).toMatchObject({ success: true, workflowId: 'wf-1' });
|
||||
expect(updated).toMatchObject({ success: true, workflowId: 'wf-1' });
|
||||
expect(context.aiCreatedWorkflowIds).toEqual(new Set(['wf-1']));
|
||||
expect(grantSessionToolApproval).toHaveBeenCalledWith('workflows:update:wf-1');
|
||||
expect(suspend).not.toHaveBeenCalled();
|
||||
expect(context.workflowService.updateFromWorkflowJSON).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('updates a workflow with a session ownership grant without requesting approval', async () => {
|
||||
const { context, filePath } = makeContext({
|
||||
source: 'workflow source',
|
||||
overrides: {
|
||||
sessionApprovedToolKeys: new Set(['workflows:update:wf-session']),
|
||||
permissions: {
|
||||
createWorkflow: 'always_allow',
|
||||
updateWorkflow: 'require_approval',
|
||||
} as InstanceAiContext['permissions'],
|
||||
},
|
||||
});
|
||||
const suspend = vi.fn();
|
||||
|
||||
const result = await executeTool<BuildToolOutput>(
|
||||
createBuildWorkflowTool(context),
|
||||
{ filePath, workflowId: 'wf-session' },
|
||||
{ suspend },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ success: true, workflowId: 'wf-session' });
|
||||
expect(suspend).not.toHaveBeenCalled();
|
||||
expect(context.workflowService.updateFromWorkflowJSON).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
|
@ -533,12 +560,60 @@ describe('createBuildWorkflowTool', () => {
|
|||
expect.objectContaining({
|
||||
message: 'Edit Target workflow (ID: wf-existing)?',
|
||||
severity: 'warning',
|
||||
workflowId: 'wf-existing',
|
||||
}),
|
||||
);
|
||||
expect(compileWorkflowSource).not.toHaveBeenCalled();
|
||||
expect(context.workflowService.updateFromWorkflowJSON).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('persists a session update grant when edit approval resumes with scope=session', async () => {
|
||||
const grantSessionToolApproval = vi.fn().mockResolvedValue(undefined);
|
||||
const { context, filePath } = makeContext({
|
||||
source: 'workflow source',
|
||||
overrides: {
|
||||
grantSessionToolApproval,
|
||||
permissions: {
|
||||
createWorkflow: 'always_allow',
|
||||
updateWorkflow: 'require_approval',
|
||||
} as InstanceAiContext['permissions'],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeTool<BuildToolOutput>(
|
||||
createBuildWorkflowTool(context),
|
||||
{ filePath, workflowId: 'wf-existing' },
|
||||
{ resumeData: { approved: true, scope: 'session' } },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ success: true, workflowId: 'wf-existing' });
|
||||
expect(grantSessionToolApproval).toHaveBeenCalledWith('workflows:update:wf-existing');
|
||||
expect(context.workflowService.updateFromWorkflowJSON).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not persist a grant for a one-time edit approval', async () => {
|
||||
const grantSessionToolApproval = vi.fn().mockResolvedValue(undefined);
|
||||
const { context, filePath } = makeContext({
|
||||
source: 'workflow source',
|
||||
overrides: {
|
||||
grantSessionToolApproval,
|
||||
permissions: {
|
||||
createWorkflow: 'always_allow',
|
||||
updateWorkflow: 'require_approval',
|
||||
} as InstanceAiContext['permissions'],
|
||||
},
|
||||
});
|
||||
|
||||
await executeTool<BuildToolOutput>(
|
||||
createBuildWorkflowTool(context),
|
||||
{ filePath, workflowId: 'wf-existing' },
|
||||
{ resumeData: { approved: true } },
|
||||
);
|
||||
|
||||
expect(grantSessionToolApproval).not.toHaveBeenCalled();
|
||||
expect(context.workflowService.updateFromWorkflowJSON).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('blocks updates to workflows created earlier in the run when admin policy denies them', async () => {
|
||||
const { context, filePath } = makeContext({
|
||||
source: 'workflow source',
|
||||
|
|
|
|||
|
|
@ -20,8 +20,11 @@ import {
|
|||
combineWarnings,
|
||||
formatWarning,
|
||||
getBuildFailureTrackingKey,
|
||||
grantSessionWorkflowUpdate,
|
||||
isApprovedBuildContext,
|
||||
canSkipWorkflowUpdateHitl,
|
||||
markSourceBuildFailed,
|
||||
recordSessionOwnedWorkflow,
|
||||
resolveBuildIdentifiers,
|
||||
resolveWorkflowName,
|
||||
sourceResponseBase,
|
||||
|
|
@ -77,10 +80,14 @@ const confirmationSuspendSchema = z.object({
|
|||
requestId: z.string(),
|
||||
message: z.string(),
|
||||
severity: instanceAiConfirmationSeveritySchema,
|
||||
/** Resolved target workflow — used by the UI for per-workflow always-allow keys. */
|
||||
workflowId: z.string(),
|
||||
});
|
||||
|
||||
const confirmationResumeSchema = z.object({
|
||||
approved: z.boolean(),
|
||||
/** `'session'` — user chose "always allow"; persist a per-workflow update grant. */
|
||||
scope: z.enum(['once', 'session']).optional(),
|
||||
});
|
||||
|
||||
interface BuildCtx {
|
||||
|
|
@ -421,13 +428,12 @@ export function createBuildWorkflowTool(context: InstanceAiContext) {
|
|||
};
|
||||
}
|
||||
|
||||
const isOwnInFlightWorkflow =
|
||||
targetWorkflowId !== undefined &&
|
||||
(context.aiCreatedWorkflowIds?.has(targetWorkflowId) ?? false);
|
||||
const canSkipUpdateHitl =
|
||||
targetWorkflowId !== undefined && canSkipWorkflowUpdateHitl(context, targetWorkflowId);
|
||||
|
||||
if (
|
||||
targetWorkflowId &&
|
||||
!isOwnInFlightWorkflow &&
|
||||
!canSkipUpdateHitl &&
|
||||
!isApprovedBuildContext(context) &&
|
||||
context.permissions?.updateWorkflow !== 'always_allow'
|
||||
) {
|
||||
|
|
@ -494,8 +500,13 @@ export function createBuildWorkflowTool(context: InstanceAiContext) {
|
|||
requestId: nanoid(),
|
||||
message: `Edit ${workflowName} (ID: ${targetWorkflowId})?`,
|
||||
severity: 'warning',
|
||||
workflowId: targetWorkflowId,
|
||||
});
|
||||
}
|
||||
// "Always allow" — persist so later edits of this workflow skip HITL.
|
||||
if (ctx.resumeData.approved && ctx.resumeData.scope === 'session') {
|
||||
await grantSessionWorkflowUpdate(context, targetWorkflowId);
|
||||
}
|
||||
}
|
||||
|
||||
// Persist inline source first so the workspace file stays canonical for later repairs.
|
||||
|
|
@ -968,7 +979,7 @@ export function createBuildWorkflowTool(context: InstanceAiContext) {
|
|||
...(projectId ? { projectId } : {}),
|
||||
markAsAiTemporary: true,
|
||||
});
|
||||
(context.aiCreatedWorkflowIds ??= new Set<string>()).add(created.id);
|
||||
await recordSessionOwnedWorkflow(context, created.id);
|
||||
return await createSuccessResponse(created, 'create');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { buildUpdateWorkflowSessionGrantKey } from '@n8n/api-types';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import {
|
||||
|
|
@ -12,6 +13,43 @@ export function isApprovedBuildContext(context: InstanceAiContext): boolean {
|
|||
return Boolean(buildContext?.plannedTaskService ?? buildContext?.allowPostPlanWorkflowCreate);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when update HITL can be skipped for this workflow in this session:
|
||||
* created earlier in the current run (`aiCreatedWorkflowIds`), or covered by a
|
||||
* `workflows:update:<id>` thread grant (written on create, or when the user
|
||||
* chose "always allow" for an edit — including foreign workflows). Untrusted
|
||||
* workflows without a grant still require approval.
|
||||
*/
|
||||
export function canSkipWorkflowUpdateHitl(context: InstanceAiContext, workflowId: string): boolean {
|
||||
if (context.aiCreatedWorkflowIds?.has(workflowId) === true) return true;
|
||||
const grantKey = buildUpdateWorkflowSessionGrantKey(workflowId);
|
||||
return context.sessionApprovedToolKeys?.has(grantKey) === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a thread grant so later update HITL for this workflow is skipped
|
||||
* (same run and later runs in the thread). Used when the user chooses
|
||||
* "always allow" on an edit confirmation.
|
||||
*/
|
||||
export async function grantSessionWorkflowUpdate(
|
||||
context: InstanceAiContext,
|
||||
workflowId: string,
|
||||
): Promise<void> {
|
||||
await context.grantSessionToolApproval?.(buildUpdateWorkflowSessionGrantKey(workflowId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a newly created workflow as owned by this session: in-memory for the
|
||||
* current run, and as a persisted thread grant so later runs skip update HITL.
|
||||
*/
|
||||
export async function recordSessionOwnedWorkflow(
|
||||
context: InstanceAiContext,
|
||||
workflowId: string,
|
||||
): Promise<void> {
|
||||
(context.aiCreatedWorkflowIds ??= new Set<string>()).add(workflowId);
|
||||
await grantSessionWorkflowUpdate(context, workflowId);
|
||||
}
|
||||
|
||||
export async function resolveWorkflowName(
|
||||
context: InstanceAiContext,
|
||||
workflowId: string,
|
||||
|
|
|
|||
|
|
@ -1111,14 +1111,12 @@ export interface InstanceAiContext {
|
|||
/** Records workflow code snapshots for the run debug buffer (dev tooling). */
|
||||
recordWorkflowCodeSnapshot?: (snapshot: WorkflowCodeSnapshotInput) => void;
|
||||
/**
|
||||
* IDs of workflows the agent created during the **currently active plan
|
||||
* cycle**. Populated by build-workflow on every successful create, and
|
||||
* hydrated at run start from the persisted plan graph when — and only when —
|
||||
* the plan is still `active` or
|
||||
* `awaiting_replan`, so replan follow-up runs keep the bypass active but
|
||||
* the window closes as soon as the plan settles. Consumed by the delete
|
||||
* handler to skip the confirmation gate when the agent cleans up its own
|
||||
* in-flight artifacts. Lazily initialized on first create.
|
||||
* IDs of workflows the agent created during the **current run**. Populated by
|
||||
* build-workflow on every successful create (via `recordSessionOwnedWorkflow`).
|
||||
* Same-run update HITL bypasses consult this set. Cross-run bypass for
|
||||
* the same thread uses the persisted `workflows:update:<id>` session grant
|
||||
* written at create time — this in-memory set alone does not survive a new run.
|
||||
* Lazily initialized on first create.
|
||||
*/
|
||||
aiCreatedWorkflowIds?: Set<string>;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/mcp-apps",
|
||||
"version": "0.12.0",
|
||||
"version": "0.12.4",
|
||||
"description": "MCP Apps UI resources and server helpers for n8n",
|
||||
"main": "dist/server/index.js",
|
||||
"types": "dist/server/index.d.ts",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/node-cli",
|
||||
"version": "0.44.0",
|
||||
"version": "0.44.4",
|
||||
"description": "Official CLI for developing community nodes for n8n",
|
||||
"bin": {
|
||||
"n8n-node": "bin/n8n-node.mjs"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/node-engine-compatibility",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.4",
|
||||
"description": "Adapts v1 nodes and workflows to the Engine 2.0 execution model",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/n8n-nodes-langchain",
|
||||
"version": "2.35.0",
|
||||
"version": "2.35.4",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"exports": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/scheduler",
|
||||
"version": "1.4.0",
|
||||
"version": "1.4.3",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/task-runner",
|
||||
"version": "2.35.0",
|
||||
"version": "2.35.4",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"start": "node dist/start.js",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/telemetry",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.1",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,36 @@ import { z } from 'zod/v4';
|
|||
|
||||
import { defineTelemetryEvents } from '../define';
|
||||
|
||||
/**
|
||||
* How each AI Assistant setup component is configured. Source (who set it) and
|
||||
* type/provider (what it is) are separate properties on purpose: an env-var
|
||||
* Daytona sandbox reports sandbox_source 'env' and sandbox_type 'daytona',
|
||||
* so neither dimension shadows the other.
|
||||
*/
|
||||
const setupSnapshotProps = {
|
||||
model_source: z
|
||||
.enum(['ui', 'env', 'none'])
|
||||
.describe('Whether the model is configured via a UI credential, env vars, or not at all'),
|
||||
model_provider: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("Model provider, e.g. 'anthropic'. Null when not configured or not derivable"),
|
||||
model_name: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
'Selected model name. Null when not configured; on the page-view event also null when the name is env-managed, which the emitting frontend cannot resolve — "AI Assistant setup completed" carries the resolved name',
|
||||
),
|
||||
sandbox_source: z.enum(['ui', 'env', 'none']),
|
||||
sandbox_type: z.enum(['n8n-sandbox', 'daytona']).nullable(),
|
||||
web_search_source: z
|
||||
.enum(['ui', 'env', 'disabled', 'none'])
|
||||
.describe(
|
||||
"'disabled' means the admin explicitly turned web search off, which counts as decided",
|
||||
),
|
||||
web_search_provider: z.enum(['brave', 'searxng']).nullable(),
|
||||
};
|
||||
|
||||
export const INSTANCE_AI_TELEMETRY = defineTelemetryEvents({
|
||||
BUILDER_SPECCED_TEMPLATED_CRED: {
|
||||
name: 'Builder specced templated cred',
|
||||
|
|
@ -33,4 +63,98 @@ export const INSTANCE_AI_TELEMETRY = defineTelemetryEvents({
|
|||
.describe('No longer model-suppliable; expected absent — presence flags a regression'),
|
||||
}),
|
||||
},
|
||||
USER_VIEWED_AI_ASSISTANT_SETUP_PAGE: {
|
||||
name: 'User viewed AI Assistant setup page',
|
||||
description:
|
||||
'The user landed on a self-hosted AI Assistant setup surface: the onboarding takeover on /assistant, or the settings page on /settings/assistant. Carries the configuration snapshot at view time, so joined with "AI Assistant setup completed" it measures setup drop-off. Not emitted on cloud or proxy deployments, where setup is managed.',
|
||||
properties: z.object({
|
||||
page: z
|
||||
.enum(['onboarding', 'settings'])
|
||||
.describe('Which setup surface: the first-run onboarding wizard or the settings page'),
|
||||
...setupSnapshotProps,
|
||||
}),
|
||||
},
|
||||
USER_CONFIGURED_AI_ASSISTANT_MODEL: {
|
||||
name: 'User configured AI Assistant model',
|
||||
description:
|
||||
'An admin saved an AI Assistant model connection (PUT /instance-ai/settings), covering the first connect, later changes, and same-provider key rotations: first connects are rows where previous_provider is absent. The event marks a saved configuration, not a verified one — the setup wizard verifies before saving, but a direct API save can skip verification. Env-var model config never emits this; it is visible on "Instance started" and on the snapshot events instead.',
|
||||
properties: z.object({
|
||||
provider: z
|
||||
.string()
|
||||
.describe("Model provider derived from the credential type, e.g. 'anthropic'"),
|
||||
model: z.string(),
|
||||
previous_provider: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Absent when nothing was configured before — an absent value marks a first connect',
|
||||
),
|
||||
previous_model: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
USER_CONFIGURED_AI_ASSISTANT_SANDBOX: {
|
||||
name: 'User configured AI Assistant sandbox',
|
||||
description:
|
||||
'An admin saved an AI Assistant sandbox connection (PUT /instance-ai/settings), covering the first connect, later changes, and same-provider key rotations: first connects are rows where previous_sandbox_type is absent. Env-var sandbox config never emits this.',
|
||||
properties: z.object({
|
||||
sandbox_type: z.enum(['n8n-sandbox', 'daytona']),
|
||||
previous_sandbox_type: z
|
||||
.enum(['n8n-sandbox', 'daytona'])
|
||||
.optional()
|
||||
.describe(
|
||||
'Absent when nothing was configured before — an absent value marks a first connect',
|
||||
),
|
||||
}),
|
||||
},
|
||||
USER_CONFIGURED_AI_ASSISTANT_WEB_SEARCH: {
|
||||
name: 'User configured AI Assistant web search',
|
||||
description:
|
||||
'An admin saved an AI Assistant web search connection (PUT /instance-ai/settings), covering the first connect, later changes, and same-provider key rotations: first connects are rows where previous_provider is absent. Explicitly disabling web search does not emit this; that decision is visible as web_search_source "disabled" on the snapshot events.',
|
||||
properties: z.object({
|
||||
provider: z.enum(['brave', 'searxng']),
|
||||
previous_provider: z
|
||||
.enum(['brave', 'searxng'])
|
||||
.optional()
|
||||
.describe(
|
||||
'Absent when nothing was configured before — an absent value marks a first connect',
|
||||
),
|
||||
}),
|
||||
},
|
||||
AI_ASSISTANT_CONNECTION_FAILED: {
|
||||
name: 'AI Assistant connection failed',
|
||||
description:
|
||||
'A setup verification call (POST /instance-ai/settings/verify/*) failed for a model, sandbox, or web search connection. One event covers all three components; the component property tells them apart. Fires once per failed verify attempt, including retries.',
|
||||
properties: z.object({
|
||||
component: z.enum(['model', 'sandbox', 'web_search']),
|
||||
provider: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
'Provider being verified, when known — model provider, sandbox type, or search provider',
|
||||
),
|
||||
failure: z
|
||||
.enum([
|
||||
'unauthorized',
|
||||
'forbidden',
|
||||
'quota_exceeded',
|
||||
'rate_limited',
|
||||
'timeout',
|
||||
'unreachable',
|
||||
'invalid_response',
|
||||
'provider_error',
|
||||
])
|
||||
.describe('Classified failure, same taxonomy the verify response returns to the UI'),
|
||||
error_message: z
|
||||
.string()
|
||||
.describe(
|
||||
'Sanitized provider error: URL queries stripped, length capped, never key values',
|
||||
),
|
||||
}),
|
||||
},
|
||||
AI_ASSISTANT_SETUP_COMPLETED: {
|
||||
name: 'AI Assistant setup completed',
|
||||
description:
|
||||
'A self-hosted instance reached a complete AI Assistant setup for the first time: model configured, sandbox configured, and web search decided (configured or explicitly disabled) — the same predicate that unlocks the assistant UI. Fires at most once per instance, guarded by a persisted settings key, regardless of how the last piece was set: emitted from the settings save path, with a boot-time check so an env-var finish is also counted. No "User" prefix because the last piece can land via env vars with no acting user.',
|
||||
properties: z.object({ ...setupSnapshotProps }),
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -64,8 +64,20 @@ export const INSTANCE_TELEMETRY = defineTelemetryEvents({
|
|||
sandbox_provider: z.string(),
|
||||
search_brave_set: z.boolean(),
|
||||
search_searxng_set: z.boolean(),
|
||||
model_env_set: z
|
||||
.boolean()
|
||||
.describe(
|
||||
'Model connection env vars are set (API key or base URL). UI-credential model state is on the "AI Assistant setup completed" and "User viewed AI Assistant setup page" events instead',
|
||||
),
|
||||
model_id: z
|
||||
.string()
|
||||
.describe(
|
||||
"Configured model identifier incl. provider, e.g. 'anthropic/claude-sonnet-4'",
|
||||
),
|
||||
})
|
||||
.describe('Which sandbox and search providers are configured, never key values'),
|
||||
.describe(
|
||||
'Which model, sandbox and search providers are configured via env vars, never key values',
|
||||
),
|
||||
metrics: z.object({
|
||||
metrics_enabled: z.boolean(),
|
||||
metrics_category_default: z.boolean(),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/tournament",
|
||||
"version": "1.9.0",
|
||||
"version": "1.9.1",
|
||||
"description": "Output compatible rewrite of riot tmpl",
|
||||
"main": "dist/index.js",
|
||||
"module": "src/index.ts",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@ import type {
|
|||
ExpressionKind,
|
||||
PatternKind,
|
||||
PropertyKind,
|
||||
SpreadElementKind,
|
||||
SpreadPropertyKind,
|
||||
StatementKind,
|
||||
SwitchCaseKind,
|
||||
VariableDeclaratorKind,
|
||||
} from 'ast-types/lib/gen/kinds';
|
||||
|
||||
|
|
@ -23,4 +26,7 @@ export type ParentKind =
|
|||
| PropertyKind
|
||||
| PatternKind
|
||||
| VariableDeclaratorKind
|
||||
| CatchClauseKind;
|
||||
| CatchClauseKind
|
||||
| SpreadElementKind
|
||||
| SpreadPropertyKind
|
||||
| SwitchCaseKind;
|
||||
|
|
|
|||
|
|
@ -80,6 +80,10 @@ const customPatches: Partial<Record<ParentKind['type'], CustomPatcher>> = {
|
|||
}
|
||||
},
|
||||
Property(path, parent: namedTypes.Property, dataNode) {
|
||||
if (parent.computed && parent.key === path.node) {
|
||||
polyfillVar(path, dataNode);
|
||||
return;
|
||||
}
|
||||
if (path.node !== parent.value) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -117,6 +121,36 @@ const customPatches: Partial<Record<ParentKind['type'], CustomPatcher>> = {
|
|||
polyfillVar(path, dataNode);
|
||||
}
|
||||
},
|
||||
SpreadElement(path, parent: namedTypes.SpreadElement, dataNode) {
|
||||
if (parent.argument === path.node) {
|
||||
polyfillVar(path, dataNode);
|
||||
}
|
||||
},
|
||||
SpreadProperty(path, parent: namedTypes.SpreadProperty, dataNode) {
|
||||
if (parent.argument === path.node) {
|
||||
polyfillVar(path, dataNode);
|
||||
}
|
||||
},
|
||||
MethodDefinition(path, parent: namedTypes.MethodDefinition, dataNode) {
|
||||
if (parent.computed && parent.key === path.node) {
|
||||
polyfillVar(path, dataNode);
|
||||
}
|
||||
},
|
||||
SwitchCase(path, parent: namedTypes.SwitchCase, dataNode) {
|
||||
if (parent.test === path.node) {
|
||||
polyfillVar(path, dataNode);
|
||||
}
|
||||
},
|
||||
ClassDeclaration(path, parent: namedTypes.ClassDeclaration, dataNode) {
|
||||
if (parent.superClass === path.node) {
|
||||
polyfillVar(path, dataNode);
|
||||
}
|
||||
},
|
||||
ClassExpression(path, parent: namedTypes.ClassExpression, dataNode) {
|
||||
if (parent.superClass === path.node) {
|
||||
polyfillVar(path, dataNode);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const jsVariablePolyfill = (
|
||||
|
|
@ -143,6 +177,12 @@ export const jsVariablePolyfill = (
|
|||
case 'OptionalMemberExpression':
|
||||
case 'VariableDeclarator':
|
||||
case 'ArrowFunctionExpression':
|
||||
case 'SpreadElement':
|
||||
case 'SpreadProperty':
|
||||
case 'MethodDefinition':
|
||||
case 'SwitchCase':
|
||||
case 'ClassDeclaration':
|
||||
case 'ClassExpression':
|
||||
if (!customPatches[parent.type]) {
|
||||
throw new Error(`Couldn't find custom patcher for parent type: ${parent.type}`);
|
||||
}
|
||||
|
|
@ -211,12 +251,9 @@ export const jsVariablePolyfill = (
|
|||
case 'RestElement':
|
||||
case 'ArrayPattern':
|
||||
case 'ObjectPattern':
|
||||
case 'ClassExpression':
|
||||
case 'RecordExpression':
|
||||
case 'V8IntrinsicIdentifier':
|
||||
case 'TopicReference':
|
||||
case 'MethodDefinition':
|
||||
case 'ClassDeclaration':
|
||||
case 'ClassProperty':
|
||||
case 'StaticBlock':
|
||||
case 'ClassBody':
|
||||
|
|
@ -314,6 +351,7 @@ export const jsVariablePolyfill = (
|
|||
// This is a simple type guard that guarantees we haven't missed
|
||||
// a case. It'll result in a type error at compile time.
|
||||
assertNever(parent);
|
||||
polyfillVar(path, dataNode);
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
|
|
|||
107
packages/@n8n/tournament/test/VariablePolyfill.test.ts
Normal file
107
packages/@n8n/tournament/test/VariablePolyfill.test.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { Tournament } from '../src/index';
|
||||
|
||||
const evaluator = new Tournament((e) => {
|
||||
throw e;
|
||||
});
|
||||
|
||||
/**
|
||||
* Every identifier that is a free read has to be resolved through the data
|
||||
* context. One that is skipped resolves against the host scope instead, which
|
||||
* is how `process` and friends become reachable from an expression.
|
||||
*/
|
||||
describe('jsVariablePolyfill', () => {
|
||||
describe('free reads resolve through the data context', () => {
|
||||
it.each([
|
||||
['object spread', '{{ ({...value}).a }}'],
|
||||
['nested object spread', '{{ ({...({...value})}).a }}'],
|
||||
['computed object key', '{{ ({[key]: 1}).a }}'],
|
||||
['computed class field', '{{ (() => { class X { [key] = 1; } return new X().a; })() }}'],
|
||||
[
|
||||
'computed class method',
|
||||
'{{ (() => { class X { [key]() { return 1; } } return new X().a(); })() }}',
|
||||
],
|
||||
['switch case', '{{ (() => { switch (1) { case one: return 1; } })() }}'],
|
||||
])('%s', (_, expression) => {
|
||||
expect(evaluator.execute(expression, { value: { a: 1 }, key: 'a', one: 1 })).toBe(1);
|
||||
});
|
||||
|
||||
it('array spread', () => {
|
||||
expect(evaluator.execute('{{ [...value].length }}', { value: [1, 2, 3] })).toBe(3);
|
||||
});
|
||||
|
||||
it('call argument spread', () => {
|
||||
expect(evaluator.execute('{{ Math.max(...value) }}', { value: [1, 5, 3] })).toBe(5);
|
||||
});
|
||||
|
||||
it('base class', () => {
|
||||
class Base {
|
||||
greet() {
|
||||
return 'hello';
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
evaluator.execute('{{ (() => { class X extends Base {} return new X().greet(); })() }}', {
|
||||
Base,
|
||||
}),
|
||||
).toBe('hello');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unresolved free reads do not fall through to the host scope', () => {
|
||||
it.each([
|
||||
['object spread', '{{ ({...process}) }}'],
|
||||
['nested object spread', '{{ ({...({...process})}) }}'],
|
||||
['spread inside a function', '{{ (() => ({...process}))() }}'],
|
||||
])('%s', (_, expression) => {
|
||||
expect(evaluator.execute(expression, {})).toEqual({});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['array spread', '{{ [...process] }}'],
|
||||
['call argument spread', '{{ ((a) => a)(...process) }}'],
|
||||
])('%s', (_, expression) => {
|
||||
expect(() => evaluator.execute(expression, {})).toThrow(/is not iterable/);
|
||||
});
|
||||
|
||||
it('base class', () => {
|
||||
expect(() => evaluator.execute('{{ (() => { class X extends Buffer {} })() }}', {})).toThrow(
|
||||
/is not a constructor or null/,
|
||||
);
|
||||
});
|
||||
|
||||
it('switch case', () => {
|
||||
expect(
|
||||
evaluator.execute(
|
||||
'{{ (() => { switch (1) { case process: return "host"; } return "safe"; })() }}',
|
||||
{},
|
||||
),
|
||||
).toBe('safe');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['computed object key', '{{ Object.keys({[process]: 1})[0] }}'],
|
||||
[
|
||||
'computed class method key',
|
||||
'{{ (() => { class X { [process]() {} } return Object.getOwnPropertyNames(X.prototype)[1]; })() }}',
|
||||
],
|
||||
])('%s', (_, expression) => {
|
||||
expect(evaluator.execute(expression, { Object })).toBe('undefined');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bindings are left alone', () => {
|
||||
it.each([
|
||||
['rest property', '{{ (() => { const {...rest} = value; return rest.a; })() }}'],
|
||||
['rest element', '{{ (() => { const [...rest] = [1]; return rest[0]; })() }}'],
|
||||
['rest parameter', '{{ ((...rest) => rest[0])(1) }}'],
|
||||
[
|
||||
'local shadowing a host global',
|
||||
'{{ (() => { const process = value; return {...process}.a; })() }}',
|
||||
],
|
||||
['parameter shadowing a host global', '{{ ((process) => ({...process}).a)(value) }}'],
|
||||
])('%s', (_, expression) => {
|
||||
expect(evaluator.execute(expression, { value: { a: 1 } })).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@n8n/utils",
|
||||
"type": "module",
|
||||
"version": "1.43.0",
|
||||
"version": "1.43.1",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -30,24 +30,33 @@ describe('sleep', () => {
|
|||
expect(onResolve).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject immediately if abort signal is already aborted', async () => {
|
||||
it('should reject with the abort reason if abort signal is already aborted', async () => {
|
||||
const abortController = new AbortController();
|
||||
abortController.abort();
|
||||
const reason = new Error('deadline');
|
||||
abortController.abort(reason);
|
||||
|
||||
await expect(sleep(1000, abortController.signal)).rejects.toThrow('Aborted');
|
||||
await expect(sleep(1000, abortController.signal)).rejects.toBe(reason);
|
||||
});
|
||||
|
||||
it('should reject when abort signal is triggered during sleep', async () => {
|
||||
it('should reject with the abort reason when abort signal is triggered during sleep', async () => {
|
||||
const abortController = new AbortController();
|
||||
const reason = new Error('deadline');
|
||||
const onSettled = vi.fn();
|
||||
|
||||
const promise = sleep(1000, abortController.signal).catch(onSettled);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
abortController.abort();
|
||||
abortController.abort(reason);
|
||||
await promise;
|
||||
|
||||
expect(onSettled).toHaveBeenCalledWith(expect.objectContaining({ message: 'Aborted' }));
|
||||
expect(onSettled).toHaveBeenCalledWith(reason);
|
||||
});
|
||||
|
||||
it('should reject with the default abort reason when none is given', async () => {
|
||||
const abortController = new AbortController();
|
||||
abortController.abort();
|
||||
|
||||
await expect(sleep(1000, abortController.signal)).rejects.toThrow('This operation was aborted');
|
||||
});
|
||||
|
||||
it('should clean up timeout when aborted during sleep', async () => {
|
||||
|
|
@ -59,10 +68,22 @@ describe('sleep', () => {
|
|||
await vi.advanceTimersByTimeAsync(50);
|
||||
abortController.abort();
|
||||
|
||||
await expect(sleepPromise).rejects.toThrow('Aborted');
|
||||
await expect(sleepPromise).rejects.toThrow('This operation was aborted');
|
||||
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||
|
||||
clearTimeoutSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should remove the abort listener when the sleep completes', async () => {
|
||||
const abortController = new AbortController();
|
||||
const removeListenerSpy = vi.spyOn(abortController.signal, 'removeEventListener');
|
||||
|
||||
const sleepPromise = sleep(100, abortController.signal);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
await sleepPromise;
|
||||
|
||||
expect(removeListenerSpy).toHaveBeenCalledWith('abort', expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,25 +1,29 @@
|
|||
import { ensureError } from './errors/ensure-error';
|
||||
|
||||
async function sleepWithAbort(ms: number, abortSignal: AbortSignal): Promise<void> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
if (abortSignal.aborted) {
|
||||
reject(new Error('Aborted'));
|
||||
reject(ensureError(abortSignal.reason));
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(resolve, ms);
|
||||
const onAbort = () => {
|
||||
clearTimeout(timeout);
|
||||
reject(ensureError(abortSignal.reason));
|
||||
};
|
||||
|
||||
abortSignal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error('Aborted'));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
const timeout = setTimeout(() => {
|
||||
abortSignal.removeEventListener('abort', onAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
|
||||
abortSignal.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves after `ms` milliseconds, or rejects early if `abortSignal` is aborted.
|
||||
* Resolves after `ms` milliseconds, or rejects with the signal's abort reason
|
||||
* if `abortSignal` fires first.
|
||||
*/
|
||||
export async function sleep(ms: number, abortSignal?: AbortSignal): Promise<void> {
|
||||
if (!abortSignal) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/workflow-sdk",
|
||||
"version": "0.28.0",
|
||||
"version": "0.28.3",
|
||||
"description": "TypeScript SDK for programmatically creating n8n workflows",
|
||||
"exports": {
|
||||
".": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n",
|
||||
"version": "2.35.0",
|
||||
"version": "2.35.5",
|
||||
"description": "n8n Workflow Automation Tool",
|
||||
"main": "dist/index",
|
||||
"types": "dist/index.d.ts",
|
||||
|
|
|
|||
|
|
@ -110,3 +110,7 @@ test('should start a task runner', async () => {
|
|||
|
||||
expect(taskRunnerModule.start).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('execute:batch needs the expression engine', () => {
|
||||
expect(new ExecuteBatch().needsExpressionEngine).toBe(true);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { LicenseState } from '@n8n/backend-common';
|
||||
import { LicenseState, Logger } from '@n8n/backend-common';
|
||||
import { mockInstance } from '@n8n/backend-test-utils';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import type { User, WorkflowEntity, Project } from '@n8n/db';
|
||||
|
|
@ -10,7 +10,9 @@ import {
|
|||
DeploymentKeyRepository,
|
||||
} from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { ErrorReporter } from 'n8n-core';
|
||||
import type { IRun } from 'n8n-workflow';
|
||||
import { Expression } from 'n8n-workflow';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { ActiveExecutions } from '@/active-executions';
|
||||
|
|
@ -41,7 +43,7 @@ const loadNodesAndCredentials = mockInstance(LoadNodesAndCredentials);
|
|||
const shutdownService = mockInstance(ShutdownService);
|
||||
const deprecationService = mockInstance(DeprecationService);
|
||||
mockInstance(MessageEventBus);
|
||||
mockInstance(ExpressionObservabilityProvider);
|
||||
const expressionObservability = mockInstance(ExpressionObservabilityProvider);
|
||||
const posthogClient = mockInstance(PostHogClient);
|
||||
const telemetryEventRelay = mockInstance(TelemetryEventRelay);
|
||||
const externalHooks = mockInstance(ExternalHooks);
|
||||
|
|
@ -50,6 +52,8 @@ mockInstance(LicenseState);
|
|||
mockInstance(CommunityPackagesService);
|
||||
mockInstance(WorkflowFailureNotificationEventRelay);
|
||||
|
||||
const logger = mockInstance(Logger);
|
||||
const errorReporter = mockInstance(ErrorReporter);
|
||||
const dbConnection = mockInstance(DbConnection);
|
||||
dbConnection.init.mockResolvedValue(undefined);
|
||||
dbConnection.migrate.mockResolvedValue(undefined);
|
||||
|
|
@ -60,6 +64,26 @@ const deploymentKeyRepository = mockInstance(DeploymentKeyRepository);
|
|||
deploymentKeyRepository.findActiveByType.mockResolvedValue(null);
|
||||
deploymentKeyRepository.insertOrIgnore.mockResolvedValue(undefined);
|
||||
|
||||
// minimal command for exercising BaseCommand.init():
|
||||
// Execute.init() chains singletons that cannot init twice per process
|
||||
class ReadOnlyCommand extends BaseCommand {}
|
||||
|
||||
// default config for every test, so none depends on what an earlier test leaked
|
||||
// into the container; tests that need different values set their own
|
||||
beforeEach(() => {
|
||||
Container.set(
|
||||
GlobalConfig,
|
||||
mock<GlobalConfig>({
|
||||
taskRunners: {},
|
||||
nodes: {},
|
||||
expressionEngine: { engine: 'legacy' },
|
||||
// must be numeric: the SIGTERM/SIGINT handlers registered by init() compute
|
||||
// a setTimeout delay from it, and a mock proxy yields NaN at pool teardown
|
||||
generic: { gracefulShutdownTimeout: 30 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('should start a task runner', async () => {
|
||||
// arrange
|
||||
|
||||
|
|
@ -85,14 +109,6 @@ test('should start a task runner', async () => {
|
|||
workflowRunner.run.mockResolvedValue('123');
|
||||
activeExecutions.getPostExecutePromise.mockResolvedValue(run);
|
||||
|
||||
Container.set(
|
||||
GlobalConfig,
|
||||
mock<GlobalConfig>({
|
||||
taskRunners: {},
|
||||
nodes: {},
|
||||
}),
|
||||
);
|
||||
|
||||
const cmd = new Execute();
|
||||
// @ts-expect-error Protected property
|
||||
cmd.flags = { id: '123' };
|
||||
|
|
@ -113,8 +129,6 @@ test('should not seed the instance identity and should tolerate deployment key r
|
|||
deploymentKeyRepository.insertOrIgnore.mockClear();
|
||||
deploymentKeyRepository.findActiveByType.mockRejectedValueOnce(new Error('permission denied'));
|
||||
|
||||
// minimal command: Execute.init() chains singletons that cannot init twice per process
|
||||
class ReadOnlyCommand extends BaseCommand {}
|
||||
const cmd = new ReadOnlyCommand();
|
||||
|
||||
// act
|
||||
|
|
@ -125,3 +139,118 @@ test('should not seed the instance identity and should tolerate deployment key r
|
|||
|
||||
expect(deploymentKeyRepository.insertOrIgnore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should not init the expression engine for commands that do not need it', async () => {
|
||||
// arrange
|
||||
|
||||
const initSpy = vi.spyOn(Expression, 'initExpressionEngine').mockResolvedValue(undefined);
|
||||
const setEngineSpy = vi.spyOn(Expression, 'setExpressionEngine').mockReturnValue(undefined);
|
||||
Container.set(
|
||||
GlobalConfig,
|
||||
mock<GlobalConfig>({
|
||||
taskRunners: {},
|
||||
nodes: {},
|
||||
expressionEngine: { engine: 'vm' },
|
||||
generic: { gracefulShutdownTimeout: 30 },
|
||||
}),
|
||||
);
|
||||
|
||||
const cmd = new ReadOnlyCommand();
|
||||
|
||||
// act
|
||||
|
||||
await cmd.init();
|
||||
|
||||
// assert
|
||||
|
||||
expect(initSpy).not.toHaveBeenCalled();
|
||||
// the configured engine is still recorded, so evaluating an expression without
|
||||
// an initialized engine throws instead of silently using the legacy engine
|
||||
expect(setEngineSpy).toHaveBeenCalledWith('vm');
|
||||
});
|
||||
|
||||
test('should exit with a crash when expression engine init fails', async () => {
|
||||
// arrange
|
||||
|
||||
const initSpy = vi
|
||||
.spyOn(Expression, 'initExpressionEngine')
|
||||
.mockRejectedValue(new Error('isolated-vm failed to load'));
|
||||
const exitSpy = vi
|
||||
// @ts-expect-error Protected method
|
||||
.spyOn(BaseCommand.prototype, 'exitWithCrash')
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
Container.set(
|
||||
GlobalConfig,
|
||||
mock<GlobalConfig>({
|
||||
taskRunners: {},
|
||||
nodes: {},
|
||||
expressionEngine: {
|
||||
engine: 'vm',
|
||||
poolSize: 4,
|
||||
maxCodeCacheSize: 1024,
|
||||
bridgeTimeout: 5000,
|
||||
bridgeMemoryLimit: 128,
|
||||
idleTimeout: 30,
|
||||
},
|
||||
generic: { gracefulShutdownTimeout: 30 },
|
||||
}),
|
||||
);
|
||||
|
||||
class ExpressionCommand extends BaseCommand {
|
||||
override needsExpressionEngine = true;
|
||||
}
|
||||
const cmd = new ExpressionCommand();
|
||||
|
||||
// act
|
||||
|
||||
await cmd.init();
|
||||
|
||||
// assert
|
||||
|
||||
expect(initSpy).toHaveBeenCalledTimes(1);
|
||||
expect(initSpy).toHaveBeenCalledWith({
|
||||
engine: 'vm',
|
||||
poolSize: 4,
|
||||
maxCodeCacheSize: 1024,
|
||||
bridgeTimeout: 5000,
|
||||
bridgeMemoryLimit: 128,
|
||||
idleTimeoutMs: 30_000, // the config value is in seconds
|
||||
observability: expressionObservability,
|
||||
});
|
||||
expect(exitSpy).toHaveBeenCalledWith(expect.stringContaining('isolated-vm'), expect.any(Error));
|
||||
});
|
||||
|
||||
test('exitWithCrash logs the crash message to the console', async () => {
|
||||
// arrange
|
||||
|
||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
|
||||
vi.useFakeTimers();
|
||||
|
||||
const cmd = new ReadOnlyCommand();
|
||||
// @ts-expect-error Protected property, set directly to avoid another init() traversal
|
||||
cmd.errorReporter = errorReporter;
|
||||
|
||||
const cause = new Error('isolated-vm failed to load');
|
||||
|
||||
// act
|
||||
|
||||
try {
|
||||
// @ts-expect-error Protected method
|
||||
const promise: Promise<void> = cmd.exitWithCrash('something broke', cause);
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await promise;
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
|
||||
// assert
|
||||
|
||||
// the error reporter may only reach Sentry, so the message must also hit the console
|
||||
expect(logger.error).toHaveBeenCalledWith('something broke', { error: cause });
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
test('execute needs the expression engine', () => {
|
||||
expect(new Execute().needsExpressionEngine).toBe(true);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -363,3 +363,7 @@ describe('Start - AuthRolesService initialization', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('start needs the expression engine', () => {
|
||||
expect(new Start().needsExpressionEngine).toBe(true);
|
||||
});
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user