From 4d9dec72bef0afe57376e3782f8fb09020e99064 Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Fri, 5 Jan 2024 17:10:40 +0100 Subject: [PATCH] Escape MySql table names correctly --- .../nodes/MySql/test/v2/utils.test.ts | 27 +++++++++++++++++++ .../actions/database/deleteTable.operation.ts | 8 +++--- .../v2/actions/database/insert.operation.ts | 14 ++++++---- .../v2/actions/database/select.operation.ts | 8 +++--- .../v2/actions/database/update.operation.ts | 10 ++++--- .../v2/actions/database/upsert.operation.ts | 10 ++++--- .../nodes/MySql/v2/helpers/utils.ts | 24 ++++++++++++++--- .../nodes/MySql/v2/methods/loadOptions.ts | 5 +++- 8 files changed, 81 insertions(+), 25 deletions(-) diff --git a/packages/nodes-base/nodes/MySql/test/v2/utils.test.ts b/packages/nodes-base/nodes/MySql/test/v2/utils.test.ts index 15c7bd2847f28..1f98b9adf2884 100644 --- a/packages/nodes-base/nodes/MySql/test/v2/utils.test.ts +++ b/packages/nodes-base/nodes/MySql/test/v2/utils.test.ts @@ -7,6 +7,7 @@ import { addWhereClauses, addSortRules, replaceEmptyStringsByNulls, + escapeSqlIdentifier, } from '../../v2/helpers/utils'; const mySqlMockNode: INode = { @@ -148,3 +149,29 @@ describe('Test MySql V2, replaceEmptyStringsByNulls', () => { expect(replacedData).toEqual([{ json: { id: 1, name: '' } }]); }); }); + +describe('Test MySql V2, escapeSqlIdentifier', () => { + it('should escape fully qualified identifier', () => { + const input = 'db_name.tbl_name.col_name'; + const escapedIdentifier = escapeSqlIdentifier(input); + expect(escapedIdentifier).toEqual('`db_name`.`tbl_name`.`col_name`'); + }); + + it('should escape table name only', () => { + const input = 'tbl_name'; + const escapedIdentifier = escapeSqlIdentifier(input); + expect(escapedIdentifier).toEqual('`tbl_name`'); + }); + + it('should escape fully qualified identifier with backticks', () => { + const input = '`db_name`.`tbl_name`.`col_name`'; + const escapedIdentifier = escapeSqlIdentifier(input); + expect(escapedIdentifier).toEqual('`db_name`.`tbl_name`.`col_name`'); + }); + + it('should escape identifier with dots', () => { + const input = '`db_name`.`some.dotted.tbl_name`'; + const escapedIdentifier = escapeSqlIdentifier(input); + expect(escapedIdentifier).toEqual('`db_name`.`some.dotted.tbl_name`'); + }); +}); diff --git a/packages/nodes-base/nodes/MySql/v2/actions/database/deleteTable.operation.ts b/packages/nodes-base/nodes/MySql/v2/actions/database/deleteTable.operation.ts index 0335a8c0c3f25..3b725739774c0 100644 --- a/packages/nodes-base/nodes/MySql/v2/actions/database/deleteTable.operation.ts +++ b/packages/nodes-base/nodes/MySql/v2/actions/database/deleteTable.operation.ts @@ -13,7 +13,7 @@ import type { WhereClause, } from '../../helpers/interfaces'; -import { addWhereClauses } from '../../helpers/utils'; +import { addWhereClauses, escapeSqlIdentifier } from '../../helpers/utils'; import { optionsCollection, @@ -98,11 +98,11 @@ export async function execute( let values: QueryValues = []; if (deleteCommand === 'drop') { - query = `DROP TABLE IF EXISTS \`${table}\``; + query = `DROP TABLE IF EXISTS ${escapeSqlIdentifier(table)}`; } if (deleteCommand === 'truncate') { - query = `TRUNCATE TABLE \`${table}\``; + query = `TRUNCATE TABLE ${escapeSqlIdentifier(table)}`; } if (deleteCommand === 'delete') { @@ -114,7 +114,7 @@ export async function execute( [query, values] = addWhereClauses( this.getNode(), i, - `DELETE FROM \`${table}\``, + `DELETE FROM ${escapeSqlIdentifier(table)}`, whereClauses, values, combineConditions, diff --git a/packages/nodes-base/nodes/MySql/v2/actions/database/insert.operation.ts b/packages/nodes-base/nodes/MySql/v2/actions/database/insert.operation.ts index 23a79d3ab18b4..f3578f8d99644 100644 --- a/packages/nodes-base/nodes/MySql/v2/actions/database/insert.operation.ts +++ b/packages/nodes-base/nodes/MySql/v2/actions/database/insert.operation.ts @@ -14,7 +14,7 @@ import type { import { AUTO_MAP, BATCH_MODE, DATA_MODE } from '../../helpers/interfaces'; -import { replaceEmptyStringsByNulls } from '../../helpers/utils'; +import { escapeSqlIdentifier, replaceEmptyStringsByNulls } from '../../helpers/utils'; import { optionsCollection } from '../common.descriptions'; import { updateDisplayOptions } from '@utils/utilities'; @@ -171,11 +171,13 @@ export async function execute( ]; } - const escapedColumns = columns.map((column) => `\`${column}\``).join(', '); + const escapedColumns = columns.map(escapeSqlIdentifier).join(', '); const placeholder = `(${columns.map(() => '?').join(',')})`; const replacements = items.map(() => placeholder).join(','); - const query = `INSERT ${priority} ${ignore} INTO \`${table}\` (${escapedColumns}) VALUES ${replacements}`; + const query = `INSERT ${priority} ${ignore} INTO ${escapeSqlIdentifier( + table, + )} (${escapedColumns}) VALUES ${replacements}`; const values = insertItems.reduce( (acc: IDataObject[], item) => acc.concat(Object.values(item) as IDataObject[]), @@ -214,10 +216,12 @@ export async function execute( columns = Object.keys(insertItem); } - const escapedColumns = columns.map((column) => `\`${column}\``).join(', '); + const escapedColumns = columns.map(escapeSqlIdentifier).join(', '); const placeholder = `(${columns.map(() => '?').join(',')})`; - const query = `INSERT ${priority} ${ignore} INTO \`${table}\` (${escapedColumns}) VALUES ${placeholder};`; + const query = `INSERT ${priority} ${ignore} INTO ${escapeSqlIdentifier( + table, + )} (${escapedColumns}) VALUES ${placeholder};`; const values = Object.values(insertItem) as QueryValues; diff --git a/packages/nodes-base/nodes/MySql/v2/actions/database/select.operation.ts b/packages/nodes-base/nodes/MySql/v2/actions/database/select.operation.ts index 9666a409ef958..7b16574f7510f 100644 --- a/packages/nodes-base/nodes/MySql/v2/actions/database/select.operation.ts +++ b/packages/nodes-base/nodes/MySql/v2/actions/database/select.operation.ts @@ -13,7 +13,7 @@ import type { WhereClause, } from '../../helpers/interfaces'; -import { addSortRules, addWhereClauses } from '../../helpers/utils'; +import { addSortRules, addWhereClauses, escapeSqlIdentifier } from '../../helpers/utils'; import { optionsCollection, @@ -91,10 +91,10 @@ export async function execute( const SELECT = selectDistinct ? 'SELECT DISTINCT' : 'SELECT'; if (outputColumns.includes('*')) { - query = `${SELECT} * FROM \`${table}\``; + query = `${SELECT} * FROM ${escapeSqlIdentifier(table)}`; } else { - const escapedColumns = outputColumns.map((column) => `\`${column}\``).join(', '); - query = `${SELECT} ${escapedColumns} FROM \`${table}\``; + const escapedColumns = outputColumns.map(escapeSqlIdentifier).join(', '); + query = `${SELECT} ${escapedColumns} FROM ${escapeSqlIdentifier(table)}`; } let values: QueryValues = []; diff --git a/packages/nodes-base/nodes/MySql/v2/actions/database/update.operation.ts b/packages/nodes-base/nodes/MySql/v2/actions/database/update.operation.ts index a27466680af2a..22bc332548f4a 100644 --- a/packages/nodes-base/nodes/MySql/v2/actions/database/update.operation.ts +++ b/packages/nodes-base/nodes/MySql/v2/actions/database/update.operation.ts @@ -8,7 +8,7 @@ import type { import type { QueryRunner, QueryValues, QueryWithValues } from '../../helpers/interfaces'; import { AUTO_MAP, DATA_MODE } from '../../helpers/interfaces'; -import { replaceEmptyStringsByNulls } from '../../helpers/utils'; +import { escapeSqlIdentifier, replaceEmptyStringsByNulls } from '../../helpers/utils'; import { optionsCollection } from '../common.descriptions'; import { updateDisplayOptions } from '@utils/utilities'; @@ -182,14 +182,16 @@ export async function execute( const updates: string[] = []; for (const column of updateColumns) { - updates.push(`\`${column}\` = ?`); + updates.push(`${escapeSqlIdentifier(column)} = ?`); values.push(item[column] as string); } - const condition = `\`${columnToMatchOn}\` = ?`; + const condition = `${escapeSqlIdentifier(columnToMatchOn)} = ?`; values.push(valueToMatchOn); - const query = `UPDATE \`${table}\` SET ${updates.join(', ')} WHERE ${condition}`; + const query = `UPDATE ${escapeSqlIdentifier(table)} SET ${updates.join( + ', ', + )} WHERE ${condition}`; queries.push({ query, values }); } diff --git a/packages/nodes-base/nodes/MySql/v2/actions/database/upsert.operation.ts b/packages/nodes-base/nodes/MySql/v2/actions/database/upsert.operation.ts index 73ccc0f14d0c8..eccc36c0feca8 100644 --- a/packages/nodes-base/nodes/MySql/v2/actions/database/upsert.operation.ts +++ b/packages/nodes-base/nodes/MySql/v2/actions/database/upsert.operation.ts @@ -8,7 +8,7 @@ import type { import type { QueryRunner, QueryValues, QueryWithValues } from '../../helpers/interfaces'; import { AUTO_MAP, DATA_MODE } from '../../helpers/interfaces'; -import { replaceEmptyStringsByNulls } from '../../helpers/utils'; +import { escapeSqlIdentifier, replaceEmptyStringsByNulls } from '../../helpers/utils'; import { optionsCollection } from '../common.descriptions'; import { updateDisplayOptions } from '@utils/utilities'; @@ -177,10 +177,12 @@ export async function execute( const onConflict = 'ON DUPLICATE KEY UPDATE'; const columns = Object.keys(item); - const escapedColumns = columns.map((column) => `\`${column}\``).join(', '); + const escapedColumns = columns.map(escapeSqlIdentifier).join(', '); const placeholder = `${columns.map(() => '?').join(',')}`; - const insertQuery = `INSERT INTO \`${table}\`(${escapedColumns}) VALUES(${placeholder})`; + const insertQuery = `INSERT INTO ${escapeSqlIdentifier( + table, + )}(${escapedColumns}) VALUES(${placeholder})`; const values = Object.values(item) as QueryValues; @@ -189,7 +191,7 @@ export async function execute( const updates: string[] = []; for (const column of updateColumns) { - updates.push(`\`${column}\` = ?`); + updates.push(`${escapeSqlIdentifier(column)} = ?`); values.push(item[column] as string); } diff --git a/packages/nodes-base/nodes/MySql/v2/helpers/utils.ts b/packages/nodes-base/nodes/MySql/v2/helpers/utils.ts index b7ab739b4af2d..4ac1f9b3d5f3a 100644 --- a/packages/nodes-base/nodes/MySql/v2/helpers/utils.ts +++ b/packages/nodes-base/nodes/MySql/v2/helpers/utils.ts @@ -21,6 +21,22 @@ import type { import { BATCH_MODE } from './interfaces'; +export function escapeSqlIdentifier(identifier: string): string { + const parts = identifier.match(/(`[^`]*`|[^.`]+)/g) ?? []; + + return parts + .map((part) => { + const trimmedPart = part.trim(); + + if (trimmedPart.startsWith('`') && trimmedPart.endsWith('`')) { + return trimmedPart; + } + + return `\`${trimmedPart}\``; + }) + .join('.'); +} + export const prepareQueryAndReplacements = (rawQuery: string, replacements?: QueryValues) => { if (replacements === undefined) { return { query: rawQuery, values: [] }; @@ -35,7 +51,7 @@ export const prepareQueryAndReplacements = (rawQuery: string, replacements?: Que for (const match of matches) { if (match.includes(':name')) { const matchIndex = Number(match.replace('$', '').replace(':name', '')) - 1; - query = query.replace(match, `\`${replacements[matchIndex]}\``); + query = query.replace(match, escapeSqlIdentifier(replacements[matchIndex].toString())); } else { const matchIndex = Number(match.replace('$', '')) - 1; query = query.replace(match, '?'); @@ -379,7 +395,9 @@ export function addWhereClauses( const operator = index === clauses.length - 1 ? '' : ` ${combineWith}`; - whereQuery += ` \`${clause.column}\` ${clause.condition}${valueReplacement}${operator}`; + whereQuery += ` ${escapeSqlIdentifier(clause.column)} ${ + clause.condition + }${valueReplacement}${operator}`; }); return [`${query}${whereQuery}`, replacements.concat(...values)]; @@ -398,7 +416,7 @@ export function addSortRules( rules.forEach((rule, index) => { const endWith = index === rules.length - 1 ? '' : ','; - orderByQuery += ` \`${rule.column}\` ${rule.direction}${endWith}`; + orderByQuery += ` ${escapeSqlIdentifier(rule.column)} ${rule.direction}${endWith}`; }); return [`${query}${orderByQuery}`, replacements.concat(...values)]; diff --git a/packages/nodes-base/nodes/MySql/v2/methods/loadOptions.ts b/packages/nodes-base/nodes/MySql/v2/methods/loadOptions.ts index c89f958a02471..7576d7c85cdc2 100644 --- a/packages/nodes-base/nodes/MySql/v2/methods/loadOptions.ts +++ b/packages/nodes-base/nodes/MySql/v2/methods/loadOptions.ts @@ -1,6 +1,7 @@ import type { IDataObject, ILoadOptionsFunctions, INodePropertyOptions } from 'n8n-workflow'; import { Client } from 'ssh2'; import { createPool } from '../transport'; +import { escapeSqlIdentifier } from '../helpers/utils'; export async function getColumns(this: ILoadOptionsFunctions): Promise { const credentials = await this.getCredentials('mySql'); @@ -22,7 +23,9 @@ export async function getColumns(this: ILoadOptionsFunctions): Promise