diff --git a/src/app/api/admin/constraints/route.ts b/src/app/api/admin/constraints/route.ts index d0afb8c..abfb125 100644 --- a/src/app/api/admin/constraints/route.ts +++ b/src/app/api/admin/constraints/route.ts @@ -2,11 +2,7 @@ import { sql } from 'drizzle-orm'; import { NextResponse } from 'next/server'; import { db } from '@/utils/db'; import { getSession } from '@/utils/session'; - -// Validate identifier format (prevent SQL injection) -function isValidIdentifier(name: string): boolean { - return /^[a-z_]\w*$/i.test(name); -} +import { isValidIdentifier } from '@/validations/DatabaseIdentifierValidation'; // Validate table exists async function validateTable(tableName: string): Promise { @@ -152,10 +148,22 @@ export async function POST(request: Request) { { status: 400 }, ); } - // Basic validation for check expression - prevent obvious SQL injection attempts - if (checkExpression.includes(';') || checkExpression.toLowerCase().includes('drop ')) { + // Validate check expression - prevent SQL injection attempts + // We check for common dangerous patterns but allow valid SQL operators + const dangerousPatterns = [ + /;\s*DROP/i, + /;\s*DELETE/i, + /;\s*UPDATE/i, + /;\s*INSERT/i, + /;\s*ALTER/i, + /;\s*CREATE/i, + /--/, // SQL comments + /\/\*/, // Block comments + ]; + + if (dangerousPatterns.some(pattern => pattern.test(checkExpression))) { return NextResponse.json( - { error: 'Invalid check expression' }, + { error: 'Invalid check expression: contains potentially dangerous SQL' }, { status: 400 }, ); } diff --git a/src/utils/featureConfig.ts b/src/utils/featureConfig.ts index 4fa3128..d6d8c64 100644 --- a/src/utils/featureConfig.ts +++ b/src/utils/featureConfig.ts @@ -40,31 +40,41 @@ export type ConstraintType = { requiresExpression: boolean; }; +// Type definition for the features config structure +type FeaturesConfig = { + features: Feature[]; + dataTypes: DataType[]; + constraintTypes?: ConstraintType[]; + navItems: NavItem[]; +}; + +const config = featuresConfig as FeaturesConfig; + export function getFeatures(): Feature[] { - return featuresConfig.features.filter(f => f.enabled); + return config.features.filter(f => f.enabled); } export function getFeatureById(id: string): Feature | undefined { - return featuresConfig.features.find(f => f.id === id && f.enabled); + return config.features.find(f => f.id === id && f.enabled); } export function getDataTypes(): DataType[] { - return featuresConfig.dataTypes; + return config.dataTypes; } export function getConstraintTypes(): ConstraintType[] { - return (featuresConfig as any).constraintTypes || []; + return config.constraintTypes || []; } export function getNavItems(): NavItem[] { - return featuresConfig.navItems.filter((item) => { + return config.navItems.filter((item) => { const feature = getFeatureById(item.featureId); return feature && feature.enabled; }); } export function getEnabledFeaturesByPriority(priority: string): Feature[] { - return featuresConfig.features.filter( + return config.features.filter( f => f.enabled && f.priority === priority, ); } diff --git a/src/validations/DatabaseIdentifierValidation.test.ts b/src/validations/DatabaseIdentifierValidation.test.ts new file mode 100644 index 0000000..1056caa --- /dev/null +++ b/src/validations/DatabaseIdentifierValidation.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; +import { areValidIdentifiers, isValidIdentifier } from './DatabaseIdentifierValidation'; + +describe('DatabaseIdentifierValidation', () => { + describe('isValidIdentifier', () => { + it('should accept valid identifiers starting with letter', () => { + expect(isValidIdentifier('users')).toBe(true); + expect(isValidIdentifier('my_table')).toBe(true); + expect(isValidIdentifier('Table123')).toBe(true); + expect(isValidIdentifier('camelCaseTable')).toBe(true); + }); + + it('should accept valid identifiers starting with underscore', () => { + expect(isValidIdentifier('_private')).toBe(true); + expect(isValidIdentifier('_table_name')).toBe(true); + }); + + it('should reject identifiers starting with number', () => { + expect(isValidIdentifier('123table')).toBe(false); + expect(isValidIdentifier('1_table')).toBe(false); + }); + + it('should reject identifiers with special characters', () => { + expect(isValidIdentifier('my-table')).toBe(false); + expect(isValidIdentifier('table!name')).toBe(false); + expect(isValidIdentifier('table@name')).toBe(false); + expect(isValidIdentifier('table name')).toBe(false); + expect(isValidIdentifier('table;drop')).toBe(false); + }); + + it('should reject empty or null identifiers', () => { + expect(isValidIdentifier('')).toBe(false); + expect(isValidIdentifier(null as any)).toBe(false); + expect(isValidIdentifier(undefined as any)).toBe(false); + }); + + it('should reject identifiers longer than 63 characters', () => { + const longName = 'a'.repeat(64); + expect(isValidIdentifier(longName)).toBe(false); + }); + + it('should accept identifiers at the 63 character limit', () => { + const maxLengthName = 'a'.repeat(63); + expect(isValidIdentifier(maxLengthName)).toBe(true); + }); + + it('should handle SQL injection attempts', () => { + expect(isValidIdentifier('table\'; DROP TABLE users--')).toBe(false); + expect(isValidIdentifier('table/*comment*/')).toBe(false); + expect(isValidIdentifier('table OR 1=1')).toBe(false); + }); + }); + + describe('areValidIdentifiers', () => { + it('should return true for all valid identifiers', () => { + expect(areValidIdentifiers(['users', 'posts', 'comments'])).toBe(true); + expect(areValidIdentifiers(['_private', 'table_123'])).toBe(true); + }); + + it('should return false if any identifier is invalid', () => { + expect(areValidIdentifiers(['users', 'invalid-name', 'posts'])).toBe(false); + expect(areValidIdentifiers(['123table', 'users'])).toBe(false); + }); + + it('should return true for empty array', () => { + expect(areValidIdentifiers([])).toBe(true); + }); + + it('should return false for array with one invalid identifier', () => { + expect(areValidIdentifiers(['valid_table', ''])).toBe(false); + expect(areValidIdentifiers(['table!name'])).toBe(false); + }); + }); +}); diff --git a/src/validations/DatabaseIdentifierValidation.ts b/src/validations/DatabaseIdentifierValidation.ts new file mode 100644 index 0000000..6755135 --- /dev/null +++ b/src/validations/DatabaseIdentifierValidation.ts @@ -0,0 +1,54 @@ +/** + * Database identifier validation utilities + * + * These functions validate SQL identifiers (table names, column names, constraint names) + * to prevent SQL injection attacks and ensure PostgreSQL naming conventions. + */ + +/** + * Validates if a string is a safe PostgreSQL identifier + * + * PostgreSQL identifiers must: + * - Start with a letter (a-z, A-Z) or underscore (_) + * - Contain only letters, numbers, and underscores + * - Be 1-63 characters long (PostgreSQL limit) + * + * This validation prevents SQL injection by ensuring only safe characters are used. + * + * @param name - The identifier to validate (table name, column name, etc.) + * @returns true if valid, false otherwise + * + * @example + * isValidIdentifier('my_table') // true + * isValidIdentifier('users_2024') // true + * isValidIdentifier('invalid-name!') // false + * isValidIdentifier('123_table') // false (starts with number) + */ +export function isValidIdentifier(name: string): boolean { + if (!name || typeof name !== 'string') { + return false; + } + + // Check length (PostgreSQL identifier limit is 63 characters) + if (name.length === 0 || name.length > 63) { + return false; + } + + // Must start with letter or underscore, followed by letters, numbers, or underscores + // Using case-insensitive flag as suggested by linter + return /^[a-z_]\w*$/i.test(name); +} + +/** + * Validates multiple identifiers at once + * + * @param identifiers - Array of identifier strings to validate + * @returns true if all identifiers are valid, false if any are invalid + * + * @example + * areValidIdentifiers(['table1', 'column_a']) // true + * areValidIdentifiers(['table1', 'invalid!']) // false + */ +export function areValidIdentifiers(identifiers: string[]): boolean { + return identifiers.every(id => isValidIdentifier(id)); +}