diff --git a/package-lock.json b/package-lock.json index a9f2e29..6077d78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "ts-orm", + "name": "@velkymx/ts-orm", "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "ts-orm", + "name": "@velkymx/ts-orm", "version": "1.4.0", "license": "MIT", "dependencies": { @@ -13,6 +13,9 @@ "mysql2": "^3.14.0", "uuid": "^11.1.0" }, + "bin": { + "ts-orm": "bin/cli.js" + }, "devDependencies": { "eslint": "^9.24.0", "jest": "^29.7.0", diff --git a/package.json b/package.json index 4fe4532..9c3b9a9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@velkymx/ts-orm", - "version": "1.4.0", + "version": "1.5.0", "description": "A lightweight CRUD ORM built on MySQL2 using JSON-based structs", "type": "module", "repository": { diff --git a/readme.md b/readme.md index 60cfa42..b1e1f72 100644 --- a/readme.md +++ b/readme.md @@ -8,6 +8,9 @@ A lightweight, JSON-struct-based ORM for Node.js using MySQL2. Define your schem - ✅ Define data schemas using simple JSON structs - ✅ Validates payloads before writing to DB +- ✅ **SQL injection prevention** with identifier validation & escaping +- ✅ **Enhanced input validation** (UUID, datetime, date, boolean formats) +- ✅ **Sanitized error messages** for security - ✅ Supports full CRUD (Create, Read, Update, Delete) - ✅ Consistent JSON responses - ✅ Built on MySQL2 + dotenv @@ -15,6 +18,59 @@ A lightweight, JSON-struct-based ORM for Node.js using MySQL2. Define your schem --- +## 🔒 Security (v1.5.0+) + +**ts-orm** now includes enterprise-grade security features: + +### SQL Injection Prevention +All table names, column names, and ORDER BY clauses are validated and escaped to prevent SQL injection attacks. + +### Input Validation +- **UUID**: Validates proper UUID v4 format +- **Datetime**: Validates `YYYY-MM-DD HH:MM:SS` format with range checks +- **Date**: Validates `YYYY-MM-DD` format with range checks +- **Boolean**: Type-checks boolean values (accepts `true`, `false`, `0`, `1`) +- **Enum**: Properly handles `null`/`undefined` for non-required fields + +### Error Sanitization +Database errors are sanitized to prevent schema information leakage: +- Duplicate key errors → "Record already exists" +- Foreign key violations → Safe descriptive messages +- Full errors logged server-side for debugging +- Generic messages returned to clients + +--- + +## ⚠️ Breaking Changes in v1.5.0 + +### Identifier Validation +Table and column names must now contain only alphanumeric characters and underscores (`[a-zA-Z0-9_]`). Special characters like spaces, quotes, or semicolons will be rejected. + +**Before v1.5.0**: Any identifier accepted (security risk) +**After v1.5.0**: Only safe identifiers allowed + +```javascript +// ✅ Valid identifiers +await create('users', struct, payload); +await create('user_profiles', struct, payload); +await create('users_2024', struct, payload); + +// ❌ Invalid identifiers (will throw error) +await create('users; DROP TABLE', struct, payload); // SQL injection attempt +await create('user profiles', struct, payload); // Contains space +await create('users-table', struct, payload); // Contains hyphen +``` + +### Error Messages +Error messages are now sanitized and more generic to prevent information leakage. + +**Before v1.5.0**: `"DB Error: Duplicate entry 'john@example.com' for key 'users.email'"` +**After v1.5.0**: `"Record already exists"` + +Full error details are logged server-side with `console.error()` for debugging. + +--- + ## Installation ```bash @@ -96,13 +152,33 @@ async function tryCreateUser() { tryCreateUser(); ``` -### Output on Fail -* Failed to create user: -* Message: Validation failed -* Details: [ 'email is required' ] + +### Output on Validation Failure +``` +Failed to create user: +Message: Validation failed +Details: [ 'email is required' ] +``` + +### Output on Invalid UUID (v1.5.0+) +``` +Failed to create user: +Message: Validation failed +Details: [ 'id must be a valid UUID' ] +``` + +### Output on Duplicate Key (v1.5.0+) +``` +Failed to create user: +Message: Database operation failed +Details: Record already exists +``` +*Note: Full error details are logged server-side for debugging* ### Output on Success +``` User created successfully: { id: 1 } +``` ## Using ts-orm in an ExpressJS Controller @@ -152,16 +228,38 @@ export default router; ## 📘 Struct Field Types -| Type | Description | Example Value | Notes | -|--------------|--------------------------------------|----------------------------------------|----------------------------------------------------| -| `uuid` | Universally unique identifier | `"123e4567-e89b-12d3-a456-426614174000"` | Must match UUID v4 format | -| `string` | Text string | `"Alice"` | `length` defines max characters allowed | -| `number` | Integer or float | `42` | Used for numeric fields including IDs | -| `datetime` | Date and time | `"2025-04-07 10:00:00"` | Format: `YYYY-MM-DD HH:MM:SS` | -| `date` | Date only | `"2025-04-07"` | Format: `YYYY-MM-DD` | -| `boolean` | Boolean flag | `true` or `false` | Stored as TINYINT in MySQL | -| `enum` | Predefined set of valid strings | `"percent"` or `"flat"` | Must define `enum: [...]` in struct | -| `auto_increment` | Special case for `number` fields | Not passed on insert | Use `"default": "auto_increment"` in struct config | +| Type | Description | Example Value | Validation (v1.5.0+) | Notes | +|--------------|--------------------------------------|----------------------------------------|------------------------------------------------------------------|----------------------------------------------------| +| `uuid` | Universally unique identifier | `"123e4567-e89b-12d3-a456-426614174000"` | **Must match UUID v4 format** (validated) | Regex pattern validated | +| `string` | Text string | `"Alice"` | Length validation only | `length` defines max characters allowed | +| `number` | Integer or float | `42` | Type validation (isNaN check) | Used for numeric fields including IDs | +| `datetime` | Date and time | `"2025-04-07 10:00:00"` | **Must match `YYYY-MM-DD HH:MM:SS` format** with range checks | Format and validity validated | +| `date` | Date only | `"2025-04-07"` | **Must match `YYYY-MM-DD` format** with range checks | Format and validity validated | +| `boolean` | Boolean flag | `true`, `false`, `0`, `1` | **Type-checked** (boolean, 0, 1, '0', '1' allowed) | Stored as TINYINT in MySQL | +| `enum` | Predefined set of valid strings | `"percent"` or `"flat"` | Validates value in enum array; allows null for non-required | Must define `enum: [...]` in struct | +| `auto_increment` | Special case for `number` fields | Not passed on insert | Cannot be manually provided on insert | Use `"default": "auto_increment"` in struct config | + +### Validation Examples + +```javascript +// ✅ Valid values (will pass validation) +const validPayload = { + id: '123e4567-e89b-12d3-a456-426614174000', // Valid UUID v4 + created_at: '2025-04-07 10:30:00', // Valid datetime + birth_date: '1990-05-15', // Valid date + is_active: true, // Valid boolean + commission_type: 'percent' // Valid enum value +}; + +// ❌ Invalid values (will fail validation) +const invalidPayload = { + id: 'not-a-valid-uuid', // Invalid UUID format + created_at: '2025/04/07 10:30:00', // Invalid datetime format (slashes) + birth_date: '04-07-2025', // Invalid date format + is_active: 'yes', // Invalid boolean + commission_type: 'invalid' // Not in enum array +}; +``` ## Using the CLI Tool to Generate Structs @@ -182,6 +280,82 @@ users.json You can then use this file as the struct definition for `ts-orm` CRUD operations. +## 🔧 Migration Guide: v1.4.0 → v1.5.0 + +### Step 1: Verify Identifier Names +Ensure all your table and column names use only alphanumeric characters and underscores: + +```javascript +// Check your table names +const validTables = ['users', 'user_profiles', 'orders_2024']; // ✅ Valid +const invalidTables = ['user-profiles', 'users table', 'orders#']; // ❌ Invalid + +// Check your column names in structs +const struct = [ + { name: "user_id", ... }, // ✅ Valid + { name: "created_at", ... }, // ✅ Valid + { name: "user-name", ... }, // ❌ Invalid - contains hyphen +]; +``` + +### Step 2: Update Error Handling +Error messages are now more generic. If you're parsing error messages, update your code: + +```javascript +// Before v1.5.0 +if (result.data.includes('Duplicate entry')) { ... } + +// After v1.5.0 +if (result.data === 'Record already exists') { ... } +``` + +### Step 3: Review Validation Requirements +New validation is stricter. Ensure your payloads match the expected formats: + +```javascript +// UUIDs must be valid v4 format +id: '123e4567-e89b-12d3-a456-426614174000' // ✅ + +// Datetimes must be YYYY-MM-DD HH:MM:SS +created_at: '2025-04-07 10:00:00' // ✅ +created_at: '2025/04/07 10:00:00' // ❌ + +// Dates must be YYYY-MM-DD +birth_date: '1990-05-15' // ✅ +birth_date: '05/15/1990' // ❌ + +// Booleans must be proper type +is_active: true // ✅ +is_active: 1 // ✅ +is_active: 'yes' // ❌ +``` + +### Step 4: Test Your Application +Run your test suite to catch any validation errors: + +```bash +npm test +``` + +## 📝 Changelog + +### v1.5.0 (2025-12-11) +**Security Enhancements** +- ✅ Added SQL injection prevention with hybrid validation & escaping +- ✅ Enhanced input validation (UUID, datetime, date, boolean formats) +- ✅ Implemented error message sanitization +- ✅ Added comprehensive security test suite + +**Breaking Changes** +- ⚠️ Table/column names now restricted to `[a-zA-Z0-9_]` +- ⚠️ Error messages are now sanitized (more generic) +- ⚠️ Stricter type validation for UUID, datetime, date, boolean fields + +**New Features** +- Server-side error logging with `console.error()` +- Improved validation error messages +- Better handling of null/undefined in non-required enum fields + ## 🌐 TechnoSorcery.com Built with ✨ by [TechnoSorcery.com](https://technosorcery.com) \ No newline at end of file diff --git a/src/introspect.js b/src/introspect.js index da2dde5..0efdd56 100644 --- a/src/introspect.js +++ b/src/introspect.js @@ -1,5 +1,6 @@ import mysql from 'mysql2/promise'; import dotenv from 'dotenv'; +import { validateAndEscapeIdentifier } from './security.js'; dotenv.config(); const typeMap = { @@ -24,7 +25,9 @@ export async function generateStructFromTable(table) { database: process.env.DB_DATABASE }); - const [columns] = await pool.execute(`SHOW COLUMNS FROM \`${table}\``); + // Validate and escape table name + const safeTable = validateAndEscapeIdentifier(table, 'table name'); + const [columns] = await pool.execute(`SHOW COLUMNS FROM ${safeTable}`); const struct = columns.map(col => { const rawType = col.Type.toLowerCase(); diff --git a/src/orm.js b/src/orm.js index 816b70e..10d020d 100644 --- a/src/orm.js +++ b/src/orm.js @@ -1,6 +1,7 @@ import mysql from 'mysql2/promise'; import dotenv from 'dotenv'; import { validatePayload } from './validator.js'; +import { validateAndEscapeIdentifier, validateQualifiedIdentifier, sanitizeError } from './security.js'; dotenv.config(); @@ -19,43 +20,55 @@ export async function create(table, struct, payload) { const errors = validatePayload(struct, payload, { skipAutoIncrement: true }); if (errors.length) return formatResponse(false, 'Validation failed', errors); - // Filter out auto_increment fields - const filtered = struct.filter(f => f.default !== 'auto_increment'); - const columns = filtered.map(f => f.name); - const values = filtered.map(f => payload[f.name] ?? f.default); + try { + // Validate and escape table name + const safeTable = validateAndEscapeIdentifier(table, 'table name'); - const placeholders = columns.map(() => '?').join(', '); - const sql = `INSERT INTO ${table} (${columns.join(', ')}) VALUES (${placeholders})`; + // Filter out auto_increment fields + const filtered = struct.filter(f => f.default !== 'auto_increment'); + const columns = filtered.map(f => f.name); + const values = filtered.map(f => payload[f.name] ?? f.default); + + // Validate and escape column names + const safeColumns = columns.map(col => validateAndEscapeIdentifier(col, 'column name')); + + const placeholders = columns.map(() => '?').join(', '); + const sql = `INSERT INTO ${safeTable} (${safeColumns.join(', ')}) VALUES (${placeholders})`; - try { const [result] = await pool.execute(sql, values); return formatResponse(true, 'Record created', { id: result.insertId }); } catch (error) { - return formatResponse(false, 'DB Error', error.message); + return formatResponse(false, 'Database operation failed', sanitizeError(error, 'create', { table })); } } export async function read(table, conditions = {}, options = {}) { - const keys = Object.keys(conditions); - const whereClause = keys.length - ? `WHERE ${keys.map(k => `${k} = ?`).join(' AND ')}` - : ''; + try { + // Validate and escape table name + const safeTable = validateAndEscapeIdentifier(table, 'table name'); - const orderClause = options.orderBy - ? `ORDER BY ${options.orderBy} ${options.direction?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'}` - : ''; + const keys = Object.keys(conditions); - const limitClause = options.limit ? `LIMIT ${parseInt(options.limit)}` : ''; - const offsetClause = options.offset ? `OFFSET ${parseInt(options.offset)}` : ''; + // Validate and escape WHERE column names + const whereClause = keys.length + ? `WHERE ${keys.map(k => `${validateAndEscapeIdentifier(k, 'column name')} = ?`).join(' AND ')}` + : ''; - const sql = `SELECT * FROM ${table} ${whereClause} ${orderClause} ${limitClause} ${offsetClause}`.trim(); + // Validate and escape ORDER BY column + const orderClause = options.orderBy + ? `ORDER BY ${validateAndEscapeIdentifier(options.orderBy, 'order by column')} ${options.direction?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'}` + : ''; + + const limitClause = options.limit ? `LIMIT ${Number.parseInt(options.limit, 10)}` : ''; + const offsetClause = options.offset ? `OFFSET ${Number.parseInt(options.offset, 10)}` : ''; + + const sql = `SELECT * FROM ${safeTable} ${whereClause} ${orderClause} ${limitClause} ${offsetClause}`.trim(); - try { const [rows] = await pool.execute(sql, keys.map(k => conditions[k])); return formatResponse(true, 'Data retrieved', rows); } catch (error) { - return formatResponse(false, 'DB Error', error.message); + return formatResponse(false, 'Database operation failed', sanitizeError(error, 'read', { table })); } } @@ -79,36 +92,70 @@ export async function readOne(table, conditions = {}) { export async function findOrFail(table, key, value) { const result = await readOne(table, { [key]: value }); - + if (!result.success || !result.data) { - throw new Error(`Record not found in table '${table}' with ${key} = ${value}`); + throw new Error('Record not found'); } - + return result.data; } export async function readWith(table, conditions = {}, joins = [], options = {}) { try { + // Validate and escape main table name + const safeTable = validateAndEscapeIdentifier(table, 'table name'); + const whereKeys = Object.keys(conditions); + + // Validate and escape WHERE column names const whereClause = whereKeys.length - ? `WHERE ${whereKeys.map(k => `${table}.${k} = ?`).join(' AND ')}` + ? `WHERE ${whereKeys.map(k => `${safeTable}.${validateAndEscapeIdentifier(k, 'column name')} = ?`).join(' AND ')}` : ''; + // Validate and escape JOIN clauses const joinClause = joins.map(join => { const joinType = (join.type || 'inner').toUpperCase(); // INNER, LEFT, RIGHT - const joinTable = join.table; - const [leftCol, rightCol] = join.on; - return `${joinType} JOIN ${joinTable} ON ${leftCol} = ${rightCol}`; - }).join(' '); - const orderClause = options.orderBy - ? `ORDER BY ${options.orderBy} ${options.direction?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'}` - : ''; + // Validate and escape join table name + const safeJoinTable = validateAndEscapeIdentifier(join.table, 'join table name'); - const limitClause = options.limit ? `LIMIT ${parseInt(options.limit)}` : ''; - const offsetClause = options.offset ? `OFFSET ${parseInt(options.offset)}` : ''; + const [leftCol, rightCol] = join.on; - const sql = `SELECT * FROM ${table} ${joinClause} ${whereClause} ${orderClause} ${limitClause} ${offsetClause}`.trim(); + // Validate qualified identifiers (table.column) + if (!validateQualifiedIdentifier(leftCol)) { + throw new Error(`Invalid join condition: ${leftCol}`); + } + if (!validateQualifiedIdentifier(rightCol)) { + throw new Error(`Invalid join condition: ${rightCol}`); + } + + // Split and escape each part + const [leftTable, leftField] = leftCol.split('.'); + const [rightTable, rightField] = rightCol.split('.'); + const safeLeftCol = `${validateAndEscapeIdentifier(leftTable, 'table name')}.${validateAndEscapeIdentifier(leftField, 'column name')}`; + const safeRightCol = `${validateAndEscapeIdentifier(rightTable, 'table name')}.${validateAndEscapeIdentifier(rightField, 'column name')}`; + + return `${joinType} JOIN ${safeJoinTable} ON ${safeLeftCol} = ${safeRightCol}`; + }).join(' '); + + // Validate and escape ORDER BY (handle both qualified and simple) + let orderClause = ''; + if (options.orderBy) { + if (validateQualifiedIdentifier(options.orderBy)) { + // Qualified identifier (table.column) + const [orderTable, orderField] = options.orderBy.split('.'); + const safeOrderBy = `${validateAndEscapeIdentifier(orderTable, 'table name')}.${validateAndEscapeIdentifier(orderField, 'column name')}`; + orderClause = `ORDER BY ${safeOrderBy} ${options.direction?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'}`; + } else { + // Simple identifier + orderClause = `ORDER BY ${validateAndEscapeIdentifier(options.orderBy, 'order by column')} ${options.direction?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'}`; + } + } + + const limitClause = options.limit ? `LIMIT ${Number.parseInt(options.limit, 10)}` : ''; + const offsetClause = options.offset ? `OFFSET ${Number.parseInt(options.offset, 10)}` : ''; + + const sql = `SELECT * FROM ${safeTable} ${joinClause} ${whereClause} ${orderClause} ${limitClause} ${offsetClause}`.trim(); const values = whereKeys.map(k => conditions[k]); @@ -116,7 +163,7 @@ export async function readOne(table, conditions = {}) { return formatResponse(true, 'Data retrieved with join', rows); } catch (error) { - return formatResponse(false, 'DB Error', error.message); + return formatResponse(false, 'Database operation failed', sanitizeError(error, 'readWith', { table })); } } @@ -126,31 +173,40 @@ export async function update(table, struct, payload, idKey = 'id') { const errors = validatePayload(struct, payload); if (errors.length) return formatResponse(false, 'Validation failed', errors); - const updates = struct - .filter(f => payload[f.name] !== undefined && f.name !== idKey) - .map(f => `${f.name} = ?`); - const values = struct - .filter(f => payload[f.name] !== undefined && f.name !== idKey) - .map(f => payload[f.name]); + try { + // Validate and escape table name and idKey + const safeTable = validateAndEscapeIdentifier(table, 'table name'); + const safeIdKey = validateAndEscapeIdentifier(idKey, 'id column name'); + + // Validate and escape column names in SET clause + const updates = struct + .filter(f => payload[f.name] !== undefined && f.name !== idKey) + .map(f => `${validateAndEscapeIdentifier(f.name, 'column name')} = ?`); + const values = struct + .filter(f => payload[f.name] !== undefined && f.name !== idKey) + .map(f => payload[f.name]); - values.push(payload[idKey]); - const sql = `UPDATE ${table} SET ${updates.join(', ')} WHERE ${idKey} = ?`; + values.push(payload[idKey]); + const sql = `UPDATE ${safeTable} SET ${updates.join(', ')} WHERE ${safeIdKey} = ?`; - try { const [result] = await pool.execute(sql, values); return formatResponse(true, 'Record updated', result); } catch (error) { - return formatResponse(false, 'DB Error', error.message); + return formatResponse(false, 'Database operation failed', sanitizeError(error, 'update', { table })); } } export async function remove(table, idKey, idVal) { - const sql = `DELETE FROM ${table} WHERE ${idKey} = ?`; - try { + // Validate and escape table name and idKey + const safeTable = validateAndEscapeIdentifier(table, 'table name'); + const safeIdKey = validateAndEscapeIdentifier(idKey, 'id column name'); + + const sql = `DELETE FROM ${safeTable} WHERE ${safeIdKey} = ?`; + const [result] = await pool.execute(sql, [idVal]); return formatResponse(true, 'Record deleted', result); } catch (error) { - return formatResponse(false, 'DB Error', error.message); + return formatResponse(false, 'Database operation failed', sanitizeError(error, 'remove', { table })); } } diff --git a/src/security.js b/src/security.js new file mode 100644 index 0000000..7abfea0 --- /dev/null +++ b/src/security.js @@ -0,0 +1,203 @@ +import mysql from 'mysql2/promise'; + +// SQL Identifier validation patterns +const IDENTIFIER_PATTERN = /^[a-zA-Z0-9_]+$/; +const QUALIFIED_IDENTIFIER_PATTERN = /^[a-zA-Z0-9_]+\.[a-zA-Z0-9_]+$/; + +// Input format validation patterns +const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const DATETIME_PATTERN = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/; +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +// MySQL error code mappings to safe messages +const ERROR_MESSAGES = { + ER_DUP_ENTRY: 'Record already exists', + ER_DUP_KEY: 'Record already exists', + ER_NO_REFERENCED_ROW: 'Related record not found', + ER_NO_REFERENCED_ROW_2: 'Related record not found', + ER_ROW_IS_REFERENCED: 'Cannot delete record - it is referenced by other records', + ER_ROW_IS_REFERENCED_2: 'Cannot delete record - it is referenced by other records', + ER_BAD_FIELD_ERROR: 'Invalid field name', + ER_NO_SUCH_TABLE: 'Table not found', + ER_BAD_TABLE_ERROR: 'Table not found', + ER_PARSE_ERROR: 'Query syntax error', + ER_ACCESS_DENIED_ERROR: 'Access denied', + ER_WRONG_VALUE_COUNT: 'Invalid number of values', + DEFAULT: 'Database operation failed' +}; + +/** + * Validates that an identifier contains only safe characters + * @param {string} identifier - The identifier to validate + * @param {string} context - Context for error message (e.g., 'table name', 'column name') + * @returns {boolean} True if valid + * @throws {Error} If identifier is invalid + */ +export function validateIdentifier(identifier, context = 'identifier') { + if (typeof identifier !== 'string' || identifier.length === 0) { + throw new Error(`Invalid ${context}: must be a non-empty string`); + } + + if (!IDENTIFIER_PATTERN.test(identifier)) { + throw new Error(`Invalid ${context}: '${identifier}' contains illegal characters. Only alphanumeric and underscore allowed.`); + } + + return true; +} + +/** + * Escapes an identifier using mysql2's escapeId + * @param {string} identifier - The identifier to escape + * @returns {string} Escaped identifier + */ +export function escapeIdentifier(identifier) { + return mysql.escapeId(identifier); +} + +/** + * Validates and escapes an identifier (hybrid approach) + * @param {string} identifier - The identifier to validate and escape + * @param {string} context - Context for error message + * @returns {string} Escaped identifier + * @throws {Error} If identifier is invalid + */ +export function validateAndEscapeIdentifier(identifier, context = 'identifier') { + validateIdentifier(identifier, context); + return escapeIdentifier(identifier); +} + +/** + * Validates a qualified identifier (e.g., table.column) + * @param {string} qualifiedId - The qualified identifier to validate + * @returns {boolean} True if valid + */ +export function validateQualifiedIdentifier(qualifiedId) { + if (typeof qualifiedId !== 'string' || qualifiedId.length === 0) { + return false; + } + return QUALIFIED_IDENTIFIER_PATTERN.test(qualifiedId); +} + +/** + * Validates UUID v4 format + * @param {*} value - The value to validate + * @returns {boolean} True if valid UUID v4 + */ +export function isValidUUID(value) { + if (typeof value !== 'string') { + return false; + } + return UUID_V4_PATTERN.test(value); +} + +/** + * Validates datetime format (YYYY-MM-DD HH:MM:SS) + * @param {*} value - The value to validate + * @returns {boolean} True if valid datetime + */ +export function isValidDatetime(value) { + if (typeof value !== 'string') { + return false; + } + + if (!DATETIME_PATTERN.test(value)) { + return false; + } + + // Additional validation: check if it's a valid date + const [datePart, timePart] = value.split(' '); + const [year, month, day] = datePart.split('-').map(Number); + const [hour, minute, second] = timePart.split(':').map(Number); + + // Basic range checks + if (month < 1 || month > 12) return false; + if (day < 1 || day > 31) return false; + if (hour < 0 || hour > 23) return false; + if (minute < 0 || minute > 59) return false; + if (second < 0 || second > 59) return false; + + // Check if date is valid using Date object + const date = new Date(year, month - 1, day, hour, minute, second); + return date.getFullYear() === year && + date.getMonth() === month - 1 && + date.getDate() === day; +} + +/** + * Validates date format (YYYY-MM-DD) + * @param {*} value - The value to validate + * @returns {boolean} True if valid date + */ +export function isValidDate(value) { + if (typeof value !== 'string') { + return false; + } + + if (!DATE_PATTERN.test(value)) { + return false; + } + + // Additional validation: check if it's a valid date + const [year, month, day] = value.split('-').map(Number); + + // Basic range checks + if (month < 1 || month > 12) return false; + if (day < 1 || day > 31) return false; + + // Check if date is valid using Date object + const date = new Date(year, month - 1, day); + return date.getFullYear() === year && + date.getMonth() === month - 1 && + date.getDate() === day; +} + +/** + * Validates boolean type + * @param {*} value - The value to validate + * @returns {boolean} True if valid boolean + */ +export function isValidBoolean(value) { + return typeof value === 'boolean' || value === 0 || value === 1 || value === '0' || value === '1'; +} + +/** + * Sanitizes database errors and logs them server-side + * @param {Error} error - The error to sanitize + * @param {string} operation - The operation that failed (e.g., 'create', 'read') + * @param {Object} context - Additional context for logging (e.g., { table: 'users' }) + * @returns {string} Sanitized error message safe for client + */ +export function sanitizeError(error, operation, context = {}) { + // Log full error server-side for debugging + console.error(`[ts-orm] ${operation} operation failed:`, { + error: error.message, + code: error.code, + errno: error.errno, + sqlState: error.sqlState, + context + }); + + // Validation errors from our security module are safe to pass through + // (they don't contain schema information, just validation messages) + if (error.message && error.message.includes('Invalid')) { + return error.message; + } + + // Return sanitized message based on error code + if (error.code && ERROR_MESSAGES[error.code]) { + return ERROR_MESSAGES[error.code]; + } + + // Check if error message contains specific patterns + if (error.message) { + if (error.message.includes('Duplicate entry')) { + return ERROR_MESSAGES.ER_DUP_ENTRY; + } + if (error.message.includes('foreign key constraint')) { + return ERROR_MESSAGES.ER_ROW_IS_REFERENCED; + } + } + + // Default safe message + return ERROR_MESSAGES.DEFAULT; +} diff --git a/src/validator.js b/src/validator.js index deb6701..a851c5a 100644 --- a/src/validator.js +++ b/src/validator.js @@ -1,3 +1,5 @@ +import { isValidUUID, isValidDatetime, isValidDate, isValidBoolean } from './security.js'; + export function validatePayload(struct, payload, options = {}) { const errors = []; @@ -14,19 +16,48 @@ export function validatePayload(struct, payload, options = {}) { if (skipAuto) continue; - if (field.required && (value === undefined || value === null || value === '')) { + // Fix: Allow falsy values like false, 0, but reject undefined/null for required fields + if (field.required && (value === undefined || value === null)) { errors.push(`${field.name} is required`); continue; } - if (field.type === 'number' && value !== undefined && isNaN(Number(value))) { + // Skip validation if value is undefined or null for non-required fields + if (value === undefined || value === null) { + continue; + } + + // Number validation + if (field.type === 'number' && isNaN(Number(value))) { errors.push(`${field.name} must be a number`); } + // UUID validation + if (field.type === 'uuid' && !isValidUUID(value)) { + errors.push(`${field.name} must be a valid UUID`); + } + + // Datetime validation (skip 'current_timestamp' default value) + if (field.type === 'datetime' && value !== 'current_timestamp' && !isValidDatetime(value)) { + errors.push(`${field.name} must be in format YYYY-MM-DD HH:MM:SS`); + } + + // Date validation + if (field.type === 'date' && !isValidDate(value)) { + errors.push(`${field.name} must be in format YYYY-MM-DD`); + } + + // Boolean validation + if (field.type === 'boolean' && !isValidBoolean(value)) { + errors.push(`${field.name} must be a boolean`); + } + + // Enum validation (skip undefined/null for non-required fields) if (field.enum && !field.enum.includes(value)) { errors.push(`${field.name} must be one of ${field.enum.join(', ')}`); } + // String length validation if (field.length && typeof value === 'string' && field.length > 0 && value.length > field.length) { errors.push(`${field.name} exceeds max length of ${field.length}`); } diff --git a/tests/crud.test.mjs b/tests/crud.test.mjs index 97f7c4b..faa3a74 100644 --- a/tests/crud.test.mjs +++ b/tests/crud.test.mjs @@ -108,11 +108,117 @@ async function dropTestTable() { await conn.end(); } +describe('Security Tests', () => { + test('Rejects SQL injection in table name', async () => { + const maliciousTable = "users; DROP TABLE users--"; + const res = await read(maliciousTable, {}); + expect(res.success).toBe(false); + expect(res.message).toBe('Database operation failed'); + expect(res.data).toContain('Invalid'); + }); + + test('Rejects SQL injection in ORDER BY', async () => { + const res = await read(table, {}, { orderBy: "id; DROP TABLE test--" }); + expect(res.success).toBe(false); + expect(res.message).toBe('Database operation failed'); + expect(res.data).toContain('Invalid'); + }); + + test('Rejects SQL injection in WHERE column names', async () => { + const res = await read(table, { "id' OR '1'='1": 'test' }); + expect(res.success).toBe(false); + expect(res.message).toBe('Database operation failed'); + expect(res.data).toContain('Invalid'); + }); + + test('Rejects SQL injection in JOIN table name', async () => { + const res = await readWith(table, {}, [ + { table: "test'; DROP TABLE test--", on: ['test.id', 'test.id'], type: 'inner' } + ]); + expect(res.success).toBe(false); + expect(res.message).toBe('Database operation failed'); + expect(res.data).toContain('Invalid'); + }); + + test('Validates UUID format', async () => { + const res = await create(table, struct, { + ...basePayload, + id: 'not-a-uuid', + name: 'Test', + json: '{}', + related_forms: 'test', + date_created: new Date().toISOString().slice(0, 19).replace('T', ' ') + }); + expect(res.success).toBe(false); + expect(res.message).toBe('Validation failed'); + expect(res.data).toContain('id must be a valid UUID'); + }); + + test('Validates datetime format', async () => { + const res = await create(table, struct, { + ...basePayload, + id: uuidv4(), + date_created: 'invalid-date' + }); + expect(res.success).toBe(false); + expect(res.data).toContain('date_created must be in format YYYY-MM-DD HH:MM:SS'); + }); + + test('Validates date format', async () => { + const testPayload = { + ...basePayload, + id: uuidv4(), + date_due: '2025/04/07' // Wrong format + }; + const res = await create(table, struct, testPayload); + expect(res.success).toBe(false); + expect(res.data).toContain('date_due must be in format YYYY-MM-DD'); + }); + + test('Validates boolean type', async () => { + const testPayload = { + ...basePayload, + id: uuidv4(), + opt_shareclient: 'not-a-boolean' + }; + const res = await create(table, struct, testPayload); + expect(res.success).toBe(false); + expect(res.data).toContain('opt_shareclient must be a boolean'); + }); + + test('Allows null for non-required enum field', async () => { + const testPayload = { + ...basePayload, + id: uuidv4(), + commission_type: null, + opt_shareclient: false + }; + const res = await create(table, struct, testPayload); + // This will fail without DB connection, but validates the validation logic + expect(res.success).toBe(false); + // Should not have validation errors about commission_type + if (Array.isArray(res.data)) { + expect(res.data.some(err => err.includes('commission_type'))).toBe(false); + } + }); +}); + +const basePayload = { + id: uuidv4(), + name: "Test Record", + json: JSON.stringify({ key: "value" }), + related_forms: "form1,form2", + icon: "test-icon", + date_created: new Date().toISOString().slice(0, 19).replace('T', ' '), + commission_type: "percent", + opt_shareclient: true +}; + describe('ts-orm CRUD operations', () => { const testId = uuidv4(); const relatedId = uuidv4(); - const basePayload = { + const crudPayload = { id: testId, name: "Test Record", json: JSON.stringify({ key: "value" }), @@ -132,7 +238,7 @@ describe('ts-orm CRUD operations', () => { }); test('Create a record', async () => { - const res = await create(table, struct, basePayload); + const res = await create(table, struct, crudPayload); expect(res.success).toBe(true); expect(res.data).toHaveProperty('id'); }); @@ -141,7 +247,7 @@ describe('ts-orm CRUD operations', () => { for (let i = 0; i < 5; i++) { const id = uuidv4(); const payload = { - ...basePayload, + ...crudPayload, id, name: `Record ${i}`, date_created: new Date(Date.now() + i * 1000).toISOString().slice(0, 19).replace('T', ' ') @@ -230,7 +336,7 @@ describe('ts-orm CRUD operations', () => { test('Update the record', async () => { const updatedPayload = { - ...basePayload, + ...crudPayload, name: "Updated Record", date_updated: new Date().toISOString().slice(0, 19).replace('T', ' ') };