From 233f1ca0e7eaef4d5eb4d59a9213e08330fa1f33 Mon Sep 17 00:00:00 2001 From: Yen Su <49794855+yens1@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:42:00 +0000 Subject: [PATCH] feat(Databricks Node): Add hidden Databricks Chat Model sub-node (#37299) --- .../__tests__/utils/follow-redirects.test.ts | 66 ++- .../utils/refreshing-auth-fetch.test.ts | 54 ++- .../src/utils/follow-redirects.ts | 24 +- .../src/utils/refreshing-auth-fetch.ts | 15 +- .../src/ssrf/passthrough-egress-filter.ts | 2 + .../@n8n/client-oauth2/src/client-oauth2.ts | 6 +- .../test/EmbeddingsOllama.node.test.ts | 3 + .../test/LmChatOllama.node.test.ts | 3 + .../llms/LMOllama/test/LmOllama.node.test.ts | 3 + .../LmChatDatabricks/LmChatDatabricks.node.ts | 346 +++++++++++++++ .../llms/LmChatDatabricks/databricks.dark.svg | 19 + .../llms/LmChatDatabricks/databricks.svg | 19 + .../test/LmChatDatabricks.test.ts | 334 ++++++++++++++ .../test/token-provider.test.ts | 409 ++++++++++++++++++ .../llms/LmChatDatabricks/token-provider.ts | 123 ++++++ .../__test__/McpClientTool.node.test.ts | 1 + .../nodes/mcp/shared/__test__/utils.test.ts | 32 ++ .../nodes/mcp/shared/runtime.test.ts | 1 + .../nodes-langchain/nodes/mcp/shared/utils.ts | 4 +- packages/@n8n/nodes-langchain/package.json | 1 + .../src/utils/__tests__/auth-fetch.test.ts | 19 + packages/cli/src/utils/auth-fetch.ts | 4 +- .../nodes-base/nodes/Kafka/test/utils.test.ts | 1 + packages/workflow/src/interfaces.ts | 6 +- 24 files changed, 1473 insertions(+), 22 deletions(-) create mode 100644 packages/@n8n/nodes-langchain/nodes/llms/LmChatDatabricks/LmChatDatabricks.node.ts create mode 100644 packages/@n8n/nodes-langchain/nodes/llms/LmChatDatabricks/databricks.dark.svg create mode 100644 packages/@n8n/nodes-langchain/nodes/llms/LmChatDatabricks/databricks.svg create mode 100644 packages/@n8n/nodes-langchain/nodes/llms/LmChatDatabricks/test/LmChatDatabricks.test.ts create mode 100644 packages/@n8n/nodes-langchain/nodes/llms/LmChatDatabricks/test/token-provider.test.ts create mode 100644 packages/@n8n/nodes-langchain/nodes/llms/LmChatDatabricks/token-provider.ts diff --git a/packages/@n8n/ai-utilities/src/__tests__/utils/follow-redirects.test.ts b/packages/@n8n/ai-utilities/src/__tests__/utils/follow-redirects.test.ts index f2f10041653..ad1cbe8391f 100644 --- a/packages/@n8n/ai-utilities/src/__tests__/utils/follow-redirects.test.ts +++ b/packages/@n8n/ai-utilities/src/__tests__/utils/follow-redirects.test.ts @@ -55,8 +55,36 @@ describe('fetchFollowingRedirects', () => { }); expect(onBeforeHop).toHaveBeenCalledTimes(2); - expect(onBeforeHop).toHaveBeenNthCalledWith(1, 'https://example.com/start'); - expect(onBeforeHop).toHaveBeenNthCalledWith(2, 'https://example.com/relative'); + expect(onBeforeHop).toHaveBeenNthCalledWith(1, 'https://example.com/start', { + crossedOrigin: false, + }); + expect(onBeforeHop).toHaveBeenNthCalledWith(2, 'https://example.com/relative', { + crossedOrigin: false, + }); + }); + + it('treats crossedOrigin as permanent, even when the chain returns to the original origin', async () => { + const fetcher = mockFetcher() + .mockResolvedValueOnce(makeRedirect(307, 'https://other.example.com/away')) + .mockResolvedValueOnce(makeRedirect(307, 'https://example.com/back')) + .mockResolvedValueOnce(makeResponse(200)); + const onBeforeHop = vi.fn(); + + await fetchFollowingRedirects( + fetcher, + 'https://example.com', + { headers: { authorization: 'Bearer secret' } }, + { onBeforeHop }, + ); + + expect(onBeforeHop.mock.calls.map((call) => call[1])).toEqual([ + { crossedOrigin: false }, + { crossedOrigin: true }, + { crossedOrigin: true }, + ]); + // Credential headers stripped on the cross-origin hop are not restored either + const hop3Headers = new Headers((fetcher.mock.calls[2][1] as RequestInit).headers); + expect(hop3Headers.get('authorization')).toBeNull(); }); it('aborts the chain when onBeforeHop throws', async () => { @@ -144,6 +172,40 @@ describe('fetchFollowingRedirects', () => { ); }); + it('strips credential headers when a redirect crosses origins', async () => { + const fetcher = mockFetcher() + .mockResolvedValueOnce(makeRedirect(307, 'https://other.example.com/target')) + .mockResolvedValueOnce(makeResponse(200)); + + await fetchFollowingRedirects(fetcher, 'https://example.com', { + headers: { + authorization: 'Bearer secret', + cookie: 'session=abc', + 'proxy-authorization': 'Basic xyz', + 'content-type': 'application/json', + }, + }); + + const hop2Headers = new Headers((fetcher.mock.calls[1][1] as RequestInit).headers); + expect(hop2Headers.get('authorization')).toBeNull(); + expect(hop2Headers.get('cookie')).toBeNull(); + expect(hop2Headers.get('proxy-authorization')).toBeNull(); + expect(hop2Headers.get('content-type')).toBe('application/json'); + }); + + it('keeps credential headers on a same-origin redirect', async () => { + const fetcher = mockFetcher() + .mockResolvedValueOnce(makeRedirect(307, '/moved')) + .mockResolvedValueOnce(makeResponse(200)); + + await fetchFollowingRedirects(fetcher, 'https://example.com/start', { + headers: { authorization: 'Bearer secret' }, + }); + + const hop2Headers = new Headers((fetcher.mock.calls[1][1] as RequestInit).headers); + expect(hop2Headers.get('authorization')).toBe('Bearer secret'); + }); + it('preserves a URL input object on the first hop', async () => { const fetcher = mockFetcher().mockResolvedValue(makeResponse(200)); const url = new URL('https://example.com'); diff --git a/packages/@n8n/ai-utilities/src/__tests__/utils/refreshing-auth-fetch.test.ts b/packages/@n8n/ai-utilities/src/__tests__/utils/refreshing-auth-fetch.test.ts index cd69043e039..acde0580533 100644 --- a/packages/@n8n/ai-utilities/src/__tests__/utils/refreshing-auth-fetch.test.ts +++ b/packages/@n8n/ai-utilities/src/__tests__/utils/refreshing-auth-fetch.test.ts @@ -33,10 +33,10 @@ describe('createRefreshingAuthFetch', () => { expect(init.redirect).toBeUndefined(); }); - it('forwards auth headers to a redirect after its URL is validated', async () => { + it('forwards auth headers to a same-origin redirect after its URL is validated', async () => { const baseFetch = vi .fn() - .mockResolvedValueOnce(makeRedirect('https://redirected.example/mcp')) + .mockResolvedValueOnce(makeRedirect('https://example.com/v2/mcp')) .mockResolvedValueOnce(new Response('ok')); const assertAllowedUrl = vi.fn(); const fetchWithAuth = createRefreshingAuthFetch({ @@ -57,13 +57,55 @@ describe('createRefreshingAuthFetch', () => { const [, secondInit] = baseFetch.mock.calls[1] as [URL, RequestInit]; const headers = new Headers(secondInit.headers); - expect(assertAllowedUrl).toHaveBeenLastCalledWith('https://redirected.example/mcp'); + expect(assertAllowedUrl).toHaveBeenLastCalledWith('https://example.com/v2/mcp'); expect(headers.get('authorization')).toBe('Bearer token'); expect(headers.get('x-api-key')).toBe('secret'); expect(headers.get('cookie')).toBe('session=secret'); expect(headers.get('x-request')).toBe('value'); }); + it('withholds auth headers once a redirect crosses origins, even to a validated URL', async () => { + const baseFetch = vi + .fn() + .mockResolvedValueOnce(makeRedirect('https://other.example/mcp')) + .mockResolvedValueOnce(new Response('ok')); + const fetchWithAuth = createRefreshingAuthFetch({ + baseFetch, + initialHeaders: { Authorization: 'Bearer token', 'X-Api-Key': 'secret' }, + assertAllowedUrl: vi.fn(), + }); + + await fetchWithAuth('https://example.com/mcp', { + headers: { 'X-Request': 'value' }, + }); + + const [, secondInit] = baseFetch.mock.calls[1] as [URL, RequestInit]; + const headers = new Headers(secondInit.headers); + expect(headers.get('authorization')).toBeNull(); + expect(headers.get('x-api-key')).toBeNull(); + expect(headers.get('x-request')).toBe('value'); + }); + + it('does not refresh on a 401 from a cross-origin redirect target', async () => { + const baseFetch = vi + .fn() + .mockResolvedValueOnce(makeRedirect('https://other.example/mcp')) + .mockResolvedValueOnce(makeUnauthorized()); + const refreshHeaders = vi.fn(); + const fetchWithAuth = createRefreshingAuthFetch({ + baseFetch, + initialHeaders: { Authorization: 'Bearer token' }, + refreshHeaders, + assertAllowedUrl: vi.fn(), + }); + + const response = await fetchWithAuth('https://example.com/mcp'); + + expect(response.status).toBe(401); + expect(refreshHeaders).not.toHaveBeenCalled(); + expect(baseFetch).toHaveBeenCalledTimes(2); + }); + describe('401 handling', () => { it('refreshes the token and retries the request once', async () => { const baseFetch = vi @@ -194,7 +236,7 @@ describe('createRefreshingAuthFetch', () => { it('retries with the refreshed token on a redirect-validated request', async () => { const baseFetch = vi .fn() - .mockResolvedValueOnce(makeRedirect('https://redirected.example/mcp')) + .mockResolvedValueOnce(makeRedirect('https://example.com/v2/mcp')) .mockResolvedValueOnce(makeUnauthorized()) .mockResolvedValueOnce(new Response('ok')); const refreshHeaders = vi.fn().mockResolvedValue({ Authorization: 'Bearer fresh' }); @@ -211,9 +253,9 @@ describe('createRefreshingAuthFetch', () => { expect(response.status).toBe(200); expect(authorizationOf(baseFetch.mock.calls[1])).toBe('Bearer stale'); expect(authorizationOf(baseFetch.mock.calls[2])).toBe('Bearer fresh'); - expect(assertAllowedUrl.mock.calls.flat()).toEqual([ + expect(assertAllowedUrl.mock.calls.map((call) => call[0])).toEqual([ 'https://example.com/mcp', - 'https://redirected.example/mcp', + 'https://example.com/v2/mcp', ]); }); }); diff --git a/packages/@n8n/ai-utilities/src/utils/follow-redirects.ts b/packages/@n8n/ai-utilities/src/utils/follow-redirects.ts index 2d0b9cfa958..de2048586eb 100644 --- a/packages/@n8n/ai-utilities/src/utils/follow-redirects.ts +++ b/packages/@n8n/ai-utilities/src/utils/follow-redirects.ts @@ -3,14 +3,22 @@ import { OperationalError } from 'n8n-workflow'; const DEFAULT_MAX_REDIRECTS = 20; export interface FollowRedirectsOptions { - /** Called before each hop; throw to reject. */ - onBeforeHop?: (url: string) => void | Promise; + /** + * Called before each hop; throw to reject. `crossedOrigin` turns true once + * the chain has left its original origin and stays true — fetchers that + * inject their own credential headers must withhold them from then on, + * mirroring how this helper strips credentials it carries in `init`. + */ + onBeforeHop?: (url: string, info: { crossedOrigin: boolean }) => void | Promise; maxRedirects?: number; } +const CREDENTIAL_HEADERS = ['authorization', 'cookie', 'proxy-authorization']; + /** * Manual redirect handling so each hop can be validated before the request is - * sent. 301/302/303 demote unsafe methods to GET per fetch spec. + * sent. Per fetch spec, 301/302/303 demote unsafe methods to GET, and + * credential headers are stripped when a redirect crosses origins. */ export async function fetchFollowingRedirects( fetcher: (input: RequestInfo | URL, init?: RequestInit) => Promise, @@ -22,11 +30,12 @@ export async function fetchFollowingRedirects( let currentInput: string | URL = url; let currentInit: RequestInit = { ...init }; let hops = 0; + let crossedOrigin = false; while (true) { const currentUrlString = currentInput instanceof URL ? currentInput.href : currentInput; if (options?.onBeforeHop) { - await options.onBeforeHop(currentUrlString); + await options.onBeforeHop(currentUrlString, { crossedOrigin }); } const response = await fetcher(currentInput, { @@ -52,6 +61,13 @@ export async function fetchFollowingRedirects( currentInput = new URL(location, currentUrlString); + if (currentInput.origin !== new URL(currentUrlString).origin) { + crossedOrigin = true; + const headers = new Headers(currentInit.headers); + for (const header of CREDENTIAL_HEADERS) headers.delete(header); + currentInit = { ...currentInit, headers }; + } + const method = (currentInit.method ?? 'GET').toUpperCase(); const isUnsafe = method !== 'GET' && method !== 'HEAD'; if ( diff --git a/packages/@n8n/ai-utilities/src/utils/refreshing-auth-fetch.ts b/packages/@n8n/ai-utilities/src/utils/refreshing-auth-fetch.ts index 0f1f19429ba..00ca3b61de0 100644 --- a/packages/@n8n/ai-utilities/src/utils/refreshing-auth-fetch.ts +++ b/packages/@n8n/ai-utilities/src/utils/refreshing-auth-fetch.ts @@ -44,6 +44,9 @@ export function createRefreshingAuthFetch({ return async (input: RequestInfo | URL, init?: RequestInit): Promise => { let retried = false; + // Auth headers follow redirects within the starting origin, but per fetch + // spec are withheld once a hop crosses origins, even to an allowed host + let sendAuth = true; const authedFetch = async ( requestInput: RequestInfo | URL, requestInit?: RequestInit, @@ -52,12 +55,13 @@ export function createRefreshingAuthFetch({ const execute = async () => await baseFetch(requestInput instanceof Request ? requestInput.clone() : requestInput, { ...requestInit, - // Include auth headers for redirect requests too - headers: mergeHeaders(getInputHeaders(requestInput, requestInit), authHeaders), + headers: sendAuth + ? mergeHeaders(getInputHeaders(requestInput, requestInit), authHeaders) + : getInputHeaders(requestInput, requestInit), }); const response = await execute(); - if (response.status !== 401 || !refreshHeaders || retried) return response; + if (response.status !== 401 || !refreshHeaders || retried || !sendAuth) return response; retried = true; const canRetry = authVersion !== requestAuthVersion || (await refresh()); @@ -73,7 +77,10 @@ export function createRefreshingAuthFetch({ // unwrapped to their URL so the redirect loop can carry a stable input. const startUrl = input instanceof Request ? input.url : input; return await fetchFollowingRedirects(authedFetch, startUrl, init, { - onBeforeHop: assertAllowedUrl, + onBeforeHop: async (hopUrl, { crossedOrigin }) => { + sendAuth = !crossedOrigin; + await assertAllowedUrl(hopUrl); + }, }); }; } diff --git a/packages/@n8n/backend-network/src/ssrf/passthrough-egress-filter.ts b/packages/@n8n/backend-network/src/ssrf/passthrough-egress-filter.ts index 2f604dbebc6..afc7836c531 100644 --- a/packages/@n8n/backend-network/src/ssrf/passthrough-egress-filter.ts +++ b/packages/@n8n/backend-network/src/ssrf/passthrough-egress-filter.ts @@ -13,4 +13,6 @@ export const passthroughEgressFilter: NodeEgressFilter = { validateUrl: async () => await Promise.resolve(createResultOk(undefined)), // Same function reference every call, so callers can compare it by identity. createSecureLookup: (): LookupFunction => lookup, + // No egress policy configured, so every redirect target is allowed. + validateRedirectSync: () => {}, }; diff --git a/packages/@n8n/client-oauth2/src/client-oauth2.ts b/packages/@n8n/client-oauth2/src/client-oauth2.ts index ee1f4374d15..b1c760595a3 100644 --- a/packages/@n8n/client-oauth2/src/client-oauth2.ts +++ b/packages/@n8n/client-oauth2/src/client-oauth2.ts @@ -52,9 +52,11 @@ export interface ClientOAuth2Options { /** * When provided, token endpoint requests are validated against the host's * outbound network policy before dispatch, at DNS resolution time, and on - * every redirect. Omit to leave the request unchecked. + * every redirect. Omit to leave the request unchecked. Narrowed to the + * methods this client uses so node-facing filters (`NodeEgressFilter`) + * qualify as well as the full bridge. */ - ssrfBridge?: SsrfBridge; + ssrfBridge?: Pick; } export class ResponseError extends Error { diff --git a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOllama/test/EmbeddingsOllama.node.test.ts b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOllama/test/EmbeddingsOllama.node.test.ts index 3f33b6f90ea..8dfaf158df2 100644 --- a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOllama/test/EmbeddingsOllama.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOllama/test/EmbeddingsOllama.node.test.ts @@ -71,6 +71,7 @@ describe('EmbeddingsOllama', () => { const egressFilter: NodeEgressFilter = { validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)), createSecureLookup: vi.fn().mockReturnValue(secureLookup), + validateRedirectSync: vi.fn(), }; const ctx = setupContext({ baseUrl: 'http://ollama.example.com:11434' }, egressFilter); @@ -93,6 +94,7 @@ describe('EmbeddingsOllama', () => { const egressFilter: NodeEgressFilter = { validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)), createSecureLookup: vi.fn().mockReturnValue(vi.fn()), + validateRedirectSync: vi.fn(), }; const ctx = setupContext( { @@ -111,6 +113,7 @@ describe('EmbeddingsOllama', () => { const egressFilter: NodeEgressFilter = { validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)), createSecureLookup: vi.fn().mockReturnValue(vi.fn()), + validateRedirectSync: vi.fn(), }; const ctx = setupContext( { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMChatOllama/test/LmChatOllama.node.test.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOllama/test/LmChatOllama.node.test.ts index 8de2c2d0f34..a16d0cfe12e 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMChatOllama/test/LmChatOllama.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOllama/test/LmChatOllama.node.test.ts @@ -72,6 +72,7 @@ describe('LmChatOllama', () => { const egressFilter: NodeEgressFilter = { validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)), createSecureLookup: vi.fn().mockReturnValue(secureLookup), + validateRedirectSync: vi.fn(), }; const ctx = setupContext({ baseUrl: 'http://ollama.example.com:11434' }, egressFilter); @@ -92,6 +93,7 @@ describe('LmChatOllama', () => { const egressFilter: NodeEgressFilter = { validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)), createSecureLookup: vi.fn().mockReturnValue(vi.fn()), + validateRedirectSync: vi.fn(), }; const ctx = setupContext( { @@ -110,6 +112,7 @@ describe('LmChatOllama', () => { const egressFilter: NodeEgressFilter = { validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)), createSecureLookup: vi.fn().mockReturnValue(vi.fn()), + validateRedirectSync: vi.fn(), }; const ctx = setupContext( { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMOllama/test/LmOllama.node.test.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMOllama/test/LmOllama.node.test.ts index 7895674a457..a025d5dce40 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMOllama/test/LmOllama.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMOllama/test/LmOllama.node.test.ts @@ -72,6 +72,7 @@ describe('LmOllama', () => { const egressFilter: NodeEgressFilter = { validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)), createSecureLookup: vi.fn().mockReturnValue(secureLookup), + validateRedirectSync: vi.fn(), }; const ctx = setupContext({ baseUrl: 'http://ollama.example.com:11434' }, egressFilter); @@ -92,6 +93,7 @@ describe('LmOllama', () => { const egressFilter: NodeEgressFilter = { validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)), createSecureLookup: vi.fn().mockReturnValue(vi.fn()), + validateRedirectSync: vi.fn(), }; const ctx = setupContext( { @@ -110,6 +112,7 @@ describe('LmOllama', () => { const egressFilter: NodeEgressFilter = { validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)), createSecureLookup: vi.fn().mockReturnValue(vi.fn()), + validateRedirectSync: vi.fn(), }; const ctx = setupContext( { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatDatabricks/LmChatDatabricks.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatDatabricks/LmChatDatabricks.node.ts new file mode 100644 index 00000000000..a33a3c82569 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatDatabricks/LmChatDatabricks.node.ts @@ -0,0 +1,346 @@ +import { ChatOpenAI, type ClientOptions } from '@langchain/openai'; +import { + getProxyAgent, + makeN8nLlmFailedAttemptHandler, + N8nLlmTracing, + getConnectionHintNoticeField, +} from '@n8n/ai-utilities'; +import { + NodeConnectionTypes, + NodeOperationError, + type ILoadOptionsFunctions, + type INodeListSearchResult, + type INodeType, + type INodeTypeDescription, + type ISupplyDataFunctions, + type SupplyData, +} from 'n8n-workflow'; + +import type { DatabricksOAuth2Credential } from './token-provider'; +import { createDatabricksFetch, getDatabricksTokenProvider } from './token-provider'; +import { openAiFailedAttemptHandler } from '../../vendors/OpenAi/helpers/error-handling'; + +// Every request carries a secret (bearer token, or the client secret on the +// mint path), so an http host would ship it in cleartext +function assertHttpsHost(ctx: ILoadOptionsFunctions | ISupplyDataFunctions, host: string) { + if (!URL.canParse(host) || new URL(host).protocol !== 'https:') { + throw new NodeOperationError(ctx.getNode(), 'Databricks host must use https'); + } +} + +interface ServingEndpointsResponse { + endpoints?: Array<{ + name: string; + task?: string; + config?: { + served_entities?: Array<{ + external_model?: { name: string }; + foundation_model?: { name: string }; + }>; + }; + }>; +} + +async function searchModels( + this: ILoadOptionsFunctions, + filter?: string, +): Promise { + const credentials = await this.getCredentials('databricksOAuth2Api'); + assertHttpsHost(this, credentials.host); + const host = credentials.host.replace(/\/$/, ''); + + const response: ServingEndpointsResponse = await this.helpers.httpRequestWithAuthentication.call( + this, + 'databricksOAuth2Api', + { + method: 'GET', + url: `${host}/api/2.0/serving-endpoints`, + headers: { Accept: 'application/json' }, + json: true, + }, + ); + + const endpoints = response.endpoints ?? []; + + const allResults = endpoints + // Covers llm/v1/chat (foundation/external models) and agent/*/chat; custom + // endpoints without a task are reachable via the resourceLocator's ID mode + .filter((endpoint) => endpoint.task?.includes('chat')) + .map((endpoint) => { + const modelNames = (endpoint.config?.served_entities ?? []) + .map((entity) => entity.external_model?.name ?? entity.foundation_model?.name) + .filter(Boolean) + .join(', '); + + return { + name: endpoint.name, + value: endpoint.name, + url: `${host}/ml/endpoints/${endpoint.name}`, + description: modelNames || 'Model serving endpoint', + }; + }); + + if (filter) { + const filterLower = filter.toLowerCase(); + return { + results: allResults.filter( + (r) => + r.name.toLowerCase().includes(filterLower) || + r.description.toLowerCase().includes(filterLower), + ), + }; + } + + return { results: allResults }; +} + +export class LmChatDatabricks implements INodeType { + methods = { + listSearch: { + searchModels, + }, + }; + + description: INodeTypeDescription = { + displayName: 'Databricks Chat Model', + name: 'lmChatDatabricks', + hidden: true, + icon: { light: 'file:databricks.svg', dark: 'file:databricks.dark.svg' }, + group: ['transform'], + version: [1], + description: 'For advanced usage with an AI chain', + defaults: { + name: 'Databricks Chat Model', + }, + codex: { + categories: ['AI'], + subcategories: { + AI: ['Language Models', 'Root Nodes'], + 'Language Models': ['Chat Models (Recommended)'], + }, + resources: { + primaryDocumentation: [ + { + url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.lmchatdatabricks/', + }, + ], + }, + }, + + inputs: [], + + outputs: [NodeConnectionTypes.AiLanguageModel], + outputNames: ['Model'], + credentials: [ + { + name: 'databricksOAuth2Api', + required: true, + }, + ], + properties: [ + getConnectionHintNoticeField([NodeConnectionTypes.AiChain, NodeConnectionTypes.AiAgent]), + { + displayName: + 'If using JSON response format, you must include word "json" in the prompt in your chain or agent. Also, make sure the selected endpoint supports JSON mode.', + name: 'notice', + type: 'notice', + default: '', + displayOptions: { + show: { + '/options.responseFormat': ['json_object'], + }, + }, + }, + { + displayName: 'Model', + name: 'model', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + required: true, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a model...', + typeOptions: { + searchListMethod: 'searchModels', + searchable: true, + }, + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + placeholder: 'my-serving-endpoint', + }, + ], + description: 'The serving endpoint. Choose from the list, or specify an ID.', + }, + { + displayName: 'Options', + name: 'options', + placeholder: 'Add Option', + description: 'Additional options to add', + type: 'collection', + default: {}, + options: [ + { + displayName: 'Frequency Penalty', + name: 'frequencyPenalty', + default: 0, + typeOptions: { maxValue: 2, minValue: -2, numberPrecision: 1 }, + description: + "Positive values penalize new tokens based on their existing frequency in the text so far, decreasing the model's likelihood to repeat the same line verbatim", + type: 'number', + }, + { + displayName: 'Maximum Number of Tokens', + name: 'maxTokens', + default: -1, + description: + 'The maximum number of tokens to generate in the completion. Most models have a context length of 2048 tokens (except for the newest models, which support 32,768).', + type: 'number', + typeOptions: { + maxValue: 32768, + }, + }, + { + displayName: 'Response Format', + name: 'responseFormat', + default: 'text', + type: 'options', + options: [ + { + name: 'Text', + value: 'text', + description: 'Regular text response', + }, + { + name: 'JSON', + value: 'json_object', + description: + 'Enables JSON mode, which should guarantee the message the model generates is valid JSON', + }, + ], + }, + { + displayName: 'Presence Penalty', + name: 'presencePenalty', + default: 0, + typeOptions: { maxValue: 2, minValue: -2, numberPrecision: 1 }, + description: + "Positive values penalize new tokens based on whether they appear in the text so far, increasing the model's likelihood to talk about new topics", + type: 'number', + }, + { + displayName: 'Sampling Temperature', + name: 'temperature', + default: 0.7, + typeOptions: { maxValue: 2, minValue: 0, numberPrecision: 1 }, + description: + 'Controls randomness: Lowering results in less random completions. As the temperature approaches zero, the model will become deterministic and repetitive.', + type: 'number', + }, + { + displayName: 'Timeout', + name: 'timeout', + default: 360000, + description: 'Maximum amount of time a request is allowed to take in milliseconds', + type: 'number', + }, + { + displayName: 'Max Retries', + name: 'maxRetries', + default: 2, + description: 'Maximum number of retries to attempt', + type: 'number', + }, + { + displayName: 'Top P', + name: 'topP', + default: 1, + typeOptions: { maxValue: 1, minValue: 0, numberPrecision: 1 }, + description: + 'Controls diversity via nucleus sampling: 0.5 means half of all likelihood-weighted options are considered. We generally recommend altering this or temperature but not both.', + type: 'number', + }, + ], + }, + ], + }; + + async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise { + const credential = await this.getCredentials('databricksOAuth2Api'); + + // ENT-381 adds user-grant support; until then the in-node mint only works + // with a client secret + if (credential.grantType === 'authorizationCode') { + throw new NodeOperationError( + this.getNode(), + 'User (Authorization Code) login is not supported by this node yet - use a Client Credentials (Service Principal) credential', + ); + } + assertHttpsHost(this, credential.host); + + const baseURL = `${credential.host.replace(/\/$/, '')}/serving-endpoints`; + + const modelName = this.getNodeParameter('model', itemIndex, '', { + extractValue: true, + }) as string; + + const options = this.getNodeParameter('options', itemIndex, {}) as { + frequencyPenalty?: number; + maxTokens?: number; + maxRetries: number; + timeout: number; + presencePenalty?: number; + temperature?: number; + topP?: number; + responseFormat?: 'text' | 'json_object'; + }; + + const egressFilter = this.helpers.getSecureEgressFilter(); + + const timeout = options.timeout; + const configuration: ClientOptions = { + baseURL, + fetch: createDatabricksFetch( + getDatabricksTokenProvider(this.getNode(), credential, egressFilter), + egressFilter, + ), + fetchOptions: { + dispatcher: getProxyAgent( + baseURL, + { + headersTimeout: timeout, + bodyTimeout: timeout, + }, + egressFilter?.createSecureLookup(), + ), + }, + }; + + const modelKwargs: Record = {}; + if (options.responseFormat) { + modelKwargs.response_format = { type: options.responseFormat }; + } + + const model = new ChatOpenAI({ + // Placeholder only - the fetch wrapper overwrites the Authorization header + apiKey: 'databricks-oauth', + model: modelName, + ...options, + timeout, + maxRetries: options.maxRetries ?? 2, + configuration, + callbacks: [new N8nLlmTracing(this)], + modelKwargs: Object.keys(modelKwargs).length > 0 ? modelKwargs : undefined, + onFailedAttempt: makeN8nLlmFailedAttemptHandler(this, openAiFailedAttemptHandler), + }); + + return { + response: model, + }; + } +} diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatDatabricks/databricks.dark.svg b/packages/@n8n/nodes-langchain/nodes/llms/LmChatDatabricks/databricks.dark.svg new file mode 100644 index 00000000000..553c3e41248 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatDatabricks/databricks.dark.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatDatabricks/databricks.svg b/packages/@n8n/nodes-langchain/nodes/llms/LmChatDatabricks/databricks.svg new file mode 100644 index 00000000000..553c3e41248 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatDatabricks/databricks.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatDatabricks/test/LmChatDatabricks.test.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatDatabricks/test/LmChatDatabricks.test.ts new file mode 100644 index 00000000000..891d4c6c59a --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatDatabricks/test/LmChatDatabricks.test.ts @@ -0,0 +1,334 @@ +/* eslint-disable n8n-nodes-base/node-filename-against-convention */ +/* eslint-disable n8n-nodes-base/node-param-description-lowercase-first-char */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ + +/* eslint-disable @typescript-eslint/unbound-method */ +import { ChatOpenAI } from '@langchain/openai'; +import { makeN8nLlmFailedAttemptHandler, getProxyAgent } from '@n8n/ai-utilities'; +import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers'; +import type { ILoadOptionsFunctions, INode, ISupplyDataFunctions } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; +import type { Mocked } from 'vitest'; + +import { LmChatDatabricks } from '../LmChatDatabricks.node'; +import { createDatabricksFetch, getDatabricksTokenProvider } from '../token-provider'; + +vi.mock('@langchain/openai'); +vi.mock('@n8n/ai-utilities'); +vi.mock('../token-provider'); + +const MockedChatOpenAI = vi.mocked(ChatOpenAI); +const mockedMakeN8nLlmFailedAttemptHandler = vi.mocked(makeN8nLlmFailedAttemptHandler); +const mockedGetProxyAgent = vi.mocked(getProxyAgent); +const mockedGetDatabricksTokenProvider = vi.mocked(getDatabricksTokenProvider); +const mockedCreateDatabricksFetch = vi.mocked(createDatabricksFetch); + +const mockTokenProvider = vi.fn(async () => 'test-token'); +const mockFetch = vi.fn() as unknown as typeof fetch; + +const mockCredential = { + host: 'https://my.databricks.com/', + grantType: 'clientCredentials', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + accessTokenUrl: 'https://my.databricks.com/oidc/v1/token', + scope: 'all-apis', + authentication: 'header', +}; + +describe('LmChatDatabricks', () => { + let node: LmChatDatabricks; + + const mockNodeDef: INode = { + id: '1', + name: 'Databricks Chat Model', + typeVersion: 1, + type: '@n8n/n8n-nodes-langchain.lmChatDatabricks', + position: [0, 0], + parameters: {}, + }; + + const setupMockContext = (credentialOverrides: Partial = {}) => { + const ctx = createMockExecuteFunction( + {}, + mockNodeDef, + ) as Mocked; + + ctx.getCredentials = vi.fn().mockResolvedValue({ ...mockCredential, ...credentialOverrides }); + ctx.getNode = vi.fn().mockReturnValue(mockNodeDef); + ctx.helpers.getSecureEgressFilter = vi.fn().mockReturnValue(undefined); + ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => { + if (paramName === 'model') return 'my-chat-endpoint'; + if (paramName === 'options') return {}; + return undefined; + }); + + mockedMakeN8nLlmFailedAttemptHandler.mockReturnValue(vi.fn()); + mockedGetProxyAgent.mockReturnValue({} as any); + mockedGetDatabricksTokenProvider.mockReturnValue(mockTokenProvider); + mockedCreateDatabricksFetch.mockReturnValue(mockFetch); + return ctx; + }; + + beforeEach(() => { + node = new LmChatDatabricks(); + vi.clearAllMocks(); + }); + + describe('node description', () => { + it('should have correct node properties', () => { + expect(node.description).toMatchObject({ + displayName: 'Databricks Chat Model', + name: 'lmChatDatabricks', + group: ['transform'], + version: [1], + hidden: true, + credentials: [{ name: 'databricksOAuth2Api', required: true }], + outputs: ['ai_languageModel'], + outputNames: ['Model'], + }); + }); + }); + + describe('supplyData', () => { + it('should create ChatOpenAI with basic configuration', async () => { + const ctx = setupMockContext(); + + const result = await node.supplyData.call(ctx, 0); + + expect(ctx.getCredentials).toHaveBeenCalledWith('databricksOAuth2Api'); + expect(MockedChatOpenAI).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: 'databricks-oauth', + model: 'my-chat-endpoint', + maxRetries: 2, + callbacks: expect.arrayContaining([expect.any(Object)]), + onFailedAttempt: expect.any(Function), + }), + ); + expect(result).toEqual({ response: expect.any(Object) }); + }); + + it('should strip the trailing slash from the host in baseURL', async () => { + const ctx = setupMockContext(); + + await node.supplyData.call(ctx, 0); + + const callArgs = MockedChatOpenAI.mock.calls[0][0]; + expect(callArgs?.configuration?.baseURL).toBe('https://my.databricks.com/serving-endpoints'); + }); + + it('should wire the token-provider fetch wrapper into ChatOpenAI', async () => { + const ctx = setupMockContext(); + + await node.supplyData.call(ctx, 0); + + expect(mockedGetDatabricksTokenProvider).toHaveBeenCalledWith( + mockNodeDef, + mockCredential, + undefined, + ); + expect(mockedCreateDatabricksFetch).toHaveBeenCalledWith(mockTokenProvider, undefined); + const callArgs = MockedChatOpenAI.mock.calls[0][0]; + expect(callArgs?.configuration?.fetch).toBe(mockFetch); + }); + + it('should thread the egress filter into the token provider and proxy agent', async () => { + const ctx = setupMockContext(); + const secureLookup = vi.fn(); + const egressFilter = { + validateUrl: vi.fn(), + validateRedirectSync: vi.fn(), + createSecureLookup: vi.fn().mockReturnValue(secureLookup), + }; + ctx.helpers.getSecureEgressFilter = vi.fn().mockReturnValue(egressFilter); + + await node.supplyData.call(ctx, 0); + + expect(mockedGetDatabricksTokenProvider).toHaveBeenCalledWith( + mockNodeDef, + mockCredential, + egressFilter, + ); + expect(mockedCreateDatabricksFetch).toHaveBeenCalledWith(mockTokenProvider, egressFilter); + expect(mockedGetProxyAgent).toHaveBeenCalledWith( + 'https://my.databricks.com/serving-endpoints', + expect.any(Object), + secureLookup, + ); + }); + + it('should read the model via resourceLocator value extraction', async () => { + const ctx = setupMockContext(); + + await node.supplyData.call(ctx, 0); + + expect(ctx.getNodeParameter).toHaveBeenCalledWith('model', 0, '', { extractValue: true }); + }); + + it('should pass options to ChatOpenAI', async () => { + const ctx = setupMockContext(); + ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => { + if (paramName === 'model') return 'my-chat-endpoint'; + if (paramName === 'options') + return { + temperature: 0.5, + maxTokens: 2000, + topP: 0.9, + frequencyPenalty: 0.3, + presencePenalty: 0.2, + timeout: 60000, + maxRetries: 5, + }; + return undefined; + }); + + await node.supplyData.call(ctx, 0); + + expect(MockedChatOpenAI).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0.5, + maxTokens: 2000, + topP: 0.9, + frequencyPenalty: 0.3, + presencePenalty: 0.2, + timeout: 60000, + maxRetries: 5, + }), + ); + }); + + it('should set response_format in modelKwargs when responseFormat is provided', async () => { + const ctx = setupMockContext(); + ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => { + if (paramName === 'model') return 'my-chat-endpoint'; + if (paramName === 'options') return { responseFormat: 'json_object' }; + return undefined; + }); + + await node.supplyData.call(ctx, 0); + + expect(MockedChatOpenAI).toHaveBeenCalledWith( + expect.objectContaining({ + modelKwargs: { response_format: { type: 'json_object' } }, + }), + ); + }); + + it('should reject authorizationCode credentials', async () => { + const ctx = setupMockContext({ grantType: 'authorizationCode' }); + + await expect(node.supplyData.call(ctx, 0)).rejects.toThrow(NodeOperationError); + expect(MockedChatOpenAI).not.toHaveBeenCalled(); + }); + + it('should reject non-https hosts', async () => { + const ctx = setupMockContext({ host: 'http://my.databricks.com' }); + + await expect(node.supplyData.call(ctx, 0)).rejects.toThrow(NodeOperationError); + expect(MockedChatOpenAI).not.toHaveBeenCalled(); + }); + + it('should accept an uppercase HTTPS scheme', async () => { + const ctx = setupMockContext({ host: 'HTTPS://my.databricks.com' }); + + await node.supplyData.call(ctx, 0); + + expect(MockedChatOpenAI).toHaveBeenCalled(); + }); + }); + + describe('searchModels', () => { + const endpointsResponse = { + endpoints: [ + { + name: 'chat-endpoint', + task: 'llm/v1/chat', + config: { served_entities: [{ foundation_model: { name: 'llama-3' } }] }, + }, + { name: 'embeddings-endpoint', task: 'llm/v1/embeddings' }, + { + name: 'agent-endpoint', + task: 'agent/v1/chat', + config: { served_entities: [{ external_model: { name: 'gpt-4o' } }] }, + }, + { name: 'custom-endpoint' }, + ], + }; + + let mockContext: Mocked; + let httpRequestWithAuthentication: ReturnType; + + const setupSearchContext = (host: string, response: unknown) => { + httpRequestWithAuthentication = vi.fn().mockResolvedValue(response); + mockContext = { + getCredentials: vi.fn().mockResolvedValue({ ...mockCredential, host }), + getNode: vi.fn().mockReturnValue(mockNodeDef), + helpers: { httpRequestWithAuthentication }, + } as unknown as Mocked; + }; + + it('should reject non-https hosts before requesting the endpoint list', async () => { + setupSearchContext('http://my.databricks.com', endpointsResponse); + + await expect(node.methods.listSearch.searchModels.call(mockContext)).rejects.toThrow( + 'Databricks host must use https', + ); + expect(httpRequestWithAuthentication).not.toHaveBeenCalled(); + }); + + it('should list only chat-capable endpoints', async () => { + setupSearchContext('https://my.databricks.com', endpointsResponse); + + const result = await node.methods.listSearch.searchModels.call(mockContext); + + expect(httpRequestWithAuthentication).toHaveBeenCalledWith( + 'databricksOAuth2Api', + expect.objectContaining({ + method: 'GET', + url: 'https://my.databricks.com/api/2.0/serving-endpoints', + }), + ); + expect(result.results).toEqual([ + { + name: 'chat-endpoint', + value: 'chat-endpoint', + url: 'https://my.databricks.com/ml/endpoints/chat-endpoint', + description: 'llama-3', + }, + { + name: 'agent-endpoint', + value: 'agent-endpoint', + url: 'https://my.databricks.com/ml/endpoints/agent-endpoint', + description: 'gpt-4o', + }, + ]); + }); + + it('should apply the substring filter', async () => { + setupSearchContext('https://my.databricks.com', endpointsResponse); + + const result = await node.methods.listSearch.searchModels.call(mockContext, 'agent'); + + expect(result.results).toEqual([expect.objectContaining({ name: 'agent-endpoint' })]); + }); + + it('should return no results when the response has no endpoints field', async () => { + setupSearchContext('https://my.databricks.com', {}); + + const result = await node.methods.listSearch.searchModels.call(mockContext); + + expect(result).toEqual({ results: [] }); + }); + + it('should strip the trailing slash from the host in the request URL', async () => { + setupSearchContext('https://my.databricks.com/', endpointsResponse); + + await node.methods.listSearch.searchModels.call(mockContext); + + const [, requestOptions] = httpRequestWithAuthentication.mock.calls[0]; + expect(requestOptions.url).toBe('https://my.databricks.com/api/2.0/serving-endpoints'); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatDatabricks/test/token-provider.test.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatDatabricks/test/token-provider.test.ts new file mode 100644 index 00000000000..927f22a4825 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatDatabricks/test/token-provider.test.ts @@ -0,0 +1,409 @@ +import type { INode, NodeEgressFilter } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; + +import type { DatabricksOAuth2Credential } from '../token-provider'; +import { createDatabricksFetch, getDatabricksTokenProvider } from '../token-provider'; + +const { MockClientOAuth2, mockGetToken } = vi.hoisted(() => { + const mockGetToken = vi.fn(); + + class MockClientOAuth2 { + credentials = { getToken: mockGetToken }; + + constructor(readonly options: unknown) { + MockClientOAuth2.init(options); + } + + static init = vi.fn(); + } + + return { MockClientOAuth2, mockGetToken }; +}); + +vi.mock('@n8n/client-oauth2', () => ({ + ClientOAuth2: MockClientOAuth2, +})); + +const mockNode: INode = { + id: '1', + name: 'Databricks Chat Model', + typeVersion: 1, + type: '@n8n/n8n-nodes-langchain.lmChatDatabricks', + position: [0, 0], + parameters: {}, +}; + +const SENTINEL_SECRET = 'sentinel-client-secret-xyz'; + +const mockCredential: DatabricksOAuth2Credential = { + host: 'https://my.databricks.com', + grantType: 'clientCredentials', + clientId: 'test-client-id', + clientSecret: SENTINEL_SECRET, + scope: 'all-apis', + authentication: 'header', +}; + +// Realistic mint failure: an Error instance shaped like the token endpoint's +// error response (status + body own props) +class MockResponseError extends Error { + status = 401; + + body = { error: 'invalid_client', error_description: 'Client authentication failed' }; + + constructor() { + super('401: Client authentication failed (invalid_client)'); + } +} + +function tokenResponse(accessToken: string, expiresIn?: number | string) { + return { + accessToken, + data: { access_token: accessToken, expires_in: expiresIn }, + }; +} + +describe('getDatabricksTokenProvider', () => { + afterEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + it('should mint a token via ClientOAuth2 built from the decrypted credential', async () => { + mockGetToken.mockResolvedValue(tokenResponse('token-a', 3600)); + const getToken = getDatabricksTokenProvider(mockNode, mockCredential); + + await expect(getToken()).resolves.toBe('token-a'); + expect(MockClientOAuth2.init).toHaveBeenCalledWith({ + clientId: 'test-client-id', + clientSecret: SENTINEL_SECRET, + accessTokenUri: 'https://my.databricks.com/oidc/v1/token', + scopes: ['all-apis'], + authentication: 'header', + }); + }); + + it('should derive the mint URL from the host, ignoring a stored accessTokenUrl', async () => { + mockGetToken.mockResolvedValue(tokenResponse('token-a', 3600)); + const poisoned = { + ...mockCredential, + host: 'https://my.databricks.com/', + accessTokenUrl: 'http://attacker.example/token', + } as DatabricksOAuth2Credential; + const getToken = getDatabricksTokenProvider(mockNode, poisoned); + + await expect(getToken()).resolves.toBe('token-a'); + expect(MockClientOAuth2.init).toHaveBeenCalledWith( + expect.objectContaining({ accessTokenUri: 'https://my.databricks.com/oidc/v1/token' }), + ); + }); + + it('should pass the egress filter to ClientOAuth2 as its ssrfBridge', async () => { + mockGetToken.mockResolvedValue(tokenResponse('token-a', 3600)); + const egressFilter = { + validateUrl: vi.fn(), + validateRedirectSync: vi.fn(), + createSecureLookup: vi.fn(), + }; + const getToken = getDatabricksTokenProvider(mockNode, mockCredential, egressFilter); + + await expect(getToken()).resolves.toBe('token-a'); + expect(MockClientOAuth2.init).toHaveBeenCalledWith( + expect.objectContaining({ ssrfBridge: egressFilter }), + ); + }); + + it('should reuse the cached token before the 60s early-expiry buffer', async () => { + vi.useFakeTimers(); + mockGetToken.mockResolvedValue(tokenResponse('token-a', 3600)); + const getToken = getDatabricksTokenProvider(mockNode, mockCredential); + + await expect(getToken()).resolves.toBe('token-a'); + vi.advanceTimersByTime((3600 - 120) * 1000); + await expect(getToken()).resolves.toBe('token-a'); + expect(mockGetToken).toHaveBeenCalledTimes(1); + }); + + it('should re-mint within the last 60s of the token window', async () => { + vi.useFakeTimers(); + mockGetToken + .mockResolvedValueOnce(tokenResponse('token-a', 3600)) + .mockResolvedValueOnce(tokenResponse('token-b', 3600)); + const getToken = getDatabricksTokenProvider(mockNode, mockCredential); + + await expect(getToken()).resolves.toBe('token-a'); + vi.advanceTimersByTime((3600 - 30) * 1000); + await expect(getToken()).resolves.toBe('token-b'); + expect(mockGetToken).toHaveBeenCalledTimes(2); + }); + + it('should re-mint every call when expires_in is missing', async () => { + mockGetToken + .mockResolvedValueOnce(tokenResponse('token-a')) + .mockResolvedValueOnce(tokenResponse('token-b')); + const getToken = getDatabricksTokenProvider(mockNode, mockCredential); + + await expect(getToken()).resolves.toBe('token-a'); + await expect(getToken()).resolves.toBe('token-b'); + expect(mockGetToken).toHaveBeenCalledTimes(2); + }); + + it('should cache when expires_in is a numeric string', async () => { + mockGetToken.mockResolvedValue(tokenResponse('token-a', '3600')); + const getToken = getDatabricksTokenProvider(mockNode, mockCredential); + + await expect(getToken()).resolves.toBe('token-a'); + await expect(getToken()).resolves.toBe('token-a'); + expect(mockGetToken).toHaveBeenCalledTimes(1); + }); + + it('should share a single in-flight mint between concurrent callers', async () => { + let resolveMint!: (value: ReturnType) => void; + mockGetToken.mockImplementation( + async () => await new Promise((resolve) => (resolveMint = resolve)), + ); + const getToken = getDatabricksTokenProvider(mockNode, mockCredential); + + const first = getToken(); + const second = getToken(); + resolveMint(tokenResponse('token-a', 3600)); + + await expect(first).resolves.toBe('token-a'); + await expect(second).resolves.toBe('token-a'); + expect(mockGetToken).toHaveBeenCalledTimes(1); + }); + + it('should clear the cache slot on a rejected mint so the next call re-mints', async () => { + mockGetToken + .mockRejectedValueOnce(new MockResponseError()) + .mockResolvedValueOnce(tokenResponse('token-a', 3600)); + const getToken = getDatabricksTokenProvider(mockNode, mockCredential); + + await expect(getToken()).rejects.toThrow(NodeOperationError); + await expect(getToken()).resolves.toBe('token-a'); + expect(mockGetToken).toHaveBeenCalledTimes(2); + }); + + it('should wrap mint failures in a NodeOperationError that leaks no secret', async () => { + mockGetToken.mockRejectedValue(new MockResponseError()); + const getToken = getDatabricksTokenProvider(mockNode, mockCredential); + + const error = await getToken().then( + () => { + throw new Error('expected getToken to reject'); + }, + (e: NodeOperationError) => e, + ); + + expect(error).toBeInstanceOf(NodeOperationError); + expect(error.message).toBe('Failed to retrieve Databricks access token'); + expect(error.description).toContain('Client authentication failed'); + + const serialized = JSON.stringify(error, Object.getOwnPropertyNames(error)); + for (const text of [serialized, error.message, error.description ?? '']) { + expect(text).not.toContain(SENTINEL_SECRET); + expect(text).not.toContain('Bearer '); + } + }); + + it('should scrub the client secret from an echoed mint error message', async () => { + mockGetToken.mockRejectedValue( + new Error(`400: invalid request "client_secret=${SENTINEL_SECRET}"`), + ); + const getToken = getDatabricksTokenProvider(mockNode, mockCredential); + + const error = await getToken().then( + () => { + throw new Error('expected getToken to reject'); + }, + (e: NodeOperationError) => e, + ); + + expect(error.description).not.toContain(SENTINEL_SECRET); + expect(error.description).toContain('***'); + }); +}); + +describe('createDatabricksFetch', () => { + const origFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = origFetch; + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + it('should set the Authorization header, overwriting an existing one', async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + globalThis.fetch = mockFetch; + const wrappedFetch = createDatabricksFetch(async () => 'fresh-token'); + + await wrappedFetch('https://my.databricks.com/serving-endpoints/chat/completions', { + method: 'POST', + headers: { authorization: 'Bearer stale-token' }, + }); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(new Headers(init.headers).get('authorization')).toBe('Bearer fresh-token'); + expect(init.method).toBe('POST'); + }); + + it('should preserve the headers of a Request input when init sets none', async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + globalThis.fetch = mockFetch; + const wrappedFetch = createDatabricksFetch(async () => 'fresh-token'); + + const request = new Request('https://my.databricks.com/serving-endpoints/chat/completions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + }); + await wrappedFetch(request); + + const [, init] = mockFetch.mock.calls[0] as [Request, RequestInit]; + const sentHeaders = new Headers(init.headers); + expect(sentHeaders.get('content-type')).toBe('application/json'); + expect(sentHeaders.get('authorization')).toBe('Bearer fresh-token'); + }); + + it('should preserve the method and body of a Request input passed without init', async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + globalThis.fetch = mockFetch; + const wrappedFetch = createDatabricksFetch(async () => 'fresh-token'); + + const request = new Request('https://my.databricks.com/serving-endpoints/chat/completions', { + method: 'POST', + body: JSON.stringify({ messages: [] }), + }); + await wrappedFetch(request); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(init.method).toBe('POST'); + expect(Buffer.from(init.body as ArrayBuffer).toString()).toBe('{"messages":[]}'); + }); + + it('should return the exact Response instance with an unread body', async () => { + const response = new Response('data: chunk\n\n'); + globalThis.fetch = vi.fn().mockResolvedValue(response); + const wrappedFetch = createDatabricksFetch(async () => 'fresh-token'); + + const result = await wrappedFetch('https://my.databricks.com/serving-endpoints'); + + expect(result).toBe(response); + expect(result.bodyUsed).toBe(false); + }); + + it('should return an error response unmodified without throwing', async () => { + const response = new Response('{"error":"denied"}', { status: 401 }); + globalThis.fetch = vi.fn().mockResolvedValue(response); + const wrappedFetch = createDatabricksFetch(async () => 'fresh-token'); + + const result = await wrappedFetch('https://my.databricks.com/serving-endpoints'); + + expect(result).toBe(response); + }); + + it('should propagate network failures without leaking the token', async () => { + globalThis.fetch = vi.fn().mockRejectedValue(new TypeError('fetch failed')); + const wrappedFetch = createDatabricksFetch(async () => 'minted-token-abc'); + + const error = await wrappedFetch('https://my.databricks.com/serving-endpoints').then( + () => { + throw new Error('expected wrapped fetch to reject'); + }, + (e: Error) => e, + ); + + const serialized = JSON.stringify(error, Object.getOwnPropertyNames(error)); + expect(serialized).not.toContain('minted-token-abc'); + expect(serialized).not.toContain('Bearer '); + }); + + it('should validate each redirect hop against the egress filter before following it', async () => { + const mockFetch = vi + .fn() + .mockResolvedValueOnce( + new Response(null, { status: 302, headers: { location: 'https://internal.evil/steal' } }), + ) + .mockResolvedValue(new Response('ok')); + globalThis.fetch = mockFetch; + const validateUrl = vi + .fn() + .mockResolvedValueOnce({ ok: true }) + .mockResolvedValueOnce({ ok: false, error: new Error('egress blocked') }); + const wrappedFetch = createDatabricksFetch(async () => 'fresh-token', { + validateUrl, + } as unknown as NodeEgressFilter); + + await expect(wrappedFetch('https://my.databricks.com/serving-endpoints')).rejects.toThrow( + 'egress blocked', + ); + + expect(validateUrl).toHaveBeenNthCalledWith(2, 'https://internal.evil/steal'); + // The redirect target must never be fetched + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('should follow a redirect the egress filter allows', async () => { + const finalResponse = new Response('ok'); + const mockFetch = vi + .fn() + .mockResolvedValueOnce( + new Response(null, { + status: 302, + headers: { location: 'https://my.databricks.com/moved' }, + }), + ) + .mockResolvedValueOnce(finalResponse); + globalThis.fetch = mockFetch; + const wrappedFetch = createDatabricksFetch(async () => 'fresh-token', { + validateUrl: vi.fn().mockResolvedValue({ ok: true }), + } as unknown as NodeEgressFilter); + + const result = await wrappedFetch('https://my.databricks.com/serving-endpoints'); + + expect(result).toBe(finalResponse); + expect(mockFetch.mock.calls[1][0]).toEqual(new URL('https://my.databricks.com/moved')); + }); + + it('should not send the bearer token to a cross-origin redirect target', async () => { + const mockFetch = vi + .fn() + .mockResolvedValueOnce( + new Response(null, { + status: 307, + headers: { location: 'https://other-allowed.example.com/endpoint' }, + }), + ) + .mockResolvedValueOnce(new Response('ok')); + globalThis.fetch = mockFetch; + const wrappedFetch = createDatabricksFetch(async () => 'fresh-token', { + validateUrl: vi.fn().mockResolvedValue({ ok: true }), + } as unknown as NodeEgressFilter); + + await wrappedFetch('https://my.databricks.com/serving-endpoints'); + + const hop2Headers = new Headers((mockFetch.mock.calls[1][1] as RequestInit).headers); + expect(hop2Headers.get('authorization')).toBeNull(); + }); + + it('should send a rotated token after the previous one expires mid-execution', async () => { + vi.useFakeTimers(); + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + globalThis.fetch = mockFetch; + mockGetToken + .mockResolvedValueOnce(tokenResponse('token-a', 3600)) + .mockResolvedValueOnce(tokenResponse('token-b', 3600)); + const wrappedFetch = createDatabricksFetch( + getDatabricksTokenProvider(mockNode, mockCredential), + ); + + await wrappedFetch('https://my.databricks.com/serving-endpoints'); + vi.advanceTimersByTime((3600 - 30) * 1000); + await wrappedFetch('https://my.databricks.com/serving-endpoints'); + + const sentAuth = mockFetch.mock.calls.map((call) => + new Headers((call[1] as RequestInit).headers).get('authorization'), + ); + expect(sentAuth).toEqual(['Bearer token-a', 'Bearer token-b']); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatDatabricks/token-provider.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatDatabricks/token-provider.ts new file mode 100644 index 00000000000..f53fc8851fb --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatDatabricks/token-provider.ts @@ -0,0 +1,123 @@ +import { fetchFollowingRedirects } from '@n8n/ai-utilities'; +import { ClientOAuth2 } from '@n8n/client-oauth2'; +import type { INode, NodeEgressFilter } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; + +export interface DatabricksOAuth2Credential { + host: string; + grantType: 'clientCredentials' | 'authorizationCode'; + clientId: string; + clientSecret: string; + scope?: string; + authentication?: 'header' | 'body'; +} + +/** + * Mints Databricks service-principal tokens on demand. Concurrent callers + * share one in-flight mint, and tokens re-mint 60s before expiry so requests + * near the end of the token window don't fail. + * + * Self-mints (instead of using core's `oauthTokenData`) because the model + * client bypasses core's refresh machinery, so a core-issued token would + * expire mid-run. The mint URL is derived from the https-validated `host` + * (matching the credential's default) so a stored `accessTokenUrl` cannot + * redirect the client secret elsewhere. + */ +export function getDatabricksTokenProvider( + node: INode, + credential: DatabricksOAuth2Credential, + egressFilter?: NodeEgressFilter, +): () => Promise { + const tokenUrl = `${credential.host.replace(/\/$/, '')}/oidc/v1/token`; + + let cached: Promise | undefined; + let expiresAt = 0; + + const mint = async (): Promise => { + try { + // The bridge enforces the egress policy inside the client: pre-flight + // URL validation, connect-time DNS pinning, and per-redirect checks + const oAuthClient = new ClientOAuth2({ + clientId: credential.clientId, + clientSecret: credential.clientSecret, + accessTokenUri: tokenUrl, + scopes: credential.scope?.split(' '), + authentication: credential.authentication, + ssrfBridge: egressFilter, + }); + const token = await oAuthClient.credentials.getToken(); + const expiresIn = Number(token.data.expires_in); + // ponytail: early-expiry buffer only; if server-side revocation mid-run + // ever matters, add invalidate-and-retry-once on 401/403 in createDatabricksFetch + expiresAt = Number.isNaN(expiresIn) ? 0 : Date.now() + (expiresIn - 60) * 1000; + return token.accessToken; + } catch (error) { + cached = undefined; + expiresAt = 0; + // The description is persisted in execution error JSON; scrub the secret + // in case the token endpoint or a proxy echoes the request back + const message = error instanceof Error ? error.message : undefined; + throw new NodeOperationError(node, 'Failed to retrieve Databricks access token', { + description: + message && credential.clientSecret + ? message.replaceAll(credential.clientSecret, '***') + : message, + }); + } + }; + + return async () => { + if (!cached || Date.now() >= expiresAt) { + // Infinity until the mint resolves, so concurrent first callers join it + expiresAt = Infinity; + cached = mint(); + } + return await cached; + }; +} + +/** + * Wraps fetch to inject a fresh bearer token per request. Never reads or + * clones the body, so streaming responses pass through untouched. Redirects + * are followed manually so every hop is validated against the egress filter + * before the token is sent to it, matching the MCP client's fetch wrapper; + * the redirect helper also drops the bearer on cross-origin hops. + */ +export function createDatabricksFetch( + getToken: () => Promise, + egressFilter?: NodeEgressFilter, +): typeof globalThis.fetch { + return async (input, init) => { + // Passing headers in init replaces a Request input's own headers, so + // carry those over when init doesn't set any + const headers = new Headers( + init?.headers ?? (input instanceof Request ? input.headers : undefined), + ); + headers.set('authorization', `Bearer ${await getToken()}`); + // The redirect loop takes a URL, so unwrap a Request input and carry its + // method/body/signal over (init still wins, per fetch spec). The body is + // buffered, which also lets 307/308 hops replay it. + const requestInit: RequestInit = { ...init }; + if (input instanceof Request) { + requestInit.method ??= input.method; + requestInit.signal ??= input.signal; + if (requestInit.body === undefined && input.body) { + requestInit.body = await input.arrayBuffer(); + } + } + const startUrl = input instanceof Request ? input.url : input; + return await fetchFollowingRedirects( + fetch, + startUrl, + { ...requestInit, headers }, + { + onBeforeHop: async (hopUrl) => { + if (egressFilter) { + const result = await egressFilter.validateUrl(hopUrl); + if (!result.ok) throw result.error; + } + }, + }, + ); + }; +} diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/__test__/McpClientTool.node.test.ts b/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/__test__/McpClientTool.node.test.ts index 6198a3001b8..7dc68f51086 100644 --- a/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/__test__/McpClientTool.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/__test__/McpClientTool.node.test.ts @@ -38,6 +38,7 @@ const mockedProxyFetch = proxyFetch as MockedFunction; const createTestEgressFilter = (): NodeEgressFilter => ({ validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)), createSecureLookup: vi.fn().mockReturnValue(vi.fn()), + validateRedirectSync: vi.fn(), }); const egressHelpers = (): Partial => diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/shared/__test__/utils.test.ts b/packages/@n8n/nodes-langchain/nodes/mcp/shared/__test__/utils.test.ts index bdb59257a85..44f3d86d3c8 100644 --- a/packages/@n8n/nodes-langchain/nodes/mcp/shared/__test__/utils.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/mcp/shared/__test__/utils.test.ts @@ -38,6 +38,7 @@ const MockedClient = Client as MockedClass; const createTestEgressFilter = (): NodeEgressFilter => ({ validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)), createSecureLookup: vi.fn(), + validateRedirectSync: vi.fn(), }); describe('utils', () => { @@ -669,6 +670,35 @@ describe('utils', () => { expect(new Headers(options.init?.headers).get('authorization')).toBe('Bearer my-token'); }); + it('should not send auth headers to a cross-origin redirect target', async () => { + mockClient.connect.mockResolvedValue(undefined); + mockedProxyFetch + .mockResolvedValueOnce( + new Response(null, { + status: 307, + headers: { location: 'https://other.example.com/moved' }, + }), + ) + .mockResolvedValueOnce(new Response('ok', { status: 200 })); + + await connectMcpClient({ + serverTransport: transport, + secureEgressFilter: createTestEgressFilter(), + endpointUrl: 'https://example.com', + headers: { Authorization: 'Bearer my-token' }, + name: 'test-client', + version: 1, + }); + + const [, opts] = (TransportClass as Mock).mock.calls[0]; + await opts.fetch('https://example.com/mcp', {}); + + expect(mockedProxyFetch).toHaveBeenCalledTimes(2); + const [secondCallOptions] = mockedProxyFetch.mock.calls[1]; + expect(secondCallOptions.input.toString()).toBe('https://other.example.com/moved'); + expect(new Headers(secondCallOptions.init?.headers).get('authorization')).toBeNull(); + }); + it('should preserve SDK headers passed as Headers instance', async () => { mockClient.connect.mockResolvedValue(undefined); @@ -851,6 +881,7 @@ describe('utils', () => { const secureLookup = vi.fn(); const egressFilter: NodeEgressFilter = { validateUrl: vi.fn().mockResolvedValue(createResultError(new Error('Egress blocked'))), + validateRedirectSync: vi.fn(), createSecureLookup: vi.fn().mockReturnValue(secureLookup), }; @@ -884,6 +915,7 @@ describe('utils', () => { const secureLookup = vi.fn(); const egressFilter: NodeEgressFilter = { validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)), + validateRedirectSync: vi.fn(), createSecureLookup: vi.fn().mockReturnValue(secureLookup), }; diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/shared/runtime.test.ts b/packages/@n8n/nodes-langchain/nodes/mcp/shared/runtime.test.ts index 672b218fd8b..86956d1fcea 100644 --- a/packages/@n8n/nodes-langchain/nodes/mcp/shared/runtime.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/mcp/shared/runtime.test.ts @@ -49,6 +49,7 @@ const baseConnectionConfig: McpConnectionConfig = { const createTestEgressFilter = (): NodeEgressFilter => ({ validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)), createSecureLookup: vi.fn(), + validateRedirectSync: vi.fn(), }); const egressHelpers = (): Partial => diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/shared/utils.ts b/packages/@n8n/nodes-langchain/nodes/mcp/shared/utils.ts index dc1e5146daf..57dd957a743 100644 --- a/packages/@n8n/nodes-langchain/nodes/mcp/shared/utils.ts +++ b/packages/@n8n/nodes-langchain/nodes/mcp/shared/utils.ts @@ -297,7 +297,9 @@ function headersToRecord(headers: HeadersInit | undefined): Record | undefined, diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index 230e0a78bd7..4af613360a7 100644 --- a/packages/@n8n/nodes-langchain/package.json +++ b/packages/@n8n/nodes-langchain/package.json @@ -124,6 +124,7 @@ "dist/nodes/llms/LmChatAzureOpenAi/LmChatAzureOpenAi.node.js", "dist/nodes/llms/LmChatAwsBedrock/LmChatAwsBedrock.node.js", "dist/nodes/llms/LmChatCohere/LmChatCohere.node.js", + "dist/nodes/llms/LmChatDatabricks/LmChatDatabricks.node.js", "dist/nodes/llms/LmChatDeepSeek/LmChatDeepSeek.node.js", "dist/nodes/llms/LmChatGoogleGemini/LmChatGoogleGemini.node.js", "dist/nodes/llms/LmChatGoogleVertex/LmChatGoogleVertex.node.js", diff --git a/packages/cli/src/utils/__tests__/auth-fetch.test.ts b/packages/cli/src/utils/__tests__/auth-fetch.test.ts index e0f134325e8..69e7ffa232d 100644 --- a/packages/cli/src/utils/__tests__/auth-fetch.test.ts +++ b/packages/cli/src/utils/__tests__/auth-fetch.test.ts @@ -194,6 +194,25 @@ describe('createAuthFetch — allowedDomains', () => { expect(baseFetchMock).toHaveBeenCalledTimes(2); }); + it('withholds auth headers after a redirect hop crosses origins', async () => { + baseFetchMock + .mockResolvedValueOnce(makeRedirect('https://other.test/moved')) + .mockResolvedValueOnce(makeOk()); + + const fetchFn = createAuthFetch({ + baseFetch, + initialHeaders: { Authorization: 'Bearer A' }, + allowedDomains: { mode: 'domains', domains: 'example.test,other.test' }, + }); + await fetchFn('https://example.test/mcp'); + + expect(baseFetchMock).toHaveBeenCalledTimes(2); + expect(new Headers(baseFetchMock.mock.calls[0][1].headers).get('authorization')).toBe( + 'Bearer A', + ); + expect(new Headers(baseFetchMock.mock.calls[1][1].headers).get('authorization')).toBeNull(); + }); + it('blocks all requests when mode is none', async () => { const fetchFn = createAuthFetch({ baseFetch, diff --git a/packages/cli/src/utils/auth-fetch.ts b/packages/cli/src/utils/auth-fetch.ts index 094ca0438c8..90390328be9 100644 --- a/packages/cli/src/utils/auth-fetch.ts +++ b/packages/cli/src/utils/auth-fetch.ts @@ -60,7 +60,9 @@ function assertDomainPolicyAllowsUrl(url: string, policy: AuthFetchDomainPolicy) * HTTP_PROXY settings apply uniformly), * 2. injects the latest auth headers on every request, * 3. on a single 401, calls `onUnauthorized` to refresh the token and - * retries the request once with the new headers. + * retries the request once with the new headers, + * 4. when a domain policy is set, follows redirects manually, validating + * every hop and withholding the auth headers once a hop crosses origins. * * This mirrors the langchain MCP node's `createAuthFetch` so an agent's MCP * connection behaves identically to one configured via the workflow editor. diff --git a/packages/nodes-base/nodes/Kafka/test/utils.test.ts b/packages/nodes-base/nodes/Kafka/test/utils.test.ts index 3fd3946f3c0..b8a8fdb0e37 100644 --- a/packages/nodes-base/nodes/Kafka/test/utils.test.ts +++ b/packages/nodes-base/nodes/Kafka/test/utils.test.ts @@ -1040,6 +1040,7 @@ describe('Kafka Utils', () => { const validateUrl = vi.fn().mockResolvedValue(result); const filter: NodeEgressFilter = { validateUrl, + validateRedirectSync: vi.fn(), createSecureLookup: () => lookup, }; return { filter, lookup, validateUrl }; diff --git a/packages/workflow/src/interfaces.ts b/packages/workflow/src/interfaces.ts index ba22b127b60..eb09f57479e 100644 --- a/packages/workflow/src/interfaces.ts +++ b/packages/workflow/src/interfaces.ts @@ -953,14 +953,16 @@ interface NodeHelperFunctions { /** * Egress filter exposed to nodes whose embedded HTTP clients cannot go through - * `httpRequest`. Mirrors the two layers n8n's own egress uses: a pre-flight URL - * validation and a connect-time secure DNS lookup. + * `httpRequest`. Mirrors the layers n8n's own egress uses: a pre-flight URL + * validation, a connect-time secure DNS lookup, and per-redirect validation. */ export interface NodeEgressFilter { /** Validate a target URL before any connection. Resolves hostnames; direct IP literals are checked without DNS. */ validateUrl(url: string | URL): Promise>; /** DNS lookup drop-in that validates resolved addresses against the configured egress rules. */ createSecureLookup(): LookupFunction; + /** Validate a redirect hop synchronously; throws when the target is not allowed. */ + validateRedirectSync(url: string): void; } export interface RequestHelperFunctions {