mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-09 22:24:05 -08:00
fix(HTTP Request Node): Sanitize secrets of predefined credentials (#9612)
This commit is contained in:
parent
5361e9f69a
commit
84f091d3e5
|
@ -2844,7 +2844,8 @@ const getCommonWorkflowFunctions = (
|
||||||
getInstanceBaseUrl: () => additionalData.instanceBaseUrl,
|
getInstanceBaseUrl: () => additionalData.instanceBaseUrl,
|
||||||
getInstanceId: () => Container.get(InstanceSettings).instanceId,
|
getInstanceId: () => Container.get(InstanceSettings).instanceId,
|
||||||
getTimezone: () => getTimezone(workflow),
|
getTimezone: () => getTimezone(workflow),
|
||||||
|
getCredentialsProperties: (type: string) =>
|
||||||
|
additionalData.credentialsHelper.getCredentialsProperties(type),
|
||||||
prepareOutputData: async (outputData) => [outputData],
|
prepareOutputData: async (outputData) => [outputData],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,16 +1,20 @@
|
||||||
import type { SecureContextOptions } from 'tls';
|
import type { SecureContextOptions } from 'tls';
|
||||||
import type {
|
import type {
|
||||||
|
ICredentialDataDecryptedObject,
|
||||||
IDataObject,
|
IDataObject,
|
||||||
INodeExecutionData,
|
INodeExecutionData,
|
||||||
|
INodeProperties,
|
||||||
IOAuth2Options,
|
IOAuth2Options,
|
||||||
IRequestOptions,
|
IRequestOptions,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
import set from 'lodash/set';
|
import set from 'lodash/set';
|
||||||
|
import isPlainObject from 'lodash/isPlainObject';
|
||||||
|
|
||||||
import FormData from 'form-data';
|
import FormData from 'form-data';
|
||||||
import type { HttpSslAuthCredentials } from './interfaces';
|
|
||||||
import { formatPrivateKey } from '../../utils/utilities';
|
import { formatPrivateKey } from '../../utils/utilities';
|
||||||
|
import type { HttpSslAuthCredentials } from './interfaces';
|
||||||
|
import get from 'lodash/get';
|
||||||
|
|
||||||
export type BodyParameter = {
|
export type BodyParameter = {
|
||||||
name: string;
|
name: string;
|
||||||
|
@ -29,7 +33,33 @@ export const replaceNullValues = (item: INodeExecutionData) => {
|
||||||
return item;
|
return item;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function sanitizeUiMessage(request: IRequestOptions, authDataKeys: IAuthDataSanitizeKeys) {
|
export const REDACTED = '**hidden**';
|
||||||
|
|
||||||
|
function isObject(obj: unknown): obj is IDataObject {
|
||||||
|
return isPlainObject(obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
function redact<T = unknown>(obj: T, secrets: string[]): T {
|
||||||
|
if (typeof obj === 'string') {
|
||||||
|
return secrets.reduce((safe, secret) => safe.replace(secret, REDACTED), obj) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
return obj.map((item) => redact(item, secrets)) as T;
|
||||||
|
} else if (isObject(obj)) {
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
(obj as IDataObject)[key] = redact(value, secrets);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeUiMessage(
|
||||||
|
request: IRequestOptions,
|
||||||
|
authDataKeys: IAuthDataSanitizeKeys,
|
||||||
|
secrets?: string[],
|
||||||
|
) {
|
||||||
let sendRequest = request as unknown as IDataObject;
|
let sendRequest = request as unknown as IDataObject;
|
||||||
|
|
||||||
// Protect browser from sending large binary data
|
// Protect browser from sending large binary data
|
||||||
|
@ -38,7 +68,7 @@ export function sanitizeUiMessage(request: IRequestOptions, authDataKeys: IAuthD
|
||||||
...request,
|
...request,
|
||||||
body: `Binary data got replaced with this text. Original was a Buffer with a size of ${
|
body: `Binary data got replaced with this text. Original was a Buffer with a size of ${
|
||||||
(request.body as string).length
|
(request.body as string).length
|
||||||
} byte.`,
|
} bytes.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -50,7 +80,7 @@ export function sanitizeUiMessage(request: IRequestOptions, authDataKeys: IAuthD
|
||||||
// eslint-disable-next-line @typescript-eslint/no-loop-func
|
// eslint-disable-next-line @typescript-eslint/no-loop-func
|
||||||
(acc: IDataObject, curr) => {
|
(acc: IDataObject, curr) => {
|
||||||
acc[curr] = authDataKeys[requestProperty].includes(curr)
|
acc[curr] = authDataKeys[requestProperty].includes(curr)
|
||||||
? '** hidden **'
|
? REDACTED
|
||||||
: (sendRequest[requestProperty] as IDataObject)[curr];
|
: (sendRequest[requestProperty] as IDataObject)[curr];
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
|
@ -59,9 +89,33 @@ export function sanitizeUiMessage(request: IRequestOptions, authDataKeys: IAuthD
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (secrets && secrets.length > 0) {
|
||||||
|
return redact(sendRequest, secrets);
|
||||||
|
}
|
||||||
|
|
||||||
return sendRequest;
|
return sendRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getSecrets(
|
||||||
|
properties: INodeProperties[],
|
||||||
|
credentials: ICredentialDataDecryptedObject,
|
||||||
|
): string[] {
|
||||||
|
const sensitivePropNames = new Set(
|
||||||
|
properties.filter((prop) => prop.typeOptions?.password).map((prop) => prop.name),
|
||||||
|
);
|
||||||
|
|
||||||
|
const secrets = Object.entries(credentials)
|
||||||
|
.filter(([propName]) => sensitivePropNames.has(propName))
|
||||||
|
.map(([_, value]) => value)
|
||||||
|
.filter((value): value is string => typeof value === 'string');
|
||||||
|
const oauthAccessToken = get(credentials, 'oauthTokenData.access_token');
|
||||||
|
if (typeof oauthAccessToken === 'string') {
|
||||||
|
secrets.push(oauthAccessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return secrets;
|
||||||
|
}
|
||||||
|
|
||||||
export const getOAuth2AdditionalParameters = (nodeCredentialType: string) => {
|
export const getOAuth2AdditionalParameters = (nodeCredentialType: string) => {
|
||||||
const oAuth2Options: { [credentialType: string]: IOAuth2Options } = {
|
const oAuth2Options: { [credentialType: string]: IOAuth2Options } = {
|
||||||
bitlyOAuth2Api: {
|
bitlyOAuth2Api: {
|
||||||
|
|
|
@ -29,14 +29,15 @@ import type { BodyParameter, IAuthDataSanitizeKeys } from '../GenericFunctions';
|
||||||
import {
|
import {
|
||||||
binaryContentTypes,
|
binaryContentTypes,
|
||||||
getOAuth2AdditionalParameters,
|
getOAuth2AdditionalParameters,
|
||||||
|
getSecrets,
|
||||||
prepareRequestBody,
|
prepareRequestBody,
|
||||||
reduceAsync,
|
reduceAsync,
|
||||||
replaceNullValues,
|
replaceNullValues,
|
||||||
sanitizeUiMessage,
|
sanitizeUiMessage,
|
||||||
setAgentOptions,
|
setAgentOptions,
|
||||||
} from '../GenericFunctions';
|
} from '../GenericFunctions';
|
||||||
import { keysToLowercase } from '@utils/utilities';
|
|
||||||
import type { HttpSslAuthCredentials } from '../interfaces';
|
import type { HttpSslAuthCredentials } from '../interfaces';
|
||||||
|
import { keysToLowercase } from '@utils/utilities';
|
||||||
|
|
||||||
function toText<T>(data: T) {
|
function toText<T>(data: T) {
|
||||||
if (typeof data === 'object' && data !== null) {
|
if (typeof data === 'object' && data !== null) {
|
||||||
|
@ -1299,7 +1300,12 @@ export class HttpRequestV3 implements INodeType {
|
||||||
requestInterval: number;
|
requestInterval: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const sanitazedRequests: IDataObject[] = [];
|
const requests: Array<{
|
||||||
|
options: IRequestOptions;
|
||||||
|
authKeys: IAuthDataSanitizeKeys;
|
||||||
|
credentialType?: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
|
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
|
||||||
if (authentication === 'genericCredentialType') {
|
if (authentication === 'genericCredentialType') {
|
||||||
genericCredentialType = this.getNodeParameter('genericAuthType', 0) as string;
|
genericCredentialType = this.getNodeParameter('genericAuthType', 0) as string;
|
||||||
|
@ -1696,11 +1702,11 @@ export class HttpRequestV3 implements INodeType {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
requests.push({
|
||||||
const sanitazedRequestOptions = sanitizeUiMessage(requestOptions, authDataKeys);
|
options: requestOptions,
|
||||||
this.sendMessageToUI(sanitazedRequestOptions);
|
authKeys: authDataKeys,
|
||||||
sanitazedRequests.push(sanitazedRequestOptions);
|
credentialType: nodeCredentialType,
|
||||||
} catch (e) {}
|
});
|
||||||
|
|
||||||
if (pagination && pagination.paginationMode !== 'off') {
|
if (pagination && pagination.paginationMode !== 'off') {
|
||||||
let continueExpression = '={{false}}';
|
let continueExpression = '={{false}}';
|
||||||
|
@ -1827,7 +1833,29 @@ export class HttpRequestV3 implements INodeType {
|
||||||
requestPromises.push(requestWithAuthentication);
|
requestPromises.push(requestWithAuthentication);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const promisesResponses = await Promise.allSettled(requestPromises);
|
|
||||||
|
const sanitizedRequests: IDataObject[] = [];
|
||||||
|
const promisesResponses = await Promise.allSettled(
|
||||||
|
requestPromises.map(
|
||||||
|
async (requestPromise, itemIndex) =>
|
||||||
|
await requestPromise.finally(async () => {
|
||||||
|
try {
|
||||||
|
// Secrets need to be read after the request because secrets could have changed
|
||||||
|
// For example: OAuth token refresh, preAuthentication
|
||||||
|
const { options, authKeys, credentialType } = requests[itemIndex];
|
||||||
|
let secrets: string[] = [];
|
||||||
|
if (credentialType) {
|
||||||
|
const properties = this.getCredentialsProperties(credentialType);
|
||||||
|
const credentials = await this.getCredentials(credentialType, itemIndex);
|
||||||
|
secrets = getSecrets(properties, credentials);
|
||||||
|
}
|
||||||
|
const sanitizedRequestOptions = sanitizeUiMessage(options, authKeys, secrets);
|
||||||
|
sanitizedRequests.push(sanitizedRequestOptions);
|
||||||
|
this.sendMessageToUI(sanitizedRequestOptions);
|
||||||
|
} catch (e) {}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
let responseData: any;
|
let responseData: any;
|
||||||
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
|
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
|
||||||
|
@ -1842,7 +1870,7 @@ export class HttpRequestV3 implements INodeType {
|
||||||
responseData.reason.error = Buffer.from(responseData.reason.error as Buffer).toString();
|
responseData.reason.error = Buffer.from(responseData.reason.error as Buffer).toString();
|
||||||
}
|
}
|
||||||
const error = new NodeApiError(this.getNode(), responseData as JsonObject, { itemIndex });
|
const error = new NodeApiError(this.getNode(), responseData as JsonObject, { itemIndex });
|
||||||
set(error, 'context.request', sanitazedRequests[itemIndex]);
|
set(error, 'context.request', sanitizedRequests[itemIndex]);
|
||||||
throw error;
|
throw error;
|
||||||
} else {
|
} else {
|
||||||
removeCircularRefs(responseData.reason as JsonObject);
|
removeCircularRefs(responseData.reason as JsonObject);
|
||||||
|
|
|
@ -1,75 +1,140 @@
|
||||||
import type { IRequestOptions } from 'n8n-workflow';
|
import type { IRequestOptions } from 'n8n-workflow';
|
||||||
import { prepareRequestBody, setAgentOptions } from '../../GenericFunctions';
|
import {
|
||||||
|
REDACTED,
|
||||||
|
prepareRequestBody,
|
||||||
|
sanitizeUiMessage,
|
||||||
|
setAgentOptions,
|
||||||
|
} from '../../GenericFunctions';
|
||||||
import type { BodyParameter, BodyParametersReducer } from '../../GenericFunctions';
|
import type { BodyParameter, BodyParametersReducer } from '../../GenericFunctions';
|
||||||
|
|
||||||
describe('HTTP Node Utils, prepareRequestBody', () => {
|
describe('HTTP Node Utils', () => {
|
||||||
it('should call default reducer', async () => {
|
describe('prepareRequestBody', () => {
|
||||||
const bodyParameters: BodyParameter[] = [
|
it('should call default reducer', async () => {
|
||||||
{
|
const bodyParameters: BodyParameter[] = [
|
||||||
name: 'foo.bar',
|
{
|
||||||
value: 'baz',
|
name: 'foo.bar',
|
||||||
},
|
value: 'baz',
|
||||||
];
|
},
|
||||||
const defaultReducer: BodyParametersReducer = jest.fn();
|
];
|
||||||
|
const defaultReducer: BodyParametersReducer = jest.fn();
|
||||||
|
|
||||||
await prepareRequestBody(bodyParameters, 'json', 3, defaultReducer);
|
await prepareRequestBody(bodyParameters, 'json', 3, defaultReducer);
|
||||||
|
|
||||||
expect(defaultReducer).toBeCalledTimes(1);
|
expect(defaultReducer).toBeCalledTimes(1);
|
||||||
expect(defaultReducer).toBeCalledWith({}, { name: 'foo.bar', value: 'baz' });
|
expect(defaultReducer).toBeCalledWith({}, { name: 'foo.bar', value: 'baz' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call process dot notations', async () => {
|
it('should call process dot notations', async () => {
|
||||||
const bodyParameters: BodyParameter[] = [
|
const bodyParameters: BodyParameter[] = [
|
||||||
{
|
{
|
||||||
name: 'foo.bar.spam',
|
name: 'foo.bar.spam',
|
||||||
value: 'baz',
|
value: 'baz',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const defaultReducer: BodyParametersReducer = jest.fn();
|
const defaultReducer: BodyParametersReducer = jest.fn();
|
||||||
|
|
||||||
const result = await prepareRequestBody(bodyParameters, 'json', 4, defaultReducer);
|
const result = await prepareRequestBody(bodyParameters, 'json', 4, defaultReducer);
|
||||||
|
|
||||||
expect(defaultReducer).toBeCalledTimes(0);
|
expect(defaultReducer).toBeCalledTimes(0);
|
||||||
expect(result).toBeDefined();
|
expect(result).toBeDefined();
|
||||||
expect(result).toEqual({ foo: { bar: { spam: 'baz' } } });
|
expect(result).toEqual({ foo: { bar: { spam: 'baz' } } });
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('HTTP Node Utils, setAgentOptions', () => {
|
|
||||||
it("should not have agentOptions as it's undefined", async () => {
|
|
||||||
const requestOptions: IRequestOptions = {
|
|
||||||
method: 'GET',
|
|
||||||
uri: 'https://example.com',
|
|
||||||
};
|
|
||||||
|
|
||||||
const sslCertificates = undefined;
|
|
||||||
|
|
||||||
setAgentOptions(requestOptions, sslCertificates);
|
|
||||||
|
|
||||||
expect(requestOptions).toEqual({
|
|
||||||
method: 'GET',
|
|
||||||
uri: 'https://example.com',
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have agentOptions set', async () => {
|
describe('setAgentOptions', () => {
|
||||||
const requestOptions: IRequestOptions = {
|
it("should not have agentOptions as it's undefined", async () => {
|
||||||
method: 'GET',
|
const requestOptions: IRequestOptions = {
|
||||||
uri: 'https://example.com',
|
method: 'GET',
|
||||||
};
|
uri: 'https://example.com',
|
||||||
|
};
|
||||||
|
|
||||||
const sslCertificates = {
|
const sslCertificates = undefined;
|
||||||
ca: 'mock-ca',
|
|
||||||
};
|
|
||||||
|
|
||||||
setAgentOptions(requestOptions, sslCertificates);
|
setAgentOptions(requestOptions, sslCertificates);
|
||||||
|
|
||||||
expect(requestOptions).toStrictEqual({
|
expect(requestOptions).toEqual({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
uri: 'https://example.com',
|
uri: 'https://example.com',
|
||||||
agentOptions: {
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have agentOptions set', async () => {
|
||||||
|
const requestOptions: IRequestOptions = {
|
||||||
|
method: 'GET',
|
||||||
|
uri: 'https://example.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sslCertificates = {
|
||||||
ca: 'mock-ca',
|
ca: 'mock-ca',
|
||||||
},
|
};
|
||||||
|
|
||||||
|
setAgentOptions(requestOptions, sslCertificates);
|
||||||
|
|
||||||
|
expect(requestOptions).toStrictEqual({
|
||||||
|
method: 'GET',
|
||||||
|
uri: 'https://example.com',
|
||||||
|
agentOptions: {
|
||||||
|
ca: 'mock-ca',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sanitizeUiMessage', () => {
|
||||||
|
it('should remove large Buffers', async () => {
|
||||||
|
const requestOptions: IRequestOptions = {
|
||||||
|
method: 'POST',
|
||||||
|
uri: 'https://example.com',
|
||||||
|
body: Buffer.alloc(900000),
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(sanitizeUiMessage(requestOptions, {}).body).toEqual(
|
||||||
|
'Binary data got replaced with this text. Original was a Buffer with a size of 900000 bytes.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove keys that contain sensitive data', async () => {
|
||||||
|
const requestOptions: IRequestOptions = {
|
||||||
|
method: 'POST',
|
||||||
|
uri: 'https://example.com',
|
||||||
|
body: { sessionToken: 'secret', other: 'foo' },
|
||||||
|
headers: { authorization: 'secret', other: 'foo' },
|
||||||
|
auth: { user: 'user', password: 'secret' },
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
sanitizeUiMessage(requestOptions, {
|
||||||
|
headers: ['authorization'],
|
||||||
|
body: ['sessionToken'],
|
||||||
|
auth: ['password'],
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
body: { sessionToken: REDACTED, other: 'foo' },
|
||||||
|
headers: { other: 'foo', authorization: REDACTED },
|
||||||
|
auth: { user: 'user', password: REDACTED },
|
||||||
|
method: 'POST',
|
||||||
|
uri: 'https://example.com',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove secrets', async () => {
|
||||||
|
const requestOptions: IRequestOptions = {
|
||||||
|
method: 'POST',
|
||||||
|
uri: 'https://example.com',
|
||||||
|
body: { nested: { secret: 'secretAccessToken' } },
|
||||||
|
headers: { authorization: 'secretAccessToken', other: 'foo' },
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(sanitizeUiMessage(requestOptions, {}, ['secretAccessToken'])).toEqual({
|
||||||
|
body: {
|
||||||
|
nested: {
|
||||||
|
secret: REDACTED,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
headers: { authorization: REDACTED, other: 'foo' },
|
||||||
|
method: 'POST',
|
||||||
|
uri: 'https://example.com',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -216,6 +216,8 @@ export abstract class ICredentialsHelper {
|
||||||
type: string,
|
type: string,
|
||||||
data: ICredentialDataDecryptedObject,
|
data: ICredentialDataDecryptedObject,
|
||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
|
|
||||||
|
abstract getCredentialsProperties(type: string): INodeProperties[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IAuthenticateBase {
|
export interface IAuthenticateBase {
|
||||||
|
@ -813,6 +815,7 @@ export type NodeTypeAndVersion = {
|
||||||
export interface FunctionsBase {
|
export interface FunctionsBase {
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
getCredentials(type: string, itemIndex?: number): Promise<ICredentialDataDecryptedObject>;
|
getCredentials(type: string, itemIndex?: number): Promise<ICredentialDataDecryptedObject>;
|
||||||
|
getCredentialsProperties(type: string): INodeProperties[];
|
||||||
getExecutionId(): string;
|
getExecutionId(): string;
|
||||||
getNode(): INode;
|
getNode(): INode;
|
||||||
getWorkflow(): IWorkflowMetadata;
|
getWorkflow(): IWorkflowMetadata;
|
||||||
|
|
Loading…
Reference in a new issue