fix(core): Apply egress policy to credential test requests

This commit is contained in:
Lorent Lempereur
2026-06-18 21:19:30 +02:00
parent 6e8a7fcd2d
commit abb976195f
3 changed files with 89 additions and 18 deletions
@@ -25,6 +25,7 @@ import type {
ICredentialTestFunctions,
IDataObject,
IExecuteData,
IWorkflowExecuteAdditionalData,
} from 'n8n-workflow';
import {
VersionedNodeType,
@@ -203,35 +204,38 @@ export class CredentialsTester {
}
let credentialsDataSecretKeys: string[] = [];
if (credentialsDecrypted.data) {
try {
const additionalData = await WorkflowExecuteAdditionalData.getBase({
userId,
projectId: credentialsDecrypted.homeProject?.id,
});
let baseAdditionalData: IWorkflowExecuteAdditionalData;
try {
baseAdditionalData = await WorkflowExecuteAdditionalData.getBase({
userId,
projectId: credentialsDecrypted.homeProject?.id,
});
if (credentialsDecrypted.data) {
// Keep all credentials data keys which have a secret value
credentialsDataSecretKeys = getExternalSecretExpressionPaths(credentialsDecrypted.data);
credentialsDecrypted.data = await this.credentialsHelper.applyDefaultsAndOverwrites(
additionalData,
baseAdditionalData,
credentialsDecrypted.data,
credentialType,
'internal' as WorkflowExecuteMode,
undefined,
undefined,
);
} catch (error) {
this.logger.debug('Credential test failed', error);
return {
status: 'Error',
message: error.message.toString(),
};
}
} catch (error) {
this.logger.debug('Credential test failed', error);
return {
status: 'Error',
message: error.message.toString(),
};
}
if (typeof credentialTestFunction === 'function') {
// The credentials get tested via a function that is defined on the node
const context = new CredentialTestContext();
// The credentials get tested via a function that is defined on the node.
// Pass the base additional data so the test's HTTP requests honour the
// egress policy carried by its SSRF bridge.
const context = new CredentialTestContext(baseAdditionalData);
const functionResult = credentialTestFunction.call(context, credentialsDecrypted);
if (functionResult instanceof Promise) {
const result = await functionResult;
@@ -0,0 +1,59 @@
import { OutboundHttp } from '@n8n/backend-network';
import type { HttpRequestClient, SsrfBridge } from '@n8n/backend-network';
import { Container } from '@n8n/di';
import type { IWorkflowExecuteAdditionalData } from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';
import { CredentialTestContext } from '../credentials-test-context';
// The SSH tunnel helpers resolve a manager from the container; stub them out so
// constructing the context stays a pure unit test focused on the request path.
vi.mock('../utils/ssh-tunnel-helper-functions', () => ({
getSSHTunnelFunctions: () => ({}),
}));
/**
* `CredentialTestContext` is the execution context for function-based credential
* tests. Its `helpers.request` must forward the execution's SSRF bridge so that
* test requests honour the same egress policy as regular node execution. These
* tests assert that wiring; the actual SSRF enforcement lives in
* `@n8n/backend-network`.
*/
describe('CredentialTestContext', () => {
const requestLegacy = vi.fn();
const requests = vi.fn();
const outboundHttp = mock<OutboundHttp>({ requests });
beforeEach(() => {
vi.resetAllMocks();
requestLegacy.mockResolvedValue('response-body');
requests.mockReturnValue(mock<HttpRequestClient>({ requestLegacy }));
Container.set(OutboundHttp, outboundHttp);
});
it('forwards the SSRF bridge from additionalData to the request', async () => {
const ssrfBridge = mock<SsrfBridge>();
const additionalData = mock<IWorkflowExecuteAdditionalData>({ ssrfBridge });
const context = new CredentialTestContext(additionalData);
await context.helpers.request('https://example.test');
expect(requests).toHaveBeenCalledWith({ ssrf: ssrfBridge });
});
it('disables SSRF when additionalData carries no bridge', async () => {
const additionalData = mock<IWorkflowExecuteAdditionalData>({ ssrfBridge: undefined });
const context = new CredentialTestContext(additionalData);
await context.helpers.request('https://example.test');
expect(requests).toHaveBeenCalledWith({ ssrf: 'disabled' });
});
it('disables SSRF when no additionalData is provided', async () => {
const context = new CredentialTestContext();
await context.helpers.request('https://example.test');
expect(requests).toHaveBeenCalledWith({ ssrf: 'disabled' });
});
});
@@ -1,7 +1,7 @@
import { Logger } from '@n8n/backend-common';
import { Memoized } from '@n8n/decorators';
import { Container } from '@n8n/di';
import type { ICredentialTestFunctions } from 'n8n-workflow';
import type { ICredentialTestFunctions, IWorkflowExecuteAdditionalData } from 'n8n-workflow';
import { proxyRequestToAxios } from './utils/request-helpers/legacy-request-adapter'; // This bypasses the index barrel on purpose
import { getSSHTunnelFunctions } from './utils/ssh-tunnel-helper-functions';
@@ -9,12 +9,20 @@ import { getSSHTunnelFunctions } from './utils/ssh-tunnel-helper-functions';
export class CredentialTestContext implements ICredentialTestFunctions {
readonly helpers: ICredentialTestFunctions['helpers'];
constructor() {
// `additionalData` carries the SSRF bridge so credential tests that issue
// requests honour the same egress policy as regular node execution.
constructor(additionalData?: IWorkflowExecuteAdditionalData) {
this.helpers = {
...getSSHTunnelFunctions(),
request: async (uriOrObject: string | object, options?: object) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return await proxyRequestToAxios(undefined, undefined, undefined, uriOrObject, options);
return await proxyRequestToAxios(
undefined,
additionalData,
undefined,
uriOrObject,
options,
);
},
};
}