outlet-orm 4.2.1 → 5.5.1

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.
@@ -9,23 +9,27 @@ let sqlite3;
9
9
  // Query log storage
10
10
  let queryLog = [];
11
11
  let queryLoggingEnabled = false;
12
+ // Maximum number of entries retained in the query log to prevent unbounded memory growth
13
+ const MAX_QUERY_LOG_SIZE = 1000;
14
+
15
+ const RawExpression = require('./RawExpression');
12
16
 
13
17
  /**
14
18
  * Sanitize SQL identifier (table/column name) to prevent SQL injection
15
- * @param {string} identifier
19
+ * @param {string|RawExpression} identifier
16
20
  * @returns {string}
17
21
  */
18
22
  function sanitizeIdentifier(identifier) {
23
+ if (identifier instanceof RawExpression) {
24
+ return identifier.value;
25
+ }
19
26
  if (!identifier || typeof identifier !== 'string') {
20
27
  throw new Error('Invalid SQL identifier');
21
28
  }
22
- // Allow only alphanumeric, underscore, dot (for table.column)
29
+ // Strict allowlist: only alphanumeric, underscore, dot (for table.column)
30
+ // Any identifier not matching this pattern is rejected outright — no fallback.
23
31
  if (!/^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?$/.test(identifier)) {
24
- // Check for common SQL injection patterns
25
- // Note: Escape hyphen in character class to avoid range interpretation
26
- if (/['";]|--|\/*|\*\/|xp_|sp_|0x/i.test(identifier)) {
27
- throw new Error(`Potentially dangerous SQL identifier: ${identifier}`);
28
- }
32
+ throw new Error('Invalid SQL identifier');
29
33
  }
30
34
  return identifier;
31
35
  }
@@ -44,6 +48,10 @@ function logQuery(sql, params, duration) {
44
48
  duration,
45
49
  timestamp: new Date()
46
50
  });
51
+ // Enforce size cap to prevent unbounded memory growth
52
+ if (queryLog.length > MAX_QUERY_LOG_SIZE) {
53
+ queryLog.shift();
54
+ }
47
55
  }
48
56
  }
49
57
 
@@ -272,18 +280,24 @@ class DatabaseConnection {
272
280
  switch (this.driver) {
273
281
  case 'mysql':
274
282
  if (this._transactionConnection) {
275
- await this._transactionConnection.commit();
276
- this._transactionConnection.release();
277
- this._transactionConnection = null;
283
+ try {
284
+ await this._transactionConnection.commit();
285
+ } finally {
286
+ this._transactionConnection.release();
287
+ this._transactionConnection = null;
288
+ }
278
289
  }
279
290
  break;
280
291
 
281
292
  case 'postgres':
282
293
  case 'postgresql':
283
294
  if (this._transactionConnection) {
284
- await this._transactionConnection.query('COMMIT');
285
- this._transactionConnection.release();
286
- this._transactionConnection = null;
295
+ try {
296
+ await this._transactionConnection.query('COMMIT');
297
+ } finally {
298
+ this._transactionConnection.release();
299
+ this._transactionConnection = null;
300
+ }
287
301
  }
288
302
  break;
289
303
 
@@ -306,18 +320,24 @@ class DatabaseConnection {
306
320
  switch (this.driver) {
307
321
  case 'mysql':
308
322
  if (this._transactionConnection) {
309
- await this._transactionConnection.rollback();
310
- this._transactionConnection.release();
311
- this._transactionConnection = null;
323
+ try {
324
+ await this._transactionConnection.rollback();
325
+ } finally {
326
+ this._transactionConnection.release();
327
+ this._transactionConnection = null;
328
+ }
312
329
  }
313
330
  break;
314
331
 
315
332
  case 'postgres':
316
333
  case 'postgresql':
317
334
  if (this._transactionConnection) {
318
- await this._transactionConnection.query('ROLLBACK');
319
- this._transactionConnection.release();
320
- this._transactionConnection = null;
335
+ try {
336
+ await this._transactionConnection.query('ROLLBACK');
337
+ } finally {
338
+ this._transactionConnection.release();
339
+ this._transactionConnection = null;
340
+ }
321
341
  }
322
342
  break;
323
343
 
@@ -515,10 +535,7 @@ class DatabaseConnection {
515
535
  switch (this.driver) {
516
536
  case 'mysql': {
517
537
  const conn = this._getConnection();
518
- const [res] = await conn.execute(
519
- this.convertToDriverPlaceholder(sql),
520
- params
521
- );
538
+ const [res] = await conn.execute(sql, params);
522
539
  result = { affectedRows: res.affectedRows };
523
540
  break;
524
541
  }
@@ -566,10 +583,7 @@ class DatabaseConnection {
566
583
  switch (this.driver) {
567
584
  case 'mysql': {
568
585
  const conn = this._getConnection();
569
- const [res] = await conn.execute(
570
- this.convertToDriverPlaceholder(sql),
571
- params
572
- );
586
+ const [res] = await conn.execute(sql, params);
573
587
  result = { affectedRows: res.affectedRows };
574
588
  break;
575
589
  }
@@ -737,7 +751,10 @@ class DatabaseConnection {
737
751
  }
738
752
 
739
753
  /**
740
- * Execute a raw query and return normalized results
754
+ * Execute a raw query and return normalized results.
755
+ * ⚠️ SECURITY WARNING: The `sql` parameter is passed to the database driver without
756
+ * any sanitization. NEVER pass user-controlled data in `sql`. User-controlled values
757
+ * must be supplied exclusively via the `params` array (parameterized queries).
741
758
  * @param {string} sql
742
759
  * @param {Array} params
743
760
  * @returns {Promise<Array>}
@@ -776,7 +793,10 @@ class DatabaseConnection {
776
793
  }
777
794
 
778
795
  /**
779
- * Execute raw SQL (driver-native results - for migrations)
796
+ * Execute raw SQL driver-native results (intended for migrations).
797
+ * ⚠️ SECURITY WARNING: The `sql` parameter is passed to the database driver without
798
+ * any sanitization. NEVER pass user-controlled data in `sql`. User-controlled values
799
+ * must be supplied exclusively via the `params` array (parameterized queries).
780
800
  * @param {string} sql
781
801
  * @param {Array} params
782
802
  * @returns {Promise<any>}
@@ -864,7 +884,7 @@ class DatabaseConnection {
864
884
  // SELECT clause
865
885
  let selectClause = '*';
866
886
  if (query.columns && query.columns.length > 0 && query.columns[0] !== '*') {
867
- selectClause = query.columns.join(', ');
887
+ selectClause = query.columns.map(col => sanitizeIdentifier(col)).join(', ');
868
888
  }
869
889
 
870
890
  // DISTINCT
@@ -876,7 +896,12 @@ class DatabaseConnection {
876
896
  if (query.joins && query.joins.length > 0) {
877
897
  for (const join of query.joins) {
878
898
  const joinType = (join.type || 'inner').toUpperCase();
879
- sql += ` ${joinType} JOIN ${join.table} ON ${join.first} ${join.operator} ${join.second}`;
899
+ const ALLOWED_OPERATORS = ['=', '!=', '<>', '<', '>', '<=', '>=', 'LIKE', 'NOT LIKE', 'IS', 'IS NOT'];
900
+ const op = join.operator.toUpperCase();
901
+ if (!ALLOWED_OPERATORS.includes(op)) {
902
+ throw new Error(`Invalid operator: ${join.operator}`);
903
+ }
904
+ sql += ` ${joinType} JOIN ${sanitizeIdentifier(join.table)} ON ${sanitizeIdentifier(join.first)} ${op} ${sanitizeIdentifier(join.second)}`;
880
905
  }
881
906
  }
882
907
 
@@ -887,19 +912,24 @@ class DatabaseConnection {
887
912
 
888
913
  // GROUP BY
889
914
  if (query.groupBys && query.groupBys.length > 0) {
890
- sql += ` GROUP BY ${query.groupBys.join(', ')}`;
915
+ sql += ` GROUP BY ${query.groupBys.map(col => sanitizeIdentifier(col)).join(', ')}`;
891
916
  }
892
917
 
893
918
  // HAVING
894
919
  if (query.havings && query.havings.length > 0) {
895
920
  const havingClauses = [];
896
921
  for (const h of query.havings) {
922
+ const ALLOWED_OPERATORS = ['=', '!=', '<>', '<', '>', '<=', '>=', 'LIKE', 'NOT LIKE', 'IS', 'IS NOT'];
923
+ const op = h.operator.toUpperCase();
924
+ if (!ALLOWED_OPERATORS.includes(op)) {
925
+ throw new Error(`Invalid operator: ${h.operator}`);
926
+ }
897
927
  if (h.type === 'basic') {
898
- havingClauses.push(`${h.column} ${h.operator} ?`);
928
+ havingClauses.push(`${sanitizeIdentifier(h.column)} ${op} ?`);
899
929
  params.push(h.value);
900
930
  } else if (h.type === 'count') {
901
- const col = h.column && h.column !== '*' ? h.column : '*';
902
- havingClauses.push(`COUNT(${col}) ${h.operator} ?`);
931
+ const col = h.column && h.column !== '*' ? sanitizeIdentifier(h.column) : '*';
932
+ havingClauses.push(`COUNT(${col}) ${op} ?`);
903
933
  params.push(h.value);
904
934
  }
905
935
  }
@@ -911,19 +941,28 @@ class DatabaseConnection {
911
941
  // ORDER BY
912
942
  if (query.orders && query.orders.length > 0) {
913
943
  const orderClauses = query.orders.map(
914
- order => `${order.column} ${order.direction.toUpperCase()}`
944
+ order => {
945
+ if (order.type === 'raw') {
946
+ return order.sql;
947
+ }
948
+ const dir = order.direction.toUpperCase();
949
+ if (dir !== 'ASC' && dir !== 'DESC') throw new Error(`Invalid direction: ${dir}`);
950
+ return `${sanitizeIdentifier(order.column)} ${dir}`;
951
+ }
915
952
  );
916
953
  sql += ` ORDER BY ${orderClauses.join(', ')}`;
917
954
  }
918
955
 
919
956
  // LIMIT
920
957
  if (query.limit !== null && query.limit !== undefined) {
921
- sql += ` LIMIT ${query.limit}`;
958
+ sql += ' LIMIT ?';
959
+ params.push(query.limit);
922
960
  }
923
961
 
924
962
  // OFFSET
925
963
  if (query.offset !== null && query.offset !== undefined) {
926
- sql += ` OFFSET ${query.offset}`;
964
+ sql += ' OFFSET ?';
965
+ params.push(query.offset);
927
966
  }
928
967
 
929
968
  return { sql, params };
@@ -940,45 +979,58 @@ class DatabaseConnection {
940
979
 
941
980
  const clauses = [];
942
981
  const params = [];
982
+ const ALLOWED_OPERATORS = ['=', '!=', '<>', '<', '>', '<=', '>=', 'LIKE', 'NOT LIKE', 'IS', 'IS NOT'];
943
983
 
944
984
  wheres.forEach((where, index) => {
945
985
  const boolean = index === 0 ? 'WHERE' : (where.boolean || 'AND').toUpperCase();
986
+ const col = where.type !== 'raw' ? sanitizeIdentifier(where.column) : null;
946
987
 
947
988
  switch (where.type) {
948
- case 'basic':
949
- clauses.push(`${boolean} ${where.column} ${where.operator} ?`);
989
+ case 'raw': {
990
+ clauses.push(`${boolean} ${where.sql}`);
991
+ if (where.bindings) {
992
+ params.push(...where.bindings);
993
+ }
994
+ break;
995
+ }
996
+
997
+ case 'basic': {
998
+ const op = where.operator.toUpperCase();
999
+ if (!ALLOWED_OPERATORS.includes(op)) throw new Error(`Invalid operator: ${where.operator}`);
1000
+ clauses.push(`${boolean} ${col} ${op} ?`);
950
1001
  params.push(where.value);
951
1002
  break;
1003
+ }
952
1004
 
953
1005
  case 'in': {
954
1006
  const inPlaceholders = where.values.map(() => '?').join(', ');
955
- clauses.push(`${boolean} ${where.column} IN (${inPlaceholders})`);
1007
+ clauses.push(`${boolean} ${col} IN (${inPlaceholders})`);
956
1008
  params.push(...where.values);
957
1009
  break;
958
1010
  }
959
1011
 
960
1012
  case 'notIn': {
961
1013
  const notInPlaceholders = where.values.map(() => '?').join(', ');
962
- clauses.push(`${boolean} ${where.column} NOT IN (${notInPlaceholders})`);
1014
+ clauses.push(`${boolean} ${col} NOT IN (${notInPlaceholders})`);
963
1015
  params.push(...where.values);
964
1016
  break;
965
1017
  }
966
1018
 
967
1019
  case 'null':
968
- clauses.push(`${boolean} ${where.column} IS NULL`);
1020
+ clauses.push(`${boolean} ${col} IS NULL`);
969
1021
  break;
970
1022
 
971
1023
  case 'notNull':
972
- clauses.push(`${boolean} ${where.column} IS NOT NULL`);
1024
+ clauses.push(`${boolean} ${col} IS NOT NULL`);
973
1025
  break;
974
1026
 
975
1027
  case 'between':
976
- clauses.push(`${boolean} ${where.column} BETWEEN ? AND ?`);
1028
+ clauses.push(`${boolean} ${col} BETWEEN ? AND ?`);
977
1029
  params.push(...where.values);
978
1030
  break;
979
1031
 
980
1032
  case 'like':
981
- clauses.push(`${boolean} ${where.column} LIKE ?`);
1033
+ clauses.push(`${boolean} ${col} LIKE ?`);
982
1034
  params.push(where.value);
983
1035
  break;
984
1036
  }
@@ -1,48 +1,48 @@
1
- /**
2
- * Base Migration Class
3
- * All migrations should extend this class
4
- */
5
-
6
- class Migration {
7
- constructor(connection) {
8
- this.connection = connection;
9
- }
10
-
11
- /**
12
- * Run the migrations
13
- */
14
- async up() {
15
- throw new Error('Migration up() method must be implemented');
16
- }
17
-
18
- /**
19
- * Reverse the migrations
20
- */
21
- async down() {
22
- throw new Error('Migration down() method must be implemented');
23
- }
24
-
25
- /**
26
- * Get the migration name
27
- */
28
- static getName() {
29
- return this.name;
30
- }
31
-
32
- /**
33
- * Execute raw SQL
34
- */
35
- async execute(sql) {
36
- return await this.connection.execute(sql);
37
- }
38
-
39
- /**
40
- * Get the Schema builder
41
- */
42
- getSchema() {
43
- const { Schema } = require('../Schema/Schema');
44
- return new Schema(this.connection);
45
- }
46
- }
47
-
48
- module.exports = Migration;
1
+ /**
2
+ * Base Migration Class
3
+ * All migrations should extend this class
4
+ */
5
+
6
+ class Migration {
7
+ constructor(connection) {
8
+ this.connection = connection;
9
+ }
10
+
11
+ /**
12
+ * Run the migrations
13
+ */
14
+ async up() {
15
+ throw new Error('Migration up() method must be implemented');
16
+ }
17
+
18
+ /**
19
+ * Reverse the migrations
20
+ */
21
+ async down() {
22
+ throw new Error('Migration down() method must be implemented');
23
+ }
24
+
25
+ /**
26
+ * Get the migration name
27
+ */
28
+ static getName() {
29
+ return this.name;
30
+ }
31
+
32
+ /**
33
+ * Execute raw SQL
34
+ */
35
+ async execute(sql) {
36
+ return await this.connection.execute(sql);
37
+ }
38
+
39
+ /**
40
+ * Get the Schema builder
41
+ */
42
+ getSchema() {
43
+ const { Schema } = require('../Schema/Schema');
44
+ return new Schema(this.connection);
45
+ }
46
+ }
47
+
48
+ module.exports = Migration;
@@ -7,10 +7,13 @@ const fs = require('fs').promises;
7
7
  const path = require('path');
8
8
 
9
9
  class MigrationManager {
10
- constructor(connection, migrationsPath = './database/migrations') {
10
+ constructor(connection, migrationsPath = './database/migrations', migrationsTable = 'migrations') {
11
+ if (typeof migrationsTable !== 'string' || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(migrationsTable)) {
12
+ throw new Error(`Invalid migrations table name: "${migrationsTable}"`);
13
+ }
11
14
  this.connection = connection;
12
15
  this.migrationsPath = path.resolve(process.cwd(), migrationsPath);
13
- this.migrationsTable = 'migrations';
16
+ this.migrationsTable = migrationsTable;
14
17
  }
15
18
 
16
19
  /**
@@ -54,7 +57,7 @@ class MigrationManager {
54
57
  await this.runMigration(migration, batch);
55
58
  }
56
59
 
57
- console.log(`\n✓ All migrations completed successfully`);
60
+ console.log('\n✓ All migrations completed successfully');
58
61
  }
59
62
 
60
63
  /**
@@ -105,7 +108,7 @@ class MigrationManager {
105
108
  await this.rollbackMigration(migration);
106
109
  }
107
110
 
108
- console.log(`\n✓ Rollback completed successfully`);
111
+ console.log('\n✓ Rollback completed successfully');
109
112
  }
110
113
 
111
114
  /**
@@ -155,7 +158,7 @@ class MigrationManager {
155
158
  await this.rollbackMigration(migration);
156
159
  }
157
160
 
158
- console.log(`\n✓ Reset completed successfully`);
161
+ console.log('\n✓ Reset completed successfully');
159
162
  }
160
163
 
161
164
  /**
@@ -263,11 +266,11 @@ class MigrationManager {
263
266
  const sql = `
264
267
  SELECT * FROM ${this.migrationsTable}
265
268
  WHERE batch >= (
266
- SELECT MAX(batch) - ${steps - 1} FROM ${this.migrationsTable}
269
+ SELECT MAX(batch) - ? FROM ${this.migrationsTable}
267
270
  )
268
271
  ORDER BY batch DESC, id DESC
269
272
  `;
270
- return await this.connection.execute(sql);
273
+ return await this.connection.execute(sql, [steps - 1]);
271
274
  }
272
275
 
273
276
  /**
@@ -304,18 +307,18 @@ class MigrationManager {
304
307
  let sql;
305
308
 
306
309
  switch (driver) {
307
- case 'mysql':
308
- sql = `SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE()`;
309
- break;
310
- case 'postgres':
311
- case 'postgresql':
312
- sql = `SELECT tablename FROM pg_tables WHERE schemaname = 'public'`;
313
- break;
314
- case 'sqlite':
315
- sql = `SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`;
316
- break;
317
- default:
318
- throw new Error(`Unsupported driver: ${driver}`);
310
+ case 'mysql':
311
+ sql = 'SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE()';
312
+ break;
313
+ case 'postgres':
314
+ case 'postgresql':
315
+ sql = 'SELECT tablename FROM pg_tables WHERE schemaname = \'public\'';
316
+ break;
317
+ case 'sqlite':
318
+ sql = 'SELECT name FROM sqlite_master WHERE type=\'table\' AND name NOT LIKE \'sqlite_%\'';
319
+ break;
320
+ default:
321
+ throw new Error(`Unsupported driver: ${driver}`);
319
322
  }
320
323
 
321
324
  const result = await this.connection.execute(sql);
package/src/Model.js CHANGED
@@ -95,6 +95,13 @@ class Model {
95
95
  * @param {Function} callback - Callback function
96
96
  */
97
97
  static on(event, callback) {
98
+ if (!Object.prototype.hasOwnProperty.call(this, 'eventListeners')) {
99
+ this.eventListeners = {
100
+ creating: [], created: [], updating: [], updated: [],
101
+ saving: [], saved: [], deleting: [], deleted: [],
102
+ restoring: [], restored: []
103
+ };
104
+ }
98
105
  if (!this.eventListeners[event]) {
99
106
  this.eventListeners[event] = [];
100
107
  }
@@ -108,6 +115,9 @@ class Model {
108
115
  * @returns {Promise<boolean>} - Returns false if event should be cancelled
109
116
  */
110
117
  static async fireEvent(event, model) {
118
+ if (!Object.prototype.hasOwnProperty.call(this, 'eventListeners')) {
119
+ return true;
120
+ }
111
121
  const listeners = this.eventListeners[event] || [];
112
122
  for (const listener of listeners) {
113
123
  const result = await listener(model);
@@ -423,8 +433,13 @@ class Model {
423
433
  break;
424
434
 
425
435
  case 'regex':
426
- if (value && !new RegExp(ruleParam).test(value)) {
427
- return `${field} format is invalid`;
436
+ try {
437
+ const re = new RegExp(ruleParam);
438
+ if (value && !re.test(value)) {
439
+ return `${field} format is invalid`;
440
+ }
441
+ } catch {
442
+ return `${field} has an invalid regex rule`;
428
443
  }
429
444
  break;
430
445
  }
@@ -556,7 +571,7 @@ class Model {
556
571
  * @returns {Promise<any>}
557
572
  */
558
573
  static async delete() {
559
- return this.query().delete();
574
+ throw new Error('Cannot call static delete() without conditions. Use query().delete() instead.');
560
575
  }
561
576
 
562
577
  /**
@@ -679,8 +694,9 @@ class Model {
679
694
  * @returns {this}
680
695
  */
681
696
  fill(attributes) {
697
+ const fillable = this.constructor.fillable || [];
682
698
  for (const [key, value] of Object.entries(attributes)) {
683
- if (this.constructor.fillable.length === 0 || this.constructor.fillable.includes(key)) {
699
+ if (fillable.includes(key)) {
684
700
  this.setAttribute(key, value);
685
701
  }
686
702
  }
@@ -952,6 +968,10 @@ class Model {
952
968
  const head = segments[0];
953
969
  const tail = segments.slice(1).join('.');
954
970
 
971
+ // Prevent prototype pollution and calling built-in methods
972
+ const builtIns = ['constructor', 'load', 'save', 'delete', 'update', 'query', 'with', 'withCount', 'hasOne', 'hasMany', 'belongsTo', 'belongsToMany', 'morphTo', 'morphOne', 'morphMany', 'hasOneThrough', 'hasManyThrough'];
973
+ if (builtIns.includes(head) || head.startsWith('__')) return;
974
+
955
975
  const relationFn = this[head];
956
976
  if (typeof relationFn !== 'function') return;
957
977
 
@@ -984,7 +1004,8 @@ class Model {
984
1004
  hasOne(related, foreignKey, localKey) {
985
1005
  const HasOneRelation = require('./Relations/HasOneRelation');
986
1006
  localKey = localKey || this.constructor.primaryKey;
987
- foreignKey = foreignKey || `${this.constructor.table.slice(0, -1)}_id`;
1007
+ const pluralize = require('pluralize');
1008
+ foreignKey = foreignKey || `${pluralize.singular(this.constructor.table)}_id`;
988
1009
 
989
1010
  return new HasOneRelation(this, related, foreignKey, localKey);
990
1011
  }
@@ -999,7 +1020,8 @@ class Model {
999
1020
  hasMany(related, foreignKey, localKey) {
1000
1021
  const HasManyRelation = require('./Relations/HasManyRelation');
1001
1022
  localKey = localKey || this.constructor.primaryKey;
1002
- foreignKey = foreignKey || `${this.constructor.table.slice(0, -1)}_id`;
1023
+ const pluralize = require('pluralize');
1024
+ foreignKey = foreignKey || `${pluralize.singular(this.constructor.table)}_id`;
1003
1025
 
1004
1026
  return new HasManyRelation(this, related, foreignKey, localKey);
1005
1027
  }
@@ -1014,7 +1036,8 @@ class Model {
1014
1036
  belongsTo(related, foreignKey, ownerKey) {
1015
1037
  const BelongsToRelation = require('./Relations/BelongsToRelation');
1016
1038
  ownerKey = ownerKey || related.primaryKey;
1017
- foreignKey = foreignKey || `${related.table.slice(0, -1)}_id`;
1039
+ const pluralize = require('pluralize');
1040
+ foreignKey = foreignKey || `${pluralize.singular(related.table)}_id`;
1018
1041
 
1019
1042
  return new BelongsToRelation(this, related, foreignKey, ownerKey);
1020
1043
  }