mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-19 07:36:52 +00:00
fix(core): Apply egress policy to credential test requests
This commit is contained in:
@@ -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;
|
||||
|
||||
+59
@@ -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' });
|
||||
});
|
||||
});
|
||||
+11
-3
@@ -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,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user