@mlagie/sql-connector 2.0.2 → 2.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/releases/2.0.3.md +20 -0
- package/src/models/Model.js +25 -33
- package/src/models/ModelInstance.js +8 -8
- package/src/utils/buildQuery.js +16 -11
- package/src/utils/generateCondition.js +14 -11
- package/src/utils/sql.js +44 -0
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mlagie/sql-connector",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.3",
|
|
4
4
|
"description": "Le module sql-connector permet de gérer les connexions à une base de données MySQL, de définir des schémas de tables, et d'interagir avec les données de manière simple et efficace.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"security": "npx eslint . --max-warnings 0"
|
|
7
|
+
"security": "npx eslint . --max-warnings 0",
|
|
8
|
+
"security:socket": "npx socket scan create --auto-manifest --report ."
|
|
8
9
|
},
|
|
9
10
|
"repository": {
|
|
10
11
|
"type": "git",
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# 🛡️ Release v2.0.3 — Security Overhaul: Prepared Statements (`.execute`)
|
|
2
|
+
|
|
3
|
+
This major patch introduces a complete migration from raw `.query()` to parameterized Prepared Statements (`.execute()`) for all data-driven database operations, offering definitive protection against SQL Injection.
|
|
4
|
+
|
|
5
|
+
## What Was Improved
|
|
6
|
+
|
|
7
|
+
* **Prepared Statements Migration:** Replaced `.query()` with `.execute()` in critical data-handling pipelines (`save`, `generate_uuid`, `updateOne`, `deleteOne`).
|
|
8
|
+
* **Native Value Escaping:** Transitioned from manual string manipulation/escaping functions to the database driver's native binary protocol placeholder mechanism (`?`).
|
|
9
|
+
* **Bulletproof Security:** User inputs are now strictly bound as parameters, ensuring they are never interpreted as executable SQL commands by the database server.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Component Impact
|
|
14
|
+
|
|
15
|
+
| Impacted Area | Description | Status |
|
|
16
|
+
| :--- | :--- | :--- |
|
|
17
|
+
| **Model.js** | Upgraded `save` and `generate_uuid` to use native placeholder bindings | **Secured** |
|
|
18
|
+
| **ModelInstance.js** | Rewritten `updateOne`, `delete`, and `deleteOne` to prevent unsafe raw query concatenation | **Secured** |
|
|
19
|
+
|
|
20
|
+
*Upgrading is highly recommended for all environments handling user-supplied data.* `npm update @mlagie/sql-connector`
|
package/src/models/Model.js
CHANGED
|
@@ -7,6 +7,7 @@ const { ModelInstance } = require("./ModelInstance");
|
|
|
7
7
|
const { buildSelect, buildQueryParts } = require("../utils/buildQuery");
|
|
8
8
|
const util = require("util");
|
|
9
9
|
const { getSafe, setSafe } = require("../utils/security/safe");
|
|
10
|
+
const { escapeIdentifier, escapeIdentifierList } = require("../utils/sql");
|
|
10
11
|
|
|
11
12
|
function getFieldType(field) {
|
|
12
13
|
if (typeof field === "object") {
|
|
@@ -54,15 +55,6 @@ function formatDefaultSql(defaultValue, fieldType) {
|
|
|
54
55
|
return `DEFAULT "${String(defaultValue).replace(/"/g, '\\"')}"`;
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
function generateValueSQL(value) {
|
|
58
|
-
return value.map(item => {
|
|
59
|
-
if (item === null) return 'NULL';
|
|
60
|
-
if (typeof item === "string") return `"${item.replace(/"/g, '\\"')}"`;
|
|
61
|
-
if (typeof item === "object" && item !== null) return `"${item}"`;
|
|
62
|
-
return item;
|
|
63
|
-
}).join(", ");
|
|
64
|
-
}
|
|
65
|
-
|
|
66
58
|
const reservedKeywords = ['ADD', 'ALL', 'ALTER', 'AND', 'AS', 'ASC', 'BETWEEN', 'BY', 'CASE', 'CHECK', 'COLUMN', 'CONSTRAINT', 'CREATE', 'CURRENT_DATE', 'CURRENT_TIME', 'CURRENT_TIMESTAMP', 'DEFAULT', 'DELETE', 'DESC', 'DISTINCT', 'DROP', 'ELSE', 'END', 'ESCAPE', 'EXCEPT', 'EXISTS', 'FOR', 'FOREIGN', 'FROM', 'FULL', 'GROUP', 'HAVING', 'IN', 'INNER', 'INSERT', 'INTERSECT', 'INTO', 'IS', 'JOIN', 'LEFT', 'LIKE', 'LIMIT', 'NOT', 'NULL', 'ON', 'OR', 'ORDER', 'OUTER', 'PRIMARY', 'REFERENCES', 'RIGHT', 'SELECT', 'SET', 'SOME', 'TABLE', 'THEN', 'UNION', 'UNIQUE', 'UPDATE', 'VALUES', 'WHEN', 'WHERE'];
|
|
67
59
|
|
|
68
60
|
/**
|
|
@@ -109,7 +101,7 @@ function getColumnDefinition(fieldName, field) {
|
|
|
109
101
|
if (field.auto_increment) colDef += ' AUTO_INCREMENT';
|
|
110
102
|
if (field.primary_key) colDef += ' PRIMARY KEY';
|
|
111
103
|
if (typeof field.customize === 'string' && field.customize.length != 0) colDef += ` ${field.customize}`;
|
|
112
|
-
return `${fieldName} ${colDef}`;
|
|
104
|
+
return `${escapeIdentifier(fieldName)} ${colDef}`;
|
|
113
105
|
}
|
|
114
106
|
|
|
115
107
|
/**
|
|
@@ -174,7 +166,7 @@ class Model {
|
|
|
174
166
|
const model = getSafe(modelMap, table);
|
|
175
167
|
|
|
176
168
|
try {
|
|
177
|
-
await conn.promise().
|
|
169
|
+
await conn.promise().execute(model.generateCreateTableStatement(model.schema.schemaDict));
|
|
178
170
|
await logs(`The table ${model.name} has been created or already exists`);
|
|
179
171
|
} catch (err) {
|
|
180
172
|
error(`Error creating table: ${err} with table name: ${model.name}`);
|
|
@@ -218,7 +210,7 @@ class Model {
|
|
|
218
210
|
error("Error: Invalid table name. Please choose a different name that is not a reserved keyword in SQL_request");
|
|
219
211
|
return;
|
|
220
212
|
}
|
|
221
|
-
return `CREATE TABLE IF NOT EXISTS ${this.name} (${columns.join(', ')}${foreignKey.length > 0 ? ", " + foreignKey.join(', ') : ""}) ENGINE=InnoDB`;
|
|
213
|
+
return `CREATE TABLE IF NOT EXISTS ${escapeIdentifier(this.name)} (${columns.join(', ')}${foreignKey.length > 0 ? ", " + foreignKey.join(', ') : ""}) ENGINE=InnoDB`;
|
|
222
214
|
}
|
|
223
215
|
|
|
224
216
|
getRecordData() {
|
|
@@ -241,10 +233,10 @@ class Model {
|
|
|
241
233
|
*/
|
|
242
234
|
async save(data) {
|
|
243
235
|
const keys = Object.keys(data);
|
|
244
|
-
const sql_request = `INSERT INTO ${this.name} (${keys
|
|
236
|
+
const sql_request = `INSERT INTO ${escapeIdentifier(this.name)} (${escapeIdentifierList(keys)}) VALUES (${keys.map(() => "?").join(", ")})`;
|
|
245
237
|
|
|
246
238
|
try {
|
|
247
|
-
const result = await getConnexion().promise().
|
|
239
|
+
const result = await getConnexion().promise().execute(sql_request, Object.values(data));
|
|
248
240
|
return result[0];
|
|
249
241
|
} catch (err) {
|
|
250
242
|
error(`Error inserting data into ${this.name}: ${err}`);
|
|
@@ -281,9 +273,9 @@ class Model {
|
|
|
281
273
|
if (typeof item === 'string') {
|
|
282
274
|
if (!item.includes('.')) {
|
|
283
275
|
if (item.startsWith('name')) {
|
|
284
|
-
return `${join.table}.${item}`;
|
|
276
|
+
return `${escapeIdentifier(join.table)}.${escapeIdentifier(item)}`;
|
|
285
277
|
}
|
|
286
|
-
return `${this.name}.${item}`;
|
|
278
|
+
return `${escapeIdentifier(this.name)}.${escapeIdentifier(item)}`;
|
|
287
279
|
}
|
|
288
280
|
}
|
|
289
281
|
else if (typeof item === 'object') {
|
|
@@ -292,13 +284,13 @@ class Model {
|
|
|
292
284
|
if (Array.isArray(getSafe(item, key))) {
|
|
293
285
|
setSafe(item, key, getSafe(item, key).map((param, index) => {
|
|
294
286
|
if (index === 0 && typeof param === 'string' && !param.includes('.')) {
|
|
295
|
-
return `${this.name}.${param}`;
|
|
287
|
+
return `${escapeIdentifier(this.name)}.${escapeIdentifier(param)}`;
|
|
296
288
|
}
|
|
297
289
|
return param;
|
|
298
290
|
}));
|
|
299
291
|
}
|
|
300
292
|
else if (typeof getSafe(item, key) === 'string' && !getSafe(item, key).includes('.')) {
|
|
301
|
-
setSafe(item, key, `${this.name}.${getSafe(item, key)}`);
|
|
293
|
+
setSafe(item, key, `${escapeIdentifier(this.name)}.${escapeIdentifier(getSafe(item, key))}`);
|
|
302
294
|
}
|
|
303
295
|
}
|
|
304
296
|
return item;
|
|
@@ -306,13 +298,13 @@ class Model {
|
|
|
306
298
|
}
|
|
307
299
|
let joinClause = "";
|
|
308
300
|
if (join && join.table && join.on) {
|
|
309
|
-
joinClause = ` INNER JOIN ${join.table} ON ${join.on}`;
|
|
301
|
+
joinClause = ` INNER JOIN ${escapeIdentifier(join.table)} ON ${join.on}`;
|
|
310
302
|
}
|
|
311
303
|
|
|
312
|
-
const query = `SELECT ${buildSelect(select)} FROM ${this.name}${joinClause} ${buildQueryParts(options)}`;
|
|
304
|
+
const query = `SELECT ${buildSelect(select)} FROM ${escapeIdentifier(this.name)}${joinClause} ${buildQueryParts(options)}`;
|
|
313
305
|
|
|
314
306
|
try {
|
|
315
|
-
const result = await getConnexion().promise().
|
|
307
|
+
const result = await getConnexion().promise().execute(query);
|
|
316
308
|
const rows = result && Array.isArray(result) ? result[0] : result;
|
|
317
309
|
|
|
318
310
|
if (!rows || rows.length === 0) return [];
|
|
@@ -330,7 +322,7 @@ class Model {
|
|
|
330
322
|
* @returns {Promise<ModelInstance|number>} - A promise that resolves to a `ModelInstance` if a record is found, or `0` if no records match the filter.
|
|
331
323
|
*/
|
|
332
324
|
async count(filter) {
|
|
333
|
-
return this.customRequest(`SELECT COUNT(*) as count FROM ${this.name} ${filter != undefined ? `WHERE ${generateCondition(formatObject(filter))}` : ""}`, "count");
|
|
325
|
+
return this.customRequest(`SELECT COUNT(*) as count FROM ${escapeIdentifier(this.name)} ${filter != undefined ? `WHERE ${generateCondition(formatObject(filter))}` : ""}`, "count");
|
|
334
326
|
}
|
|
335
327
|
|
|
336
328
|
/**
|
|
@@ -341,11 +333,11 @@ class Model {
|
|
|
341
333
|
*/
|
|
342
334
|
async customRequest(custom, custom_err_name = "") {
|
|
343
335
|
try {
|
|
344
|
-
const rows = await getConnexion().promise().
|
|
336
|
+
const rows = await getConnexion().promise().execute(custom);
|
|
345
337
|
|
|
346
|
-
if (rows.length == 0) return 0;
|
|
338
|
+
if (rows[0].length == 0) return 0;
|
|
347
339
|
|
|
348
|
-
return new ModelInstance(this.name, rows, this.schema);
|
|
340
|
+
return new ModelInstance(this.name, rows[0], this.schema);
|
|
349
341
|
} catch (err) {
|
|
350
342
|
error(`Error executing query ${custom_err_name}: ${err}`);
|
|
351
343
|
throw err;
|
|
@@ -361,10 +353,10 @@ class Model {
|
|
|
361
353
|
* @throws {Error} Throws an error if the SQL query fails.
|
|
362
354
|
*/
|
|
363
355
|
async delete(filter) {
|
|
364
|
-
const sql_request = `DELETE FROM ${this.name} WHERE ${generateCondition(formatObject(filter))}`;
|
|
356
|
+
const sql_request = `DELETE FROM ${escapeIdentifier(this.name)} WHERE ${generateCondition(formatObject(filter))}`;
|
|
365
357
|
return new Promise((resolve, reject) => {
|
|
366
|
-
getConnexion().promise().
|
|
367
|
-
if (rows[
|
|
358
|
+
getConnexion().promise().execute(sql_request).then((rows) => {
|
|
359
|
+
if (rows[0].affectedRows === 0) return resolve(0);
|
|
368
360
|
|
|
369
361
|
return resolve(1);
|
|
370
362
|
}).catch((err) => {
|
|
@@ -385,10 +377,10 @@ class Model {
|
|
|
385
377
|
* @returns {Promise<void>} A promise that resolves when the query execution is complete.
|
|
386
378
|
*/
|
|
387
379
|
async dropTable() {
|
|
388
|
-
const sql_request = `DROP TABLE IF EXISTS ${this.name};`;
|
|
380
|
+
const sql_request = `DROP TABLE IF EXISTS ${escapeIdentifier(this.name)};`;
|
|
389
381
|
|
|
390
382
|
return new Promise((resolve, reject) => {
|
|
391
|
-
getConnexion().promise().
|
|
383
|
+
getConnexion().promise().execute(sql_request).then((rows) => {
|
|
392
384
|
console.log(rows);
|
|
393
385
|
}).catch((err) => {
|
|
394
386
|
error(`Error executing query drop: ${err}`);
|
|
@@ -417,11 +409,11 @@ class Model {
|
|
|
417
409
|
* @throws {Error} If there is an error executing the SQL_request query.
|
|
418
410
|
*/
|
|
419
411
|
async generate_uuid(var_uuid = "uuid") {
|
|
420
|
-
const uuid = (await getConnexion().promise().
|
|
421
|
-
const sql_request = `SELECT COUNT(*) FROM ${this.name} WHERE ${var_uuid}=
|
|
412
|
+
const uuid = (await getConnexion().promise().execute("SELECT UUID();"))[0][0]["UUID()"];
|
|
413
|
+
const sql_request = `SELECT COUNT(*) FROM ${escapeIdentifier(this.name)} WHERE ${escapeIdentifier(var_uuid)} = ?;`;
|
|
422
414
|
|
|
423
415
|
return new Promise((resolve, reject) => {
|
|
424
|
-
getConnexion().promise().
|
|
416
|
+
getConnexion().promise().execute(sql_request, [uuid]).then((rows) => {
|
|
425
417
|
if (rows[0][0]['COUNT(*)'] == 0) return resolve(uuid);
|
|
426
418
|
resolve(null);
|
|
427
419
|
}).catch((err) => {
|
|
@@ -126,7 +126,7 @@ class ModelInstance {
|
|
|
126
126
|
|
|
127
127
|
const sql_request = `UPDATE ${this.name} SET ${setClause} WHERE ${whereClause}`;
|
|
128
128
|
|
|
129
|
-
const [result] = await getConnexion().promise().
|
|
129
|
+
const [result] = await getConnexion().promise().execute(sql_request).catch((err) => {
|
|
130
130
|
error(`Error executing query updateOne: ${err}`);
|
|
131
131
|
throw err;
|
|
132
132
|
});
|
|
@@ -154,12 +154,12 @@ class ModelInstance {
|
|
|
154
154
|
async delete(filter) {
|
|
155
155
|
const sql_request = `DELETE FROM ${this.name} WHERE ${generateCondition(formatObject(filter))}`;
|
|
156
156
|
|
|
157
|
-
const rows = await getConnexion().promise().
|
|
157
|
+
const rows = await getConnexion().promise().execute(sql_request).catch((err) => {
|
|
158
158
|
error(`Error executing query delete: ${err}`);
|
|
159
159
|
throw err;
|
|
160
160
|
});
|
|
161
161
|
|
|
162
|
-
return rows[
|
|
162
|
+
return rows[0].affectedRows === 0 ? 0 : 1;
|
|
163
163
|
}
|
|
164
164
|
|
|
165
165
|
/**
|
|
@@ -170,12 +170,12 @@ class ModelInstance {
|
|
|
170
170
|
async deleteOne() {
|
|
171
171
|
const sql_request = `DELETE FROM ${this.name} WHERE ${generateCondition(formatObject(this.getRecordData()))}`;
|
|
172
172
|
|
|
173
|
-
const rows = await getConnexion().promise().
|
|
173
|
+
const rows = await getConnexion().promise().execute(sql_request).catch((err) => {
|
|
174
174
|
error(`Error executing query deleteOne: ${err}`);
|
|
175
175
|
throw err;
|
|
176
176
|
});
|
|
177
177
|
|
|
178
|
-
return rows[
|
|
178
|
+
return rows[0].affectedRows === 0 ? 0 : 1;
|
|
179
179
|
}
|
|
180
180
|
|
|
181
181
|
/**
|
|
@@ -185,14 +185,14 @@ class ModelInstance {
|
|
|
185
185
|
* @throws {Error} Throws an error if query execution fails.
|
|
186
186
|
*/
|
|
187
187
|
async customRequest(custom) {
|
|
188
|
-
const rows = await getConnexion().promise().
|
|
188
|
+
const rows = await getConnexion().promise().execute(custom).catch((err) => {
|
|
189
189
|
error(`Error executing query: ${err}`);
|
|
190
190
|
throw err;
|
|
191
191
|
});
|
|
192
192
|
|
|
193
|
-
if (rows.length == 0) return 0;
|
|
193
|
+
if (rows[0].length == 0) return 0;
|
|
194
194
|
|
|
195
|
-
return new ModelInstance(this.name, rows, this.schema).data;
|
|
195
|
+
return new ModelInstance(this.name, rows[0], this.schema).data;
|
|
196
196
|
}
|
|
197
197
|
}
|
|
198
198
|
|
package/src/utils/buildQuery.js
CHANGED
|
@@ -1,26 +1,28 @@
|
|
|
1
1
|
const formatObject = require("./formatObject");
|
|
2
2
|
const generateCondition = require("./generateCondition");
|
|
3
|
+
const { escapeIdentifier, escapeOrderDirection, escapeValue } = require("./sql");
|
|
3
4
|
|
|
4
5
|
function buildField(field) {
|
|
5
6
|
if (typeof field === 'string') {
|
|
6
|
-
return
|
|
7
|
+
if (field === "*") return "*";
|
|
8
|
+
return escapeIdentifier(field);
|
|
7
9
|
}
|
|
8
10
|
|
|
9
11
|
let sql = '';
|
|
10
12
|
|
|
11
13
|
if (field.sum)
|
|
12
|
-
sql = `SUM(${field.sum})`;
|
|
14
|
+
sql = `SUM(${escapeIdentifier(field.sum)})`;
|
|
13
15
|
else if (field.dateFormat) {
|
|
14
16
|
const [col, format] = field.dateFormat;
|
|
15
|
-
sql = `DATE_FORMAT(${col},
|
|
17
|
+
sql = `DATE_FORMAT(${escapeIdentifier(col)}, ${escapeValue(format)})`;
|
|
16
18
|
}
|
|
17
19
|
else if (field.col)
|
|
18
|
-
sql = field.col;
|
|
20
|
+
sql = escapeIdentifier(field.col);
|
|
19
21
|
|
|
20
22
|
if (field.as)
|
|
21
|
-
sql += ` AS ${field.as}`;
|
|
23
|
+
sql += ` AS ${escapeIdentifier(field.as)}`;
|
|
22
24
|
else if (field.sum)
|
|
23
|
-
sql += ` AS ${field.sum}`;
|
|
25
|
+
sql += ` AS ${escapeIdentifier(field.sum)}`;
|
|
24
26
|
return sql;
|
|
25
27
|
}
|
|
26
28
|
|
|
@@ -39,30 +41,33 @@ function buildQueryParts(options) {
|
|
|
39
41
|
|
|
40
42
|
if (options.where) {
|
|
41
43
|
if (typeof options.where === 'string') {
|
|
42
|
-
|
|
44
|
+
throw new Error("Raw string WHERE clauses are not allowed. Pass an object filter instead.");
|
|
43
45
|
} else {
|
|
44
46
|
parts.push(`WHERE ${generateCondition(formatObject(options.where))}`);
|
|
45
47
|
}
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
if (options.groupBy) {
|
|
49
|
-
parts.push(`GROUP BY ${options.groupBy.join(', ')}`);
|
|
51
|
+
parts.push(`GROUP BY ${options.groupBy.map(group => escapeIdentifier(group)).join(', ')}`);
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
if (options.having) {
|
|
53
|
-
|
|
55
|
+
throw new Error("Raw string HAVING clauses are not allowed. Use a structured filter instead.");
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
if (options.orderBy) {
|
|
57
59
|
const order = options.orderBy.map(o =>
|
|
58
60
|
typeof o === 'string'
|
|
59
|
-
? o
|
|
60
|
-
: `${o.field} ${o.direction || 'ASC'}`
|
|
61
|
+
? escapeIdentifier(o)
|
|
62
|
+
: `${escapeIdentifier(o.field)} ${escapeOrderDirection(o.direction || 'ASC')}`
|
|
61
63
|
);
|
|
62
64
|
parts.push(`ORDER BY ${order.join(', ')}`);
|
|
63
65
|
}
|
|
64
66
|
|
|
65
67
|
if (options.limit) {
|
|
68
|
+
if (!Number.isInteger(options.limit) || options.limit < 0) {
|
|
69
|
+
throw new Error("Invalid LIMIT value");
|
|
70
|
+
}
|
|
66
71
|
parts.push(`LIMIT ${options.limit}`);
|
|
67
72
|
}
|
|
68
73
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { getSafe } = require("./security/safe");
|
|
2
|
+
const { escapeIdentifier, escapeValue } = require("./sql");
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Generates an SQL_request condition from a filter object.
|
|
@@ -32,6 +33,7 @@ module.exports = function (filter, isUpdate = false, schema = null) {
|
|
|
32
33
|
if (uniqueKeys.length > 0) {
|
|
33
34
|
return uniqueKeys.map(key => {
|
|
34
35
|
let value = getSafe(filter, key);
|
|
36
|
+
const escapedKey = escapeIdentifier(key);
|
|
35
37
|
// normalize strings that may contain surrounding quotes or escaped quotes
|
|
36
38
|
if (typeof value === 'string') {
|
|
37
39
|
value = value.trim();
|
|
@@ -41,19 +43,19 @@ module.exports = function (filter, isUpdate = false, schema = null) {
|
|
|
41
43
|
value = value.replace(/\\"/g, '"').replace(/\\'/g, "'");
|
|
42
44
|
}
|
|
43
45
|
if (Array.isArray(value)) {
|
|
44
|
-
return `${
|
|
46
|
+
return `${escapedKey} IN (${value.map(v => escapeValue(v)).join(", ")})`;
|
|
45
47
|
}
|
|
46
48
|
if (typeof value === "object" || (typeof value === "string" && value.trim().startsWith("{") && value.trim().endsWith("}"))) {
|
|
47
49
|
const jsonVal = typeof value === "string" ? value : JSON.stringify(value);
|
|
48
|
-
return `JSON_CONTAINS(${
|
|
50
|
+
return `JSON_CONTAINS(${escapedKey}, ${escapeValue(jsonVal)})`;
|
|
49
51
|
}
|
|
50
|
-
if (value === null || value === "null") return `${
|
|
52
|
+
if (value === null || value === "null") return `${escapedKey} IS NULL`;
|
|
51
53
|
// if string looks like an ISO datetime, convert to MySQL DATETIME format
|
|
52
54
|
if (typeof value === 'string' && /T/.test(value)) {
|
|
53
55
|
let val = value.replace(/\.\d+Z$/,'').replace(/Z$/,'').replace('T',' ');
|
|
54
|
-
return `${
|
|
56
|
+
return `${escapedKey} = ${escapeValue(val)}`;
|
|
55
57
|
}
|
|
56
|
-
return `${
|
|
58
|
+
return `${escapedKey} = ${escapeValue(value)}`;
|
|
57
59
|
}).join(" AND ");
|
|
58
60
|
}
|
|
59
61
|
}
|
|
@@ -61,6 +63,7 @@ module.exports = function (filter, isUpdate = false, schema = null) {
|
|
|
61
63
|
// Comportement par défaut
|
|
62
64
|
const conditions = filteredKeys.map((key, index) => {
|
|
63
65
|
let value = getSafe(filteredValues, index);
|
|
66
|
+
const escapedKey = escapeIdentifier(key);
|
|
64
67
|
|
|
65
68
|
if (typeof value === 'string') {
|
|
66
69
|
value = value.trim();
|
|
@@ -71,17 +74,17 @@ module.exports = function (filter, isUpdate = false, schema = null) {
|
|
|
71
74
|
}
|
|
72
75
|
|
|
73
76
|
if (Array.isArray(value)) {
|
|
74
|
-
return `${
|
|
77
|
+
return `${escapedKey} IN (${value.map(v => escapeValue(v)).join(", ")})`;
|
|
75
78
|
}
|
|
76
79
|
if (typeof value === "object" || (typeof value === "string" && value.trim().startsWith("{") && value.trim().endsWith("}"))) {
|
|
77
80
|
const jsonVal = typeof value === "string" ? value : JSON.stringify(value);
|
|
78
81
|
if (isUpdate) {
|
|
79
|
-
return `${
|
|
82
|
+
return `${escapedKey} = ${escapeValue(jsonVal)}`;
|
|
80
83
|
}
|
|
81
|
-
return `JSON_CONTAINS(${
|
|
84
|
+
return `JSON_CONTAINS(${escapedKey}, ${escapeValue(jsonVal)})`;
|
|
82
85
|
}
|
|
83
86
|
|
|
84
|
-
if ((value === null || value === "null") && isUpdate == false) return `${
|
|
87
|
+
if ((value === null || value === "null") && isUpdate == false) return `${escapedKey} IS NULL`;
|
|
85
88
|
|
|
86
89
|
// handle date-like strings when schema tells us the field is temporal
|
|
87
90
|
const fieldDef = schema && schema.schemaDict ? getSafe(schema.schemaDict, key) : null;
|
|
@@ -102,9 +105,9 @@ module.exports = function (filter, isUpdate = false, schema = null) {
|
|
|
102
105
|
if (isDateLike && /T/.test(val)) {
|
|
103
106
|
val = val.replace(/\.\d+Z$/,'').replace(/Z$/,'').replace('T',' ');
|
|
104
107
|
}
|
|
105
|
-
return `${
|
|
108
|
+
return `${escapedKey} = ${escapeValue(val)}`;
|
|
106
109
|
}
|
|
107
|
-
return `${
|
|
110
|
+
return `${escapedKey} = ${escapeValue(value)}`;
|
|
108
111
|
}).join(` ${isUpdate == false ? "AND" : ","} `);
|
|
109
112
|
return conditions;
|
|
110
113
|
}
|
package/src/utils/sql.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const { escape, escapeId } = require("mysql2");
|
|
2
|
+
|
|
3
|
+
const SAFE_IDENTIFIER = /^[A-Za-z0-9_]+$/;
|
|
4
|
+
|
|
5
|
+
function escapeIdentifier(identifier) {
|
|
6
|
+
if (identifier === "*") return "*";
|
|
7
|
+
|
|
8
|
+
if (typeof identifier !== "string" || identifier.length === 0) {
|
|
9
|
+
throw new Error(`Invalid SQL identifier: ${identifier}`);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return identifier.split(".").map(part => {
|
|
13
|
+
if (part === "*") return "*";
|
|
14
|
+
if (!SAFE_IDENTIFIER.test(part)) {
|
|
15
|
+
throw new Error(`Invalid SQL identifier: ${identifier}`);
|
|
16
|
+
}
|
|
17
|
+
return escapeId(part);
|
|
18
|
+
}).join(".");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function escapeIdentifierList(identifiers) {
|
|
22
|
+
return identifiers.map(identifier => escapeIdentifier(identifier)).join(", ");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function escapeValue(value) {
|
|
26
|
+
return escape(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function escapeOrderDirection(direction) {
|
|
30
|
+
const normalized = String(direction ?? "ASC").toUpperCase();
|
|
31
|
+
|
|
32
|
+
if (normalized !== "ASC" && normalized !== "DESC") {
|
|
33
|
+
throw new Error(`Invalid SQL sort direction: ${direction}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return normalized;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = {
|
|
40
|
+
escapeIdentifier,
|
|
41
|
+
escapeIdentifierList,
|
|
42
|
+
escapeOrderDirection,
|
|
43
|
+
escapeValue
|
|
44
|
+
};
|