From a1fd5b0d126bb5982aa487728c1de8d7b5f7132c Mon Sep 17 00:00:00 2001 From: PJ Date: Fri, 22 Aug 2025 20:55:51 +0530 Subject: [PATCH 1/3] input validation --- src/sync/hash-sync-engine.ts | 61 ++- src/sync/sync-record-validator.ts | 508 ++++++++++++++++++++ tests/hash-sync-engine.test.ts | 32 +- tests/sync/sync-record-validator.test.ts | 586 +++++++++++++++++++++++ 4 files changed, 1155 insertions(+), 32 deletions(-) create mode 100644 src/sync/sync-record-validator.ts create mode 100644 tests/sync/sync-record-validator.test.ts diff --git a/src/sync/hash-sync-engine.ts b/src/sync/hash-sync-engine.ts index f9a1f53..5fd6ef2 100644 --- a/src/sync/hash-sync-engine.ts +++ b/src/sync/hash-sync-engine.ts @@ -2,6 +2,7 @@ import { IndexedDBStore } from '../storage/indexed-db-store'; import { IndexedBaseModel } from '../models/indexed-base-model'; import { ModelRegistry, ModelMetadata } from '../model-registry'; import { SchemaHasher } from '../hash'; +import { SyncRecordValidator } from './sync-record-validator'; import { makeObservable, observable, action, computed } from 'mobx'; /** @@ -174,13 +175,23 @@ export class HashSyncEngine { hash: await this.hashData(record.data), }; + // Validate before queuing + const validation = SyncRecordValidator.validate(syncRecord); + if (!validation.isValid) { + console.error('Attempting to queue invalid sync record:', validation.errors); + throw new Error(`Cannot queue invalid sync record: ${validation.errors[0]?.message}`); + } + + // Sanitize before storing + const sanitizedRecord = SyncRecordValidator.sanitize(syncRecord); + // Store the sync record directly, bypassing the normal model sync queue await this.store.put('_sync', { - id: syncRecord.id, - modelName: record.modelName, - modelId: record.modelId, - operation: record.operation, - data: syncRecord, // Store the complete sync record + id: sanitizedRecord.id, + modelName: sanitizedRecord.modelName, + modelId: sanitizedRecord.modelId, + operation: sanitizedRecord.operation, + data: sanitizedRecord, // Store the complete sync record status: 'pending', createdAt: Date.now(), syncId: null @@ -419,20 +430,30 @@ export class HashSyncEngine { private async applyRemoteRecord(record: SyncRecord): Promise { try { - const ModelClass = ModelRegistry.getModel(record.modelName); + // Validate the record first for security + const validation = SyncRecordValidator.validate(record); + if (!validation.isValid) { + console.error('Invalid sync record received:', validation.errors); + throw new Error(`Invalid sync record: ${validation.errors[0]?.message}`); + } + + // Sanitize the record to remove any potentially dangerous content + const sanitizedRecord = SyncRecordValidator.sanitize(record); + + const ModelClass = ModelRegistry.getModel(sanitizedRecord.modelName); if (!ModelClass) { - console.warn(`Unknown model type: ${record.modelName}`); + console.warn(`Unknown model type: ${sanitizedRecord.modelName}`); return; } - switch (record.operation) { + switch (sanitizedRecord.operation) { case 'create': case 'update': // Check for conflicts - const existing = await (ModelClass as any).load(record.modelId); - if (existing && existing._version >= record.version) { + const existing = await (ModelClass as any).load(sanitizedRecord.modelId); + if (existing && existing._version >= sanitizedRecord.version) { // Local version is newer or equal, apply conflict resolution - const resolvedData = await this.resolveConflict(existing, record); + const resolvedData = await this.resolveConflict(existing, sanitizedRecord); if (resolvedData) { if (typeof existing.update === 'function') { existing.update(resolvedData); @@ -445,31 +466,31 @@ export class HashSyncEngine { // Remote version is newer, apply directly if (existing) { if (typeof existing.update === 'function') { - existing.update(record.data); + existing.update(sanitizedRecord.data); } else { - Object.assign(existing, record.data); + Object.assign(existing, sanitizedRecord.data); } - existing._version = record.version; + existing._version = sanitizedRecord.version; await existing.save(); } else { // For create operations, we need to store directly in IndexedDB // since we can't instantiate abstract classes - await this.store.put(record.modelName, { - ...record.data, - id: record.modelId, - _version: record.version, + await this.store.put(sanitizedRecord.modelName, { + ...sanitizedRecord.data, + id: sanitizedRecord.modelId, + _version: sanitizedRecord.version, }); } } break; case 'delete': - const modelToDelete = await (ModelClass as any).load(record.modelId); + const modelToDelete = await (ModelClass as any).load(sanitizedRecord.modelId); if (modelToDelete) { await modelToDelete.delete(); } else { // If model instance doesn't exist, delete directly from store - await this.store.delete(record.modelName, record.modelId); + await this.store.delete(sanitizedRecord.modelName, sanitizedRecord.modelId); } break; } diff --git a/src/sync/sync-record-validator.ts b/src/sync/sync-record-validator.ts new file mode 100644 index 0000000..ea54c75 --- /dev/null +++ b/src/sync/sync-record-validator.ts @@ -0,0 +1,508 @@ +import { SyncRecord, SyncOperation } from './hash-sync-engine'; +import { ModelRegistry } from '../model-registry'; + +export interface ValidationError { + field: string; + message: string; + value?: any; +} + +export interface ValidationResult { + isValid: boolean; + errors: ValidationError[]; +} + +/** + * Validates sync records to prevent security vulnerabilities and data corruption + */ +export class SyncRecordValidator { + private static readonly MAX_STRING_LENGTH = 10000; + private static readonly MAX_OBJECT_DEPTH = 10; + private static readonly MAX_ARRAY_LENGTH = 1000; + private static readonly VALID_OPERATIONS: SyncOperation[] = ['create', 'update', 'delete']; + + /** + * Validates a sync record completely + */ + static validate(record: any): ValidationResult { + const errors: ValidationError[] = []; + + // Check if record exists + if (!record || typeof record !== 'object') { + return { + isValid: false, + errors: [{ field: 'record', message: 'Record must be a valid object' }] + }; + } + + // Validate required fields + this.validateRequiredFields(record, errors); + + // Validate field types and values + this.validateFieldTypes(record, errors); + + // Validate operation-specific requirements + this.validateOperationRequirements(record, errors); + + // Validate data payload + if (record.data) { + this.validateDataPayload(record.data, errors, 'data'); + } + + // Check for potentially malicious content + this.validateSecurityConstraints(record, errors); + + return { + isValid: errors.length === 0, + errors + }; + } + + /** + * Validates required fields are present + */ + private static validateRequiredFields(record: any, errors: ValidationError[]): void { + const requiredFields = ['id', 'modelName', 'modelId', 'operation', 'version', 'timestamp', 'clientId', 'hash']; + + for (const field of requiredFields) { + if (record[field] === undefined || record[field] === null) { + errors.push({ + field, + message: `Required field '${field}' is missing`, + value: record[field] + }); + } + } + } + + /** + * Validates field types and basic constraints + */ + private static validateFieldTypes(record: any, errors: ValidationError[]): void { + // Validate ID fields are strings + const stringFields = ['id', 'modelName', 'modelId', 'clientId', 'hash']; + for (const field of stringFields) { + if (record[field] !== undefined && typeof record[field] !== 'string') { + errors.push({ + field, + message: `Field '${field}' must be a string`, + value: record[field] + }); + } else if (record[field] && record[field].length > this.MAX_STRING_LENGTH) { + errors.push({ + field, + message: `Field '${field}' exceeds maximum length of ${this.MAX_STRING_LENGTH}`, + value: record[field]?.substring(0, 100) + '...' + }); + } + } + + // Validate version is a positive number + if (record.version !== undefined) { + if (typeof record.version !== 'number' || record.version < 0) { + errors.push({ + field: 'version', + message: 'Version must be a non-negative number', + value: record.version + }); + } + } + + // Validate timestamp + if (record.timestamp !== undefined) { + if (typeof record.timestamp !== 'number' || record.timestamp < 0) { + errors.push({ + field: 'timestamp', + message: 'Timestamp must be a valid positive number', + value: record.timestamp + }); + } + + // Check if timestamp is reasonable (not too far in past or future) + const now = Date.now(); + const oneYearMs = 365 * 24 * 60 * 60 * 1000; + if (Math.abs(record.timestamp - now) > oneYearMs) { + errors.push({ + field: 'timestamp', + message: 'Timestamp is unreasonably far from current time', + value: new Date(record.timestamp).toISOString() + }); + } + } + + // Validate operation + if (record.operation && !this.VALID_OPERATIONS.includes(record.operation)) { + errors.push({ + field: 'operation', + message: `Operation must be one of: ${this.VALID_OPERATIONS.join(', ')}`, + value: record.operation + }); + } + } + + /** + * Validates operation-specific requirements + */ + private static validateOperationRequirements(record: any, errors: ValidationError[]): void { + if (!record.operation) return; + + switch (record.operation) { + case 'create': + case 'update': + if (!record.data || typeof record.data !== 'object') { + errors.push({ + field: 'data', + message: `Operation '${record.operation}' requires data payload`, + value: record.data + }); + } + break; + + case 'delete': + // Delete operations may or may not have data + break; + + default: + // Already validated in validateFieldTypes + break; + } + + // Validate model exists in registry + if (record.modelName) { + const model = ModelRegistry.getModel(record.modelName); + if (!model) { + errors.push({ + field: 'modelName', + message: `Unknown model type: ${record.modelName}`, + value: record.modelName + }); + } + } + } + + /** + * Recursively validates data payload for security issues + */ + private static validateDataPayload( + data: any, + errors: ValidationError[], + path: string, + depth: number = 0 + ): void { + // Check depth to prevent deeply nested objects + if (depth > this.MAX_OBJECT_DEPTH) { + errors.push({ + field: path, + message: `Object nesting exceeds maximum depth of ${this.MAX_OBJECT_DEPTH}`, + value: 'Object too deep' + }); + return; + } + + // Handle null/undefined + if (data === null || data === undefined) { + return; + } + + // Handle arrays + if (Array.isArray(data)) { + if (data.length > this.MAX_ARRAY_LENGTH) { + errors.push({ + field: path, + message: `Array exceeds maximum length of ${this.MAX_ARRAY_LENGTH}`, + value: `Array length: ${data.length}` + }); + return; + } + + data.forEach((item, index) => { + this.validateDataPayload(item, errors, `${path}[${index}]`, depth + 1); + }); + return; + } + + // Handle objects + if (typeof data === 'object') { + const keys = Object.keys(data); + + // Check for too many keys + if (keys.length > 1000) { + errors.push({ + field: path, + message: 'Object has too many properties (max 1000)', + value: `Property count: ${keys.length}` + }); + return; + } + + // Recursively validate each property + for (const key of keys) { + // Check for prototype pollution attempts + if (key === '__proto__' || key === 'constructor' || key === 'prototype') { + errors.push({ + field: `${path}.${key}`, + message: 'Potentially malicious property name detected', + value: key + }); + continue; + } + + // Validate key length + if (key.length > 255) { + errors.push({ + field: `${path}.${key}`, + message: 'Property name exceeds maximum length of 255', + value: key.substring(0, 50) + '...' + }); + continue; + } + + this.validateDataPayload(data[key], errors, `${path}.${key}`, depth + 1); + } + return; + } + + // Handle primitives + if (typeof data === 'string') { + if (data.length > this.MAX_STRING_LENGTH) { + errors.push({ + field: path, + message: `String exceeds maximum length of ${this.MAX_STRING_LENGTH}`, + value: data.substring(0, 100) + '...' + }); + } + + // Check for potential script injection + if (this.containsScriptTags(data)) { + errors.push({ + field: path, + message: 'String contains potentially malicious script tags', + value: data.substring(0, 100) + }); + } + } + + // Numbers should be finite + if (typeof data === 'number' && !isFinite(data)) { + errors.push({ + field: path, + message: 'Number must be finite', + value: data + }); + } + } + + /** + * Validates security constraints + */ + private static validateSecurityConstraints(record: any, errors: ValidationError[]): void { + // Check for SQL injection patterns in string fields + const stringFields = ['id', 'modelName', 'modelId', 'clientId']; + for (const field of stringFields) { + if (record[field] && this.containsSqlInjectionPattern(record[field])) { + errors.push({ + field, + message: 'Field contains potentially malicious SQL patterns', + value: record[field] + }); + } + } + + // Validate hash format (should be hexadecimal) + if (record.hash && !/^[a-f0-9]+$/i.test(record.hash)) { + errors.push({ + field: 'hash', + message: 'Hash must be a valid hexadecimal string', + value: record.hash + }); + } + + // Check total size of record + const recordSize = JSON.stringify(record).length; + if (recordSize > 1024 * 1024) { // 1MB limit + errors.push({ + field: 'record', + message: 'Record size exceeds 1MB limit', + value: `Size: ${recordSize} bytes` + }); + } + } + + /** + * Checks for script tags in strings + */ + private static containsScriptTags(value: string): boolean { + const scriptPatterns = [ + /]/i, + /<\/script>/i, + /javascript:/i, + /on\w+\s*=/i, // Event handlers like onclick= + /]/i, + /]/i, + /]/i + ]; + + return scriptPatterns.some(pattern => pattern.test(value)); + } + + /** + * Checks for SQL injection patterns + */ + private static containsSqlInjectionPattern(value: string): boolean { + const sqlPatterns = [ + /(\b(DELETE|DROP|EXEC(UTE)?|INSERT|SELECT|UNION|UPDATE)\b)/i, + /(--|\||;|\/\*|\*\/)/, + /(\bOR\b\s*\d+\s*=\s*\d+)/i, + /(\bAND\b\s*\d+\s*=\s*\d+)/i, + /(\'|\")(\s*)(OR|AND)(\s*)(\d+|\'|\")(\s*)=/i + ]; + + return sqlPatterns.some(pattern => pattern.test(value)); + } + + /** + * Sanitizes a record by removing or escaping potentially dangerous content + * Returns a new sanitized copy, doesn't modify the original + */ + static sanitize(record: SyncRecord): SyncRecord { + // Use a custom deep clone that properly handles special values + const sanitized = this.deepClone(record); + + // Sanitize string fields + const stringFields = ['id', 'modelName', 'modelId', 'clientId', 'hash']; + for (const field of stringFields) { + if (sanitized[field] && typeof sanitized[field] === 'string') { + sanitized[field] = this.sanitizeString(sanitized[field]); + } + } + + // Recursively sanitize data payload + if (sanitized.data) { + sanitized.data = this.sanitizeData(sanitized.data); + } + + return sanitized; + } + + /** + * Deep clones an object while handling special values + */ + private static deepClone(obj: any): any { + if (obj === null || obj === undefined) { + return obj; + } + + if (obj instanceof Date) { + return new Date(obj.getTime()); + } + + if (Array.isArray(obj)) { + return obj.map(item => this.deepClone(item)); + } + + if (typeof obj === 'object') { + const cloned: any = {}; + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + // Skip dangerous property names during cloning + if (key === '__proto__' || key === 'constructor' || key === 'prototype') { + continue; + } + cloned[key] = this.deepClone(obj[key]); + } + } + return cloned; + } + + // Handle special number values + if (typeof obj === 'number') { + if (!isFinite(obj)) { + return 0; // Replace Infinity/NaN with 0 + } + } + + return obj; + } + + /** + * Sanitizes a string value + */ + private static sanitizeString(value: string): string { + // Remove any HTML/script tags + let sanitized = value.replace(/<[^>]*>/g, ''); + + // For SQL keywords, only remove them if they appear as standalone commands + // Don't remove them from normal text like "Update" in "Remote Update" + sanitized = sanitized.replace(/;\s*(DELETE|DROP|EXEC(UTE)?|INSERT|SELECT|UNION|UPDATE)\s+/gi, '; '); + + // Remove script-related patterns + sanitized = sanitized.replace(/javascript:/gi, ''); + sanitized = sanitized.replace(/on\w+\s*=/gi, ''); + + // Escape HTML special characters + sanitized = sanitized + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(/\//g, '/'); + + // Truncate if too long + if (sanitized.length > this.MAX_STRING_LENGTH) { + sanitized = sanitized.substring(0, this.MAX_STRING_LENGTH); + } + + return sanitized; + } + + /** + * Recursively sanitizes data objects + */ + private static sanitizeData(data: any, depth: number = 0): any { + if (depth > this.MAX_OBJECT_DEPTH) { + return null; // Truncate deeply nested objects + } + + if (data === null || data === undefined) { + return data; + } + + if (Array.isArray(data)) { + return data + .slice(0, this.MAX_ARRAY_LENGTH) + .map(item => this.sanitizeData(item, depth + 1)); + } + + if (typeof data === 'object') { + const sanitized: any = {}; + const keys = Object.keys(data).slice(0, 1000); + + for (const key of keys) { + // Skip dangerous property names entirely, don't add them to sanitized + if (key === '__proto__' || key === 'constructor' || key === 'prototype') { + continue; + } + + const sanitizedKey = key.length > 255 ? key.substring(0, 255) : key; + sanitized[sanitizedKey] = this.sanitizeData(data[key], depth + 1); + } + + return sanitized; + } + + if (typeof data === 'string') { + return this.sanitizeString(data); + } + + if (typeof data === 'number') { + // Replace Infinity/NaN with 0 + if (!isFinite(data)) { + return 0; + } + return data; + } + + return data; + } +} \ No newline at end of file diff --git a/tests/hash-sync-engine.test.ts b/tests/hash-sync-engine.test.ts index 7b5af4a..5af0658 100644 --- a/tests/hash-sync-engine.test.ts +++ b/tests/hash-sync-engine.test.ts @@ -260,7 +260,7 @@ describe('HashSyncEngine', () => { version: 2, timestamp: Date.now() + 1000, // Newer clientId: 'remote-client', - hash: 'remote-hash', + hash: 'abc123def456789', // Valid hex hash }; // Apply remote record (this would normally be done through WebSocket) @@ -288,7 +288,7 @@ describe('HashSyncEngine', () => { version: 2, // Lower version timestamp: Date.now() - 1000, // Older clientId: 'remote-client', - hash: 'remote-hash', + hash: 'fedcba987654321', // Valid hex hash }; // Apply remote record @@ -391,7 +391,7 @@ describe('HashSyncEngine', () => { version: 1, timestamp: Date.now(), clientId: 'test-client', - hash: 'test-hash', + hash: '1234567890abcdef', // Valid hex hash }; // Verify record has all required fields @@ -466,20 +466,28 @@ describe('HashSyncEngine Edge Cases', () => { version: 1, timestamp: Date.now(), clientId: 'test-client', - hash: 'test-hash', + hash: 'abcdef1234567890', // Valid hex hash }; - // Suppress expected console.warn for this test - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + // Suppress expected console output for this test + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - // Should not throw error - await expect((syncEngine as any).applyRemoteRecord(invalidRecord)).resolves.toBeUndefined(); + // Should handle the error gracefully without throwing (caught internally) + await (syncEngine as any).applyRemoteRecord(invalidRecord); - // Verify the warning was called - expect(consoleSpy).toHaveBeenCalledWith('Unknown model type: NonExistentModel'); + // Verify the error was logged + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Invalid sync record received:'), + expect.arrayContaining([ + expect.objectContaining({ + field: 'modelName', + message: expect.stringContaining('Unknown model type') + }) + ]) + ); - // Restore console.warn - consoleSpy.mockRestore(); + // Restore console + consoleErrorSpy.mockRestore(); }); test('handles sync engine without WebSocket connection', () => { diff --git a/tests/sync/sync-record-validator.test.ts b/tests/sync/sync-record-validator.test.ts new file mode 100644 index 0000000..d97e981 --- /dev/null +++ b/tests/sync/sync-record-validator.test.ts @@ -0,0 +1,586 @@ +import { SyncRecordValidator } from '../../src/sync/sync-record-validator'; +import { SyncRecord } from '../../src/sync/hash-sync-engine'; +import { ModelRegistry } from '../../src/model-registry'; + +// Mock ModelRegistry +jest.mock('../../src/model-registry', () => ({ + ModelRegistry: { + getModel: jest.fn() + } +})); + +describe('SyncRecordValidator', () => { + beforeEach(() => { + jest.clearAllMocks(); + (ModelRegistry.getModel as jest.Mock).mockReturnValue({}); // Model exists by default + }); + + describe('validate', () => { + const validRecord: SyncRecord = { + id: 'sync_123', + modelName: 'TestModel', + modelId: 'model_123', + operation: 'create', + data: { name: 'Test', value: 123 }, + version: 1, + timestamp: Date.now(), + clientId: 'client_123', + hash: 'abc123def456' + }; + + describe('valid records', () => { + test('validates a completely valid record', () => { + const result = SyncRecordValidator.validate(validRecord); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + test('validates update operation', () => { + const record = { ...validRecord, operation: 'update' as const }; + const result = SyncRecordValidator.validate(record); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + test('validates delete operation without data', () => { + const record = { ...validRecord, operation: 'delete' as const, data: undefined }; + const result = SyncRecordValidator.validate(record); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + test('validates nested data structures', () => { + const record = { + ...validRecord, + data: { + name: 'Test', + nested: { + level1: { + level2: { + value: 'deep' + } + } + }, + array: [1, 2, 3, { nested: true }] + } + }; + + const result = SyncRecordValidator.validate(record); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + }); + + describe('missing required fields', () => { + test('rejects null record', () => { + const result = SyncRecordValidator.validate(null); + + expect(result.isValid).toBe(false); + expect(result.errors[0].message).toContain('must be a valid object'); + }); + + test('rejects undefined record', () => { + const result = SyncRecordValidator.validate(undefined); + + expect(result.isValid).toBe(false); + expect(result.errors[0].message).toContain('must be a valid object'); + }); + + test('rejects record missing id', () => { + const record = { ...validRecord, id: undefined }; + const result = SyncRecordValidator.validate(record); + + expect(result.isValid).toBe(false); + expect(result.errors).toContainEqual( + expect.objectContaining({ + field: 'id', + message: "Required field 'id' is missing" + }) + ); + }); + + test('rejects record missing multiple required fields', () => { + const record = { + modelName: 'Test', + operation: 'create' + }; + + const result = SyncRecordValidator.validate(record); + + expect(result.isValid).toBe(false); + expect(result.errors.length).toBeGreaterThan(5); + }); + }); + + describe('field type validation', () => { + test('rejects non-string id', () => { + const record = { ...validRecord, id: 123 as any }; + const result = SyncRecordValidator.validate(record); + + expect(result.isValid).toBe(false); + expect(result.errors).toContainEqual( + expect.objectContaining({ + field: 'id', + message: "Field 'id' must be a string" + }) + ); + }); + + test('rejects negative version', () => { + const record = { ...validRecord, version: -1 }; + const result = SyncRecordValidator.validate(record); + + expect(result.isValid).toBe(false); + expect(result.errors).toContainEqual( + expect.objectContaining({ + field: 'version', + message: 'Version must be a non-negative number' + }) + ); + }); + + test('rejects invalid operation', () => { + const record = { ...validRecord, operation: 'invalid' as any }; + const result = SyncRecordValidator.validate(record); + + expect(result.isValid).toBe(false); + expect(result.errors).toContainEqual( + expect.objectContaining({ + field: 'operation', + message: expect.stringContaining('must be one of') + }) + ); + }); + + test('rejects unreasonable timestamp', () => { + const record = { ...validRecord, timestamp: Date.now() + (400 * 24 * 60 * 60 * 1000) }; + const result = SyncRecordValidator.validate(record); + + expect(result.isValid).toBe(false); + expect(result.errors).toContainEqual( + expect.objectContaining({ + field: 'timestamp', + message: 'Timestamp is unreasonably far from current time' + }) + ); + }); + + test('rejects non-hexadecimal hash', () => { + const record = { ...validRecord, hash: 'not-hex-@#$' }; + const result = SyncRecordValidator.validate(record); + + expect(result.isValid).toBe(false); + expect(result.errors).toContainEqual( + expect.objectContaining({ + field: 'hash', + message: 'Hash must be a valid hexadecimal string' + }) + ); + }); + }); + + describe('operation requirements', () => { + test('rejects create without data', () => { + const record = { ...validRecord, operation: 'create' as const, data: undefined }; + const result = SyncRecordValidator.validate(record); + + expect(result.isValid).toBe(false); + expect(result.errors).toContainEqual( + expect.objectContaining({ + field: 'data', + message: "Operation 'create' requires data payload" + }) + ); + }); + + test('rejects update without data', () => { + const record = { ...validRecord, operation: 'update' as const, data: null }; + const result = SyncRecordValidator.validate(record); + + expect(result.isValid).toBe(false); + expect(result.errors).toContainEqual( + expect.objectContaining({ + field: 'data', + message: "Operation 'update' requires data payload" + }) + ); + }); + + test('rejects unknown model type', () => { + (ModelRegistry.getModel as jest.Mock).mockReturnValue(undefined); + + const result = SyncRecordValidator.validate(validRecord); + + expect(result.isValid).toBe(false); + expect(result.errors).toContainEqual( + expect.objectContaining({ + field: 'modelName', + message: 'Unknown model type: TestModel' + }) + ); + }); + }); + + describe('security validation', () => { + test('rejects script tags in data', () => { + const record = { + ...validRecord, + data: { + name: '', + value: 123 + } + }; + + const result = SyncRecordValidator.validate(record); + + expect(result.isValid).toBe(false); + expect(result.errors).toContainEqual( + expect.objectContaining({ + field: 'data.name', + message: 'String contains potentially malicious script tags' + }) + ); + }); + + test('rejects SQL injection patterns', () => { + const record = { + ...validRecord, + modelId: "'; DROP TABLE users; --" + }; + + const result = SyncRecordValidator.validate(record); + + expect(result.isValid).toBe(false); + expect(result.errors).toContainEqual( + expect.objectContaining({ + field: 'modelId', + message: 'Field contains potentially malicious SQL patterns' + }) + ); + }); + + test('rejects prototype pollution attempts', () => { + const record = { + ...validRecord, + data: { + __proto__: { isAdmin: true }, + constructor: { dangerous: true }, + normal: 'value' + } + }; + + const result = SyncRecordValidator.validate(record); + + expect(result.isValid).toBe(false); + + const protoErrors = result.errors.filter(e => + e.message.includes('Potentially malicious property name') + ); + expect(protoErrors.length).toBeGreaterThan(0); + }); + + test('rejects deeply nested objects', () => { + let deepData: any = { value: 'bottom' }; + for (let i = 0; i < 15; i++) { + deepData = { nested: deepData }; + } + + const record = { ...validRecord, data: deepData }; + const result = SyncRecordValidator.validate(record); + + expect(result.isValid).toBe(false); + expect(result.errors).toContainEqual( + expect.objectContaining({ + message: expect.stringContaining('exceeds maximum depth') + }) + ); + }); + + test('rejects overly large arrays', () => { + const record = { + ...validRecord, + data: { + bigArray: new Array(2000).fill('item') + } + }; + + const result = SyncRecordValidator.validate(record); + + expect(result.isValid).toBe(false); + expect(result.errors).toContainEqual( + expect.objectContaining({ + field: 'data.bigArray', + message: expect.stringContaining('exceeds maximum length') + }) + ); + }); + + test('rejects overly long strings', () => { + const record = { + ...validRecord, + data: { + longString: 'x'.repeat(11000) + } + }; + + const result = SyncRecordValidator.validate(record); + + expect(result.isValid).toBe(false); + expect(result.errors).toContainEqual( + expect.objectContaining({ + field: 'data.longString', + message: expect.stringContaining('exceeds maximum length') + }) + ); + }); + + test('rejects records exceeding size limit', () => { + const record = { + ...validRecord, + data: { + huge: 'x'.repeat(1024 * 1024) // 1MB of 'x' + } + }; + + const result = SyncRecordValidator.validate(record); + + expect(result.isValid).toBe(false); + expect(result.errors).toContainEqual( + expect.objectContaining({ + field: 'record', + message: 'Record size exceeds 1MB limit' + }) + ); + }); + + test('rejects non-finite numbers', () => { + const record = { + ...validRecord, + data: { + infinite: Infinity, + notANumber: NaN + } + }; + + const result = SyncRecordValidator.validate(record); + + expect(result.isValid).toBe(false); + + const numberErrors = result.errors.filter(e => + e.message.includes('Number must be finite') + ); + expect(numberErrors).toHaveLength(2); + }); + }); + }); + + describe('sanitize', () => { + test('sanitizes script tags', () => { + const record: SyncRecord = { + id: 'test', + modelName: 'Test', + modelId: 'test_123', + operation: 'create', + data: { + name: 'Normal text', + safe: 'No issues here' + }, + version: 1, + timestamp: Date.now(), + clientId: 'client', + hash: 'abc123' + }; + + const sanitized = SyncRecordValidator.sanitize(record); + + expect(sanitized.data.name).not.toContain('