From e0ab5ba45ab01ae047eafa844fd16c06e828c09e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dimitri=20Lavren=C3=BCk?= <20122620+dlavrenuek@users.noreply.github.com> Date: Fri, 19 Dec 2025 17:17:07 +0100 Subject: [PATCH] fix: Sharepoint file selection correctly applies filter (#23155) --- .../Microsoft/SharePoint/helpers/utils.ts | 2 + .../SharePoint/methods/listSearch.ts | 12 ++-- .../SharePoint/test/helpers/utils.test.ts | 11 +++- .../test/methods/listSearch.test.ts | 63 ++++++++++++++++++- 4 files changed, 78 insertions(+), 10 deletions(-) diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/helpers/utils.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/helpers/utils.ts index 8af7d628f47..e17d539880a 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/helpers/utils.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/helpers/utils.ts @@ -12,6 +12,8 @@ import { jsonParse, NodeApiError, NodeOperationError } from 'n8n-workflow'; import type { IErrorResponse } from './interfaces'; import { microsoftSharePointApiRequest } from '../transport'; +export const escapeFilterValue = (value: string) => value.replaceAll("'", "''"); + export async function simplifyItemPostReceive( this: IExecuteSingleFunctions, items: INodeExecutionData[], diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/methods/listSearch.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/methods/listSearch.ts index 392071ec5fb..0b7272101da 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/methods/listSearch.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/methods/listSearch.ts @@ -6,6 +6,7 @@ import type { } from 'n8n-workflow'; import type { IDriveItem, IList, IListItem, ISite } from '../helpers/interfaces'; +import { escapeFilterValue } from '../helpers/utils'; import { microsoftSharePointApiRequest } from '../transport'; export async function getFiles( @@ -34,7 +35,7 @@ export async function getFiles( $select: 'id,name,file', }; if (filter) { - qs.$filter = `name eq '${filter}'`; + qs.$filter = `startswith(name, '${escapeFilterValue(filter)}')`; } response = await microsoftSharePointApiRequest.call( this, @@ -85,9 +86,6 @@ export async function getFolders( // https://learn.microsoft.com/en-us/onedrive/developer/rest-api/concepts/filtering-results?view=odsp-graph-online#filterable-properties $filter: 'folder ne null', }; - if (filter) { - qs.$filter = `name eq '${filter}'`; - } response = await microsoftSharePointApiRequest.call( this, 'GET', @@ -100,7 +98,7 @@ export async function getFolders( const items: IDriveItem[] = response.value; const results: INodeListSearchItems[] = items - .filter((x) => x.folder) + .filter((x) => x.folder && (!filter || x.name?.toLowerCase()?.includes?.(filter.toLowerCase()))) .map((g) => ({ name: g.name, value: g.id, @@ -137,7 +135,7 @@ export async function getItems( $select: 'id,fields', }; if (filter) { - qs.$filter = `fields/Title eq '${filter}'`; + qs.$filter = `fields/Title eq '${escapeFilterValue(filter)}'`; } response = await microsoftSharePointApiRequest.call( this, @@ -185,7 +183,7 @@ export async function getLists( $select: 'id,displayName', }; if (filter) { - qs.$filter = `displayName eq '${filter}'`; + qs.$filter = `displayName eq '${escapeFilterValue(filter)}'`; } response = await microsoftSharePointApiRequest.call( this, diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/test/helpers/utils.test.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/test/helpers/utils.test.ts index b780a2f3190..bb0d936925a 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/test/helpers/utils.test.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/test/helpers/utils.test.ts @@ -2,7 +2,7 @@ import type { MockProxy } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended'; import type { IBinaryData, IExecuteSingleFunctions } from 'n8n-workflow'; -import { downloadFilePostReceive } from '../../helpers/utils'; +import { downloadFilePostReceive, escapeFilterValue } from '../../helpers/utils'; describe('Microsoft SharePoint Node', () => { let executeSingleFunctions: MockProxy; @@ -51,4 +51,13 @@ describe('Microsoft SharePoint Node', () => { }, ]); }); + + describe('escapeFilterValue', () => { + it('should escape single quotes', () => { + expect(escapeFilterValue("hello' there ''")).toEqual("hello'' there ''''"); + }); + it('should not escape double quotes', () => { + expect(escapeFilterValue('hello " there ""')).toEqual('hello " there ""'); + }); + }); }); diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/test/methods/listSearch.test.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/test/methods/listSearch.test.ts index ce43fd7bb0a..0daa1fc7731 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/test/methods/listSearch.test.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/test/methods/listSearch.test.ts @@ -75,7 +75,7 @@ describe('Microsoft SharePoint Node', () => { expect(mockRequestWithAuthentication).toHaveBeenCalledTimes(1); expect(mockRequestWithAuthentication.mock.calls[0][1]).toMatchObject({ qs: { - $filter: "name eq 'file'", + $filter: "startswith(name, 'file')", }, }); expect(listSearchResult).toEqual({ @@ -88,6 +88,26 @@ describe('Microsoft SharePoint Node', () => { }); }); + it('properly escapes filter parameter', async () => { + const mockResponse = { + '@odata.nextLink': + 'https://mydomain.sharepoint.com/_api/v2.0/sites(%27mydomain.sharepoint.com,site1%27)/items?%24skiptoken=aWQ9MjFFQkEzOUMtMkU3My00NzgwLUFBQzEtMTVDNzlDMTk4QjlB', + value: [], + }; + mockRequestWithAuthentication.mockReturnValue(mockResponse); + loadOptionsFunctions.getNodeParameter.mockReturnValueOnce('site'); + loadOptionsFunctions.getNodeParameter.mockReturnValueOnce('folder'); + + await node.methods.listSearch.getFiles.call(loadOptionsFunctions, "fi'le'"); + + expect(mockRequestWithAuthentication).toHaveBeenCalledTimes(1); + expect(mockRequestWithAuthentication.mock.calls[0][1]).toMatchObject({ + qs: { + $filter: "startswith(name, 'fi''le''')", + }, + }); + }); + it('should list search files with pagination', async () => { const mockResponse = { value: [ @@ -166,7 +186,7 @@ describe('Microsoft SharePoint Node', () => { expect(mockRequestWithAuthentication).toHaveBeenCalledTimes(1); expect(mockRequestWithAuthentication.mock.calls[0][1]).toMatchObject({ qs: { - $filter: "name eq 'folder'", + $filter: 'folder ne null', }, }); expect(listSearchResult).toEqual({ @@ -278,6 +298,26 @@ describe('Microsoft SharePoint Node', () => { }); }); + it('properly escapes filter parameter', async () => { + const mockResponse = { + '@odata.nextLink': + 'https://mydomain.sharepoint.com/_api/v2.0/sites(%27mydomain.sharepoint.com,site1%27)/listItems?%24skiptoken=aWQ9MjFFQkEzOUMtMkU3My00NzgwLUFBQzEtMTVDNzlDMTk4QjlB', + value: [], + }; + mockRequestWithAuthentication.mockReturnValue(mockResponse); + loadOptionsFunctions.getNodeParameter.mockReturnValueOnce('site'); + loadOptionsFunctions.getNodeParameter.mockReturnValueOnce('list'); + + await node.methods.listSearch.getItems.call(loadOptionsFunctions, "Ti'le'"); + + expect(mockRequestWithAuthentication).toHaveBeenCalledTimes(1); + expect(mockRequestWithAuthentication.mock.calls[0][1]).toMatchObject({ + qs: { + $filter: "fields/Title eq 'Ti''le'''", + }, + }); + }); + it('should list search items with pagination', async () => { const mockResponse = { value: [ @@ -362,6 +402,25 @@ describe('Microsoft SharePoint Node', () => { }); }); + it('properly escapes filter parameter', async () => { + const mockResponse = { + '@odata.nextLink': + 'https://mydomain.sharepoint.com/_api/v2.0/sites(%27mydomain.sharepoint.com,site1%27)/lists?%24skiptoken=aWQ9MjFFQkEzOUMtMkU3My00NzgwLUFBQzEtMTVDNzlDMTk4QjlB', + value: [], + }; + mockRequestWithAuthentication.mockReturnValue(mockResponse); + loadOptionsFunctions.getNodeParameter.mockReturnValueOnce('site'); + + await node.methods.listSearch.getLists.call(loadOptionsFunctions, "' or '1'='1"); + + expect(mockRequestWithAuthentication).toHaveBeenCalledTimes(1); + expect(mockRequestWithAuthentication.mock.calls[0][1]).toMatchObject({ + qs: { + $filter: "displayName eq ''' or ''1''=''1'", + }, + }); + }); + it('should list search lists with pagination', async () => { const mockResponse = { value: [