fix(core): Make parsing of content-type and content-disposition headers more flexible (#7217)

fixes #7149
This commit is contained in:
Elias Meire 2023-09-20 14:40:06 +02:00 committed by GitHub
parent 6bc477b50e
commit d41546b899
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 262 additions and 152 deletions

View file

@ -35,8 +35,6 @@
], ],
"devDependencies": { "devDependencies": {
"@types/concat-stream": "^2.0.0", "@types/concat-stream": "^2.0.0",
"@types/content-disposition": "^0.5.5",
"@types/content-type": "^1.1.5",
"@types/cron": "~1.7.1", "@types/cron": "~1.7.1",
"@types/crypto-js": "^4.0.1", "@types/crypto-js": "^4.0.1",
"@types/express": "^4.17.6", "@types/express": "^4.17.6",
@ -52,8 +50,6 @@
"axios": "^0.21.1", "axios": "^0.21.1",
"@n8n/client-oauth2": "workspace:*", "@n8n/client-oauth2": "workspace:*",
"concat-stream": "^2.0.0", "concat-stream": "^2.0.0",
"content-disposition": "^0.5.4",
"content-type": "^1.0.4",
"cron": "~1.7.2", "cron": "~1.7.2",
"crypto-js": "~4.1.1", "crypto-js": "~4.1.1",
"fast-glob": "^3.2.5", "fast-glob": "^3.2.5",

View file

@ -11,100 +11,12 @@
/* eslint-disable @typescript-eslint/no-shadow */ /* eslint-disable @typescript-eslint/no-shadow */
import type {
GenericValue,
IAdditionalCredentialOptions,
IAllExecuteFunctions,
IBinaryData,
IContextObject,
ICredentialDataDecryptedObject,
ICredentialsExpressionResolveValues,
IDataObject,
IExecuteResponsePromiseData,
IExecuteWorkflowInfo,
IHttpRequestOptions,
IN8nHttpFullResponse,
IN8nHttpResponse,
INode,
INodeCredentialDescription,
INodeCredentialsDetails,
INodeExecutionData,
IOAuth2Options,
IRunExecutionData,
ISourceData,
ITaskDataConnections,
IWebhookData,
IWebhookDescription,
IWorkflowDataProxyAdditionalKeys,
IWorkflowDataProxyData,
IWorkflowExecuteAdditionalData,
Workflow,
WorkflowActivateMode,
WorkflowExecuteMode,
IExecuteData,
IGetNodeParameterOptions,
NodeParameterValueType,
NodeExecutionWithMetadata,
IPairedItemData,
ICredentialTestFunctions,
BinaryHelperFunctions,
NodeHelperFunctions,
RequestHelperFunctions,
FunctionsBase,
IExecuteFunctions,
IExecuteSingleFunctions,
IHookFunctions,
ILoadOptionsFunctions,
IPollFunctions,
ITriggerFunctions,
IWebhookFunctions,
BinaryMetadata,
FileSystemHelperFunctions,
INodeType,
INodePropertyCollection,
INodePropertyOptions,
FieldType,
INodeProperties,
} from 'n8n-workflow';
import {
createDeferredPromise,
isObjectEmpty,
isResourceMapperValue,
NodeApiError,
NodeHelpers,
NodeOperationError,
WorkflowDataProxy,
LoggerProxy as Logger,
OAuth2GrantType,
deepCopy,
fileTypeFromMimeType,
ExpressionError,
validateFieldType,
NodeSSLError,
} from 'n8n-workflow';
import { parse as parseContentDisposition } from 'content-disposition';
import { parse as parseContentType } from 'content-type';
import pick from 'lodash/pick';
import { Agent } from 'https';
import { IncomingMessage, type IncomingHttpHeaders } from 'http';
import { stringify } from 'qs';
import type { Token } from 'oauth-1.0a';
import clientOAuth1 from 'oauth-1.0a';
import type { import type {
ClientOAuth2Options, ClientOAuth2Options,
ClientOAuth2RequestObject, ClientOAuth2RequestObject,
ClientOAuth2TokenData, ClientOAuth2TokenData,
} from '@n8n/client-oauth2'; } from '@n8n/client-oauth2';
import { ClientOAuth2 } from '@n8n/client-oauth2'; import { ClientOAuth2 } from '@n8n/client-oauth2';
import crypto, { createHmac } from 'crypto';
import get from 'lodash/get';
import type { Request, Response } from 'express';
import FormData from 'form-data';
import path from 'path';
import type { OptionsWithUri, OptionsWithUrl } from 'request';
import type { RequestPromiseOptions } from 'request-promise-native';
import FileType from 'file-type';
import { lookup, extension } from 'mime-types';
import type { import type {
AxiosError, AxiosError,
AxiosPromise, AxiosPromise,
@ -114,34 +26,120 @@ import type {
Method, Method,
} from 'axios'; } from 'axios';
import axios from 'axios'; import axios from 'axios';
import url, { URL, URLSearchParams } from 'url'; import crypto, { createHmac } from 'crypto';
import { Readable } from 'stream'; import type { Request, Response } from 'express';
import { access as fsAccess, writeFile as fsWriteFile } from 'fs/promises'; import FileType from 'file-type';
import FormData from 'form-data';
import { createReadStream } from 'fs'; import { createReadStream } from 'fs';
import { access as fsAccess, writeFile as fsWriteFile } from 'fs/promises';
import { IncomingMessage, type IncomingHttpHeaders } from 'http';
import { Agent } from 'https';
import get from 'lodash/get';
import pick from 'lodash/pick';
import { extension, lookup } from 'mime-types';
import type {
BinaryHelperFunctions,
BinaryMetadata,
FieldType,
FileSystemHelperFunctions,
FunctionsBase,
GenericValue,
IAdditionalCredentialOptions,
IAllExecuteFunctions,
IBinaryData,
IContextObject,
ICredentialDataDecryptedObject,
ICredentialTestFunctions,
ICredentialsExpressionResolveValues,
IDataObject,
IExecuteData,
IExecuteFunctions,
IExecuteResponsePromiseData,
IExecuteSingleFunctions,
IExecuteWorkflowInfo,
IGetNodeParameterOptions,
IHookFunctions,
IHttpRequestOptions,
ILoadOptionsFunctions,
IN8nHttpFullResponse,
IN8nHttpResponse,
INode,
INodeCredentialDescription,
INodeCredentialsDetails,
INodeExecutionData,
INodeProperties,
INodePropertyCollection,
INodePropertyOptions,
INodeType,
IOAuth2Options,
IPairedItemData,
IPollFunctions,
IRunExecutionData,
ISourceData,
ITaskDataConnections,
ITriggerFunctions,
IWebhookData,
IWebhookDescription,
IWebhookFunctions,
IWorkflowDataProxyAdditionalKeys,
IWorkflowDataProxyData,
IWorkflowExecuteAdditionalData,
NodeExecutionWithMetadata,
NodeHelperFunctions,
NodeParameterValueType,
RequestHelperFunctions,
Workflow,
WorkflowActivateMode,
WorkflowExecuteMode,
} from 'n8n-workflow';
import {
ExpressionError,
LoggerProxy as Logger,
NodeApiError,
NodeHelpers,
NodeOperationError,
NodeSSLError,
OAuth2GrantType,
WorkflowDataProxy,
createDeferredPromise,
deepCopy,
fileTypeFromMimeType,
isObjectEmpty,
isResourceMapperValue,
validateFieldType,
} from 'n8n-workflow';
import type { Token } from 'oauth-1.0a';
import clientOAuth1 from 'oauth-1.0a';
import path from 'path';
import { stringify } from 'qs';
import type { OptionsWithUri, OptionsWithUrl } from 'request';
import type { RequestPromiseOptions } from 'request-promise-native';
import { Readable } from 'stream';
import url, { URL, URLSearchParams } from 'url';
import { BinaryDataManager } from './BinaryDataManager'; import { BinaryDataManager } from './BinaryDataManager';
import type { ExtendedValidationResult, IResponseError, IWorkflowSettings } from './Interfaces'; import { binaryToBuffer } from './BinaryDataManager/utils';
import { extractValue } from './ExtractValue';
import { getClientCredentialsToken } from './OAuth2Helper';
import { import {
BINARY_DATA_STORAGE_PATH,
BLOCK_FILE_ACCESS_TO_N8N_FILES,
CONFIG_FILES,
CUSTOM_EXTENSION_ENV, CUSTOM_EXTENSION_ENV,
PLACEHOLDER_EMPTY_EXECUTION_ID, PLACEHOLDER_EMPTY_EXECUTION_ID,
BLOCK_FILE_ACCESS_TO_N8N_FILES,
RESTRICT_FILE_ACCESS_TO, RESTRICT_FILE_ACCESS_TO,
CONFIG_FILES,
BINARY_DATA_STORAGE_PATH,
UM_EMAIL_TEMPLATES_INVITE, UM_EMAIL_TEMPLATES_INVITE,
UM_EMAIL_TEMPLATES_PWRESET, UM_EMAIL_TEMPLATES_PWRESET,
} from './Constants'; } from './Constants';
import { binaryToBuffer } from './BinaryDataManager/utils'; import { extractValue } from './ExtractValue';
import type { ExtendedValidationResult, IResponseError, IWorkflowSettings } from './Interfaces';
import { getClientCredentialsToken } from './OAuth2Helper';
import { getSecretsProxy } from './Secrets';
import { getUserN8nFolderPath } from './UserSettings';
import { import {
getAllWorkflowExecutionMetadata, getAllWorkflowExecutionMetadata,
getWorkflowExecutionMetadata, getWorkflowExecutionMetadata,
setAllWorkflowExecutionMetadata, setAllWorkflowExecutionMetadata,
setWorkflowExecutionMetadata, setWorkflowExecutionMetadata,
} from './WorkflowExecutionMetadata'; } from './WorkflowExecutionMetadata';
import { getSecretsProxy } from './Secrets';
import { getUserN8nFolderPath } from './UserSettings';
axios.defaults.timeout = 300000; axios.defaults.timeout = 300000;
// Prevent axios from adding x-form-www-urlencoded headers by default // Prevent axios from adding x-form-www-urlencoded headers by default
@ -604,26 +602,91 @@ type ConfigObject = {
simple?: boolean; simple?: boolean;
}; };
export function parseIncomingMessage(message: IncomingMessage) { interface IContentType {
if ('content-type' in message.headers) { type: string;
const { type: contentType, parameters } = (() => { parameters: {
try { charset: string;
return parseContentType(message); [key: string]: string;
} catch { };
return { type: undefined, parameters: undefined }; }
}
})(); interface IContentDisposition {
message.contentType = contentType; type: string;
message.encoding = (parameters?.charset ?? 'utf-8').toLowerCase() as BufferEncoding; filename?: string;
}
function parseHeaderParameters(parameters: string[]): Record<string, string> {
return parameters.reduce(
(acc, param) => {
const [key, value] = param.split('=');
acc[key.toLowerCase().trim()] = decodeURIComponent(value);
return acc;
},
{} as Record<string, string>,
);
}
function parseContentType(contentType?: string): IContentType | null {
if (!contentType) {
return null;
} }
const contentDispositionHeader = message.headers['content-disposition']; const [type, ...parameters] = contentType.split(';');
if (contentDispositionHeader?.length) {
const { return {
type, type: type.toLowerCase(),
parameters: { filename }, parameters: { charset: 'utf-8', ...parseHeaderParameters(parameters) },
} = parseContentDisposition(contentDispositionHeader); };
message.contentDisposition = { type, filename }; }
function parseFileName(filename?: string): string | undefined {
if (filename?.startsWith('"') && filename?.endsWith('"')) {
return filename.slice(1, -1);
}
return filename;
}
// https://datatracker.ietf.org/doc/html/rfc5987
function parseFileNameStar(filename?: string): string | undefined {
const [_encoding, _locale, content] = filename?.split("'") ?? [];
return content;
}
function parseContentDisposition(contentDisposition?: string): IContentDisposition | null {
if (!contentDisposition) {
return null;
}
// This is invalid syntax, but common
// Example 'filename="example.png"' (instead of 'attachment; filename="example.png"')
if (!contentDisposition.startsWith('attachment') && !contentDisposition.startsWith('inline')) {
contentDisposition = `attachment; ${contentDisposition}`;
}
const [type, ...parameters] = contentDisposition.split(';');
const parsedParameters = parseHeaderParameters(parameters);
return {
type,
filename:
parseFileNameStar(parsedParameters['filename*']) ?? parseFileName(parsedParameters.filename),
};
}
export function parseIncomingMessage(message: IncomingMessage) {
const contentType = parseContentType(message.headers['content-type']);
if (contentType) {
const { type, parameters } = contentType;
message.contentType = type;
message.encoding = parameters.charset.toLowerCase() as BufferEncoding;
}
const contentDisposition = parseContentDisposition(message.headers['content-disposition']);
if (contentDisposition) {
message.contentDisposition = contentDisposition;
} }
} }

View file

@ -1,7 +1,12 @@
import nock from 'nock'; import { BinaryDataManager } from '@/BinaryDataManager';
import { join } from 'path'; import {
import { tmpdir } from 'os'; getBinaryDataBuffer,
import { readFileSync, mkdtempSync } from 'fs'; parseIncomingMessage,
proxyRequestToAxios,
setBinaryDataBuffer,
} from '@/NodeExecuteFunctions';
import { mkdtempSync, readFileSync } from 'fs';
import type { IncomingMessage } from 'http';
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import type { import type {
IBinaryData, IBinaryData,
@ -11,12 +16,9 @@ import type {
Workflow, Workflow,
WorkflowHooks, WorkflowHooks,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { BinaryDataManager } from '@/BinaryDataManager'; import nock from 'nock';
import { import { tmpdir } from 'os';
setBinaryDataBuffer, import { join } from 'path';
getBinaryDataBuffer,
proxyRequestToAxios,
} from '@/NodeExecuteFunctions';
import { initLogger } from './helpers/utils'; import { initLogger } from './helpers/utils';
const temporaryDir = mkdtempSync(join(tmpdir(), 'n8n')); const temporaryDir = mkdtempSync(join(tmpdir(), 'n8n'));
@ -136,6 +138,75 @@ describe('NodeExecuteFunctions', () => {
}); });
}); });
describe('parseIncomingMessage', () => {
it('parses valid content-type header', () => {
const message = mock<IncomingMessage>({
headers: { 'content-type': 'application/json', 'content-disposition': undefined },
});
parseIncomingMessage(message);
expect(message.contentType).toEqual('application/json');
});
it('parses valid content-type header with parameters', () => {
const message = mock<IncomingMessage>({
headers: {
'content-type': 'application/json; charset=utf-8',
'content-disposition': undefined,
},
});
parseIncomingMessage(message);
expect(message.contentType).toEqual('application/json');
});
it('parses valid content-disposition header with filename*', () => {
const message = mock<IncomingMessage>({
headers: {
'content-type': undefined,
'content-disposition':
'attachment; filename="screenshot%20(1).png"; filename*=UTF-8\'\'screenshot%20(1).png',
},
});
parseIncomingMessage(message);
expect(message.contentDisposition).toEqual({
filename: 'screenshot (1).png',
type: 'attachment',
});
});
it('parses valid content-disposition header with filename and trailing ";"', () => {
const message = mock<IncomingMessage>({
headers: {
'content-type': undefined,
'content-disposition': 'inline; filename="screenshot%20(1).png";',
},
});
parseIncomingMessage(message);
expect(message.contentDisposition).toEqual({
filename: 'screenshot (1).png',
type: 'inline',
});
});
it('parses non standard content-disposition with missing type', () => {
const message = mock<IncomingMessage>({
headers: {
'content-type': undefined,
'content-disposition': 'filename="screenshot%20(1).png";',
},
});
parseIncomingMessage(message);
expect(message.contentDisposition).toEqual({
filename: 'screenshot (1).png',
type: 'attachment',
});
});
});
describe('proxyRequestToAxios', () => { describe('proxyRequestToAxios', () => {
const baseUrl = 'http://example.de'; const baseUrl = 'http://example.de';
const workflow = mock<Workflow>(); const workflow = mock<Workflow>();

View file

@ -576,12 +576,6 @@ importers:
concat-stream: concat-stream:
specifier: ^2.0.0 specifier: ^2.0.0
version: 2.0.0 version: 2.0.0
content-disposition:
specifier: ^0.5.4
version: 0.5.4
content-type:
specifier: ^1.0.4
version: 1.0.4
cron: cron:
specifier: ~1.7.2 specifier: ~1.7.2
version: 1.7.2 version: 1.7.2
@ -631,12 +625,6 @@ importers:
'@types/concat-stream': '@types/concat-stream':
specifier: ^2.0.0 specifier: ^2.0.0
version: 2.0.0 version: 2.0.0
'@types/content-disposition':
specifier: ^0.5.5
version: 0.5.5
'@types/content-type':
specifier: ^1.1.5
version: 1.1.5
'@types/cron': '@types/cron':
specifier: ~1.7.1 specifier: ~1.7.1
version: 1.7.3 version: 1.7.3
@ -6968,14 +6956,6 @@ packages:
dependencies: dependencies:
'@types/node': 18.16.16 '@types/node': 18.16.16
/@types/content-disposition@0.5.5:
resolution: {integrity: sha512-v6LCdKfK6BwcqMo+wYW05rLS12S0ZO0Fl4w1h4aaZMD7bqT3gVUns6FvLJKGZHQmYn3SX55JWGpziwJRwVgutA==}
dev: true
/@types/content-type@1.1.5:
resolution: {integrity: sha512-dgMN+syt1xb7Hk8LU6AODOfPlvz5z1CbXpPuJE5ZrX9STfBOIXF09pEB8N7a97WT9dbngt3ksDCm6GW6yMrxfQ==}
dev: true
/@types/convict@6.1.1: /@types/convict@6.1.1:
resolution: {integrity: sha512-R+JLaTvhsD06p4jyjUDtbd5xMtZTRE3c0iI+lrFWZogSVEjgTWPYwvJPVf+t92E+yrlbXa4X4Eg9ro6gPdUt4w==} resolution: {integrity: sha512-R+JLaTvhsD06p4jyjUDtbd5xMtZTRE3c0iI+lrFWZogSVEjgTWPYwvJPVf+t92E+yrlbXa4X4Eg9ro6gPdUt4w==}
dependencies: dependencies: