fix(RSS Feed Read Node): Respect proxy settings (#30059)

This commit is contained in:
Michael Kret 2026-05-11 16:28:15 +03:00 committed by GitHub
parent 0494f24967
commit 2e046d5b7f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 221 additions and 48 deletions

View File

@ -0,0 +1,63 @@
import type {
IDataObject,
IExecuteFunctions,
IHttpRequestOptions,
IPollFunctions,
} from 'n8n-workflow';
import { sanitizeXmlName } from 'n8n-workflow';
import Parser from 'rss-parser';
const DEFAULT_HEADERS = {
'User-Agent': 'rss-parser',
Accept: 'application/rss+xml',
};
const RELAXED_ACCEPT_HEADER =
'application/rss+xml, application/rdf+xml;q=0.8, application/atom+xml;q=0.6, application/xml;q=0.4, text/xml;q=0.4';
type RequestHelpers = IExecuteFunctions['helpers'] | IPollFunctions['helpers'];
export async function parseFeedUrl(
helpers: RequestHelpers,
feedUrl: string,
options: {
customFields?: string;
ignoreSSL?: boolean;
useRelaxedAcceptHeader?: boolean;
} = {},
): Promise<Parser.Output<IDataObject>> {
const headers = {
...DEFAULT_HEADERS,
...(options.useRelaxedAcceptHeader ? { Accept: RELAXED_ACCEPT_HEADER } : {}),
};
const requestOptions: IHttpRequestOptions = {
method: 'GET',
url: feedUrl,
headers,
json: false,
encoding: 'text',
skipSslCertificateValidation: options.ignoreSSL,
};
const feedXmlResponse = await helpers.httpRequest(requestOptions);
const feedXml = typeof feedXmlResponse === 'string' ? feedXmlResponse : String(feedXmlResponse);
const parserOptions: Parser.ParserOptions<IDataObject, IDataObject> = {
xml2js: {
tagNameProcessors: [sanitizeXmlName],
attrNameProcessors: [sanitizeXmlName],
},
...(options.customFields
? {
customFields: {
item: options.customFields.split(',').map((field) => field.trim()),
},
}
: {}),
};
const parser = new Parser(parserOptions);
return await parser.parseString(feedXml);
}

View File

@ -5,11 +5,13 @@ import type {
INodeType,
INodeTypeDescription,
} from 'n8n-workflow';
import { NodeConnectionTypes, NodeOperationError, sanitizeXmlName } from 'n8n-workflow';
import Parser from 'rss-parser';
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
import { URL } from 'url';
import type Parser from 'rss-parser';
import { generatePairedItemData } from '../../utils/utilities';
import { parseFeedUrl } from './GenericFunctions';
// Utility function
@ -105,35 +107,13 @@ export class RssFeedRead implements INodeType {
});
}
const parserOptions: IDataObject = {
requestOptions: {
rejectUnauthorized: !ignoreSSL,
},
xml2js: {
tagNameProcessors: [sanitizeXmlName],
attrNameProcessors: [sanitizeXmlName],
},
};
if (nodeVersion >= 1.2) {
parserOptions.headers = {
Accept:
'application/rss+xml, application/rdf+xml;q=0.8, application/atom+xml;q=0.6, application/xml;q=0.4, text/xml;q=0.4',
};
}
if (options.customFields) {
const customFields = options.customFields as string;
parserOptions.customFields = {
item: customFields.split(',').map((field) => field.trim()),
};
}
const parser = new Parser(parserOptions);
let feed: Parser.Output<IDataObject>;
try {
feed = await parser.parseURL(url);
feed = await parseFeedUrl(this.helpers, url, {
customFields: options.customFields as string | undefined,
ignoreSSL,
useRelaxedAcceptHeader: nodeVersion >= 1.2,
});
} catch (error) {
if (error.code === 'ECONNREFUSED') {
throw new NodeOperationError(

View File

@ -6,8 +6,11 @@ import type {
INodeTypeDescription,
IPollFunctions,
} from 'n8n-workflow';
import { NodeConnectionTypes, NodeOperationError, sanitizeXmlName } from 'n8n-workflow';
import Parser from 'rss-parser';
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
import type Parser from 'rss-parser';
import { parseFeedUrl } from './GenericFunctions';
interface PollData {
lastItemDate?: string;
@ -54,16 +57,9 @@ export class RssFeedReadTrigger implements INodeType {
throw new NodeOperationError(this.getNode(), 'The parameter "URL" has to be set!');
}
const parser = new Parser({
xml2js: {
tagNameProcessors: [sanitizeXmlName],
attrNameProcessors: [sanitizeXmlName],
},
});
let feed: Parser.Output<IDataObject>;
try {
feed = await parser.parseURL(feedUrl);
feed = await parseFeedUrl(this.helpers, feedUrl);
} catch (error) {
if (error.code === 'ECONNREFUSED') {
throw new NodeOperationError(

View File

@ -0,0 +1,118 @@
import { mock } from 'jest-mock-extended';
import type { IExecuteFunctions } from 'n8n-workflow';
import Parser from 'rss-parser';
import { parseFeedUrl } from '../GenericFunctions';
jest.mock('rss-parser');
const ParserMock = Parser as unknown as jest.Mock;
const RELAXED_ACCEPT =
'application/rss+xml, application/rdf+xml;q=0.8, application/atom+xml;q=0.6, application/xml;q=0.4, text/xml;q=0.4';
describe('parseFeedUrl', () => {
const feedUrl = 'https://example.com/feed';
const xmlBody = '<rss />';
const parsed = { items: [{ title: 'item-1' }] };
let helpers: ReturnType<typeof mock<IExecuteFunctions['helpers']>>;
beforeEach(() => {
jest.clearAllMocks();
helpers = mock<IExecuteFunctions['helpers']>();
helpers.httpRequest.mockResolvedValue(xmlBody);
(Parser.prototype.parseString as jest.Mock).mockResolvedValue(parsed);
});
it('uses GET with default headers, text encoding, and no SSL skip when no options are passed', async () => {
const result = await parseFeedUrl(helpers, feedUrl);
expect(result).toBe(parsed);
expect(helpers.httpRequest).toHaveBeenCalledTimes(1);
expect(helpers.httpRequest).toHaveBeenCalledWith({
method: 'GET',
url: feedUrl,
headers: {
'User-Agent': 'rss-parser',
Accept: 'application/rss+xml',
},
json: false,
encoding: 'text',
skipSslCertificateValidation: undefined,
});
expect(Parser.prototype.parseString).toHaveBeenCalledWith(xmlBody);
});
it('switches to the relaxed Accept header when useRelaxedAcceptHeader is true', async () => {
await parseFeedUrl(helpers, feedUrl, { useRelaxedAcceptHeader: true });
expect(helpers.httpRequest).toHaveBeenCalledWith(
expect.objectContaining({
headers: expect.objectContaining({ Accept: RELAXED_ACCEPT }),
}),
);
});
it('passes skipSslCertificateValidation when ignoreSSL is true', async () => {
await parseFeedUrl(helpers, feedUrl, { ignoreSSL: true });
expect(helpers.httpRequest).toHaveBeenCalledWith(
expect.objectContaining({ skipSslCertificateValidation: true }),
);
});
it('splits and trims customFields before passing them to the parser', async () => {
await parseFeedUrl(helpers, feedUrl, { customFields: 'custom, dc:creator , media:content' });
expect(ParserMock).toHaveBeenCalledWith(
expect.objectContaining({
customFields: { item: ['custom', 'dc:creator', 'media:content'] },
}),
);
});
it('does not set a customFields parser option when none are provided', async () => {
await parseFeedUrl(helpers, feedUrl);
const parserOptions = ParserMock.mock.calls.at(-1)?.[0] ?? {};
expect(parserOptions).not.toHaveProperty('customFields');
});
it('always sets the sanitizing xml2js processors on the parser', async () => {
await parseFeedUrl(helpers, feedUrl);
const parserOptions = ParserMock.mock.calls.at(-1)?.[0];
expect(parserOptions?.xml2js?.tagNameProcessors).toHaveLength(1);
expect(parserOptions?.xml2js?.attrNameProcessors).toHaveLength(1);
});
it('coerces a non-string response body to a string before parsing', async () => {
helpers.httpRequest.mockResolvedValue(Buffer.from('<rss>buf</rss>') as unknown as string);
await parseFeedUrl(helpers, feedUrl);
expect(Parser.prototype.parseString).toHaveBeenCalledWith(
expect.stringContaining('<rss>buf</rss>'),
);
});
it('propagates errors thrown by the underlying httpRequest helper', async () => {
const networkError = Object.assign(new Error('refused'), { code: 'ECONNREFUSED' });
helpers.httpRequest.mockRejectedValue(networkError);
await expect(parseFeedUrl(helpers, feedUrl)).rejects.toBe(networkError);
expect(Parser.prototype.parseString).not.toHaveBeenCalled();
});
it('propagates errors thrown by the parser', async () => {
const parseError = new Error('bad xml');
(Parser.prototype.parseString as jest.Mock).mockRejectedValue(parseError);
await expect(parseFeedUrl(helpers, feedUrl)).rejects.toBe(parseError);
});
});

View File

@ -17,8 +17,11 @@ describe('RssFeedReadTrigger', () => {
const newItemDate = '2022-01-02T00:00:00.000Z';
const node = new RssFeedReadTrigger();
const pollFunctions = mock<IPollFunctions>({
helpers: mock({ returnJsonArray }),
const helpers = mock<IPollFunctions['helpers']>({ returnJsonArray });
const pollFunctions = mock<IPollFunctions>({ helpers });
beforeEach(() => {
jest.clearAllMocks();
});
it('should throw an error if the feed URL is empty', async () => {
@ -27,14 +30,16 @@ describe('RssFeedReadTrigger', () => {
await expect(node.poll.call(pollFunctions)).rejects.toThrowError();
expect(pollFunctions.getNodeParameter).toHaveBeenCalledWith('feedUrl');
expect(Parser.prototype.parseURL).not.toHaveBeenCalled();
expect(helpers.httpRequest).not.toHaveBeenCalled();
expect(Parser.prototype.parseString).not.toHaveBeenCalled();
});
it('should return new items from the feed', async () => {
const pollData = mock({ lastItemDate });
pollFunctions.getNodeParameter.mockReturnValue(feedUrl);
pollFunctions.getWorkflowStaticData.mockReturnValue(pollData);
(Parser.prototype.parseURL as jest.Mock).mockResolvedValue({
helpers.httpRequest.mockResolvedValue('<rss />');
(Parser.prototype.parseString as jest.Mock).mockResolvedValue({
items: [{ isoDate: lastItemDate }, { isoDate: newItemDate }],
});
@ -43,7 +48,10 @@ describe('RssFeedReadTrigger', () => {
expect(result).toEqual([[{ json: { isoDate: newItemDate } }]]);
expect(pollFunctions.getWorkflowStaticData).toHaveBeenCalledWith('node');
expect(pollFunctions.getNodeParameter).toHaveBeenCalledWith('feedUrl');
expect(Parser.prototype.parseURL).toHaveBeenCalledWith(feedUrl);
expect(helpers.httpRequest).toHaveBeenCalledWith(
expect.objectContaining({ method: 'GET', url: feedUrl }),
);
expect(Parser.prototype.parseString).toHaveBeenCalledWith('<rss />');
expect(pollData.lastItemDate).toEqual(newItemDate);
});
@ -51,28 +59,36 @@ describe('RssFeedReadTrigger', () => {
const pollData = mock();
pollFunctions.getNodeParameter.mockReturnValue(feedUrl);
pollFunctions.getWorkflowStaticData.mockReturnValue(pollData);
(Parser.prototype.parseURL as jest.Mock).mockResolvedValue({ items: [{}, {}] });
helpers.httpRequest.mockResolvedValue('<rss />');
(Parser.prototype.parseString as jest.Mock).mockResolvedValue({ items: [{}, {}] });
const result = await node.poll.call(pollFunctions);
expect(result).toEqual(null);
expect(pollFunctions.getWorkflowStaticData).toHaveBeenCalledWith('node');
expect(pollFunctions.getNodeParameter).toHaveBeenCalledWith('feedUrl');
expect(Parser.prototype.parseURL).toHaveBeenCalledWith(feedUrl);
expect(helpers.httpRequest).toHaveBeenCalledWith(
expect.objectContaining({ method: 'GET', url: feedUrl }),
);
expect(Parser.prototype.parseString).toHaveBeenCalledWith('<rss />');
});
it('should return null if the feed is empty', async () => {
const pollData = mock({ lastItemDate });
pollFunctions.getNodeParameter.mockReturnValue(feedUrl);
pollFunctions.getWorkflowStaticData.mockReturnValue(pollData);
(Parser.prototype.parseURL as jest.Mock).mockResolvedValue({ items: [] });
helpers.httpRequest.mockResolvedValue('<rss />');
(Parser.prototype.parseString as jest.Mock).mockResolvedValue({ items: [] });
const result = await node.poll.call(pollFunctions);
expect(result).toEqual(null);
expect(pollFunctions.getWorkflowStaticData).toHaveBeenCalledWith('node');
expect(pollFunctions.getNodeParameter).toHaveBeenCalledWith('feedUrl');
expect(Parser.prototype.parseURL).toHaveBeenCalledWith(feedUrl);
expect(helpers.httpRequest).toHaveBeenCalledWith(
expect.objectContaining({ method: 'GET', url: feedUrl }),
);
expect(Parser.prototype.parseString).toHaveBeenCalledWith('<rss />');
expect(pollData.lastItemDate).toEqual(lastItemDate);
});
});