diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 159c6dc5..015d2cac 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -16,3 +16,7 @@ **Vulnerability:** A custom buffer length check (`if (signatureBytes.length !== expectedSignatureBytes.length) return false`) before calling `crypto.timingSafeEqual()` leaked the length of the expected signature, enabling timing attacks. **Learning:** Never use custom 'homebrew' buffer-padding logic to match lengths for `crypto.timingSafeEqual()`, as early returns leak the length of the secret. **Prevention:** Ensure inputs are hashed to a uniform length (e.g., using `crypto.createHash('sha256')`) before comparison. +## 2026-06-10 - DDL Injection in ERD Model +**Vulnerability:** The `ERDModel` in `packages/web/src/lib/erd.ts` allows arbitrary strings for column types. This can lead to DDL injection if user input is used for column types, allowing users to inject malicious SQL (e.g., `TEXT\n);\nDROP TABLE users;--`). +**Learning:** Even internal tool models generating SQL/DDL need strict validation on all properties, not just identifiers (table/column names), because types are concatenated directly into the DDL string. +**Prevention:** Implement strict validation for column types (e.g., alphanumeric and parentheses only) to prevent injection of special characters like semicolons, quotes, and newlines. diff --git a/packages/web/src/lib/erd.test.ts b/packages/web/src/lib/erd.test.ts index 0ddcf189..7485da6f 100644 --- a/packages/web/src/lib/erd.test.ts +++ b/packages/web/src/lib/erd.test.ts @@ -1,188 +1,234 @@ -import { describe, expect, it, beforeEach } from 'vitest' -import { ERDModel } from './erd' +import { describe, expect, it, beforeEach } from "vitest"; +import { ERDModel } from "./erd"; -describe('ERDModel', () => { - let model: ERDModel +describe("ERDModel", () => { + let model: ERDModel; beforeEach(() => { - model = new ERDModel() - }) - - describe('Table Management', () => { - it('should add a new table', () => { - const table = model.addTable('users') - expect(table.name).toBe('users') - expect(model.getTables().length).toBe(1) - expect(model.getTable('users')).toBe(table) - }) - - it('should throw when adding duplicate table', () => { - model.addTable('users') - expect(() => model.addTable('users')).toThrowError("Table 'users' already exists.") - }) - - it('should reject non-snake-case table names', () => { - expect(() => model.addTable('UserProfiles')).toThrowError( - "Table 'UserProfiles' must be snake_case." - ) - expect(() => model.addTable('user-profiles')).toThrowError( - "Table 'user-profiles' must be snake_case." - ) - }) - - it('should return undefined for non-existent table', () => { - expect(model.getTable('non_existent')).toBeUndefined() - }) - }) - - describe('Column Management', () => { - it('should add a column to an existing table', () => { - model.addTable('users') - model.addColumn('users', { name: 'id', type: 'integer' }) - const table = model.getTable('users') - expect(table?.columns.length).toBe(1) - expect(table?.columns[0].name).toBe('id') - }) - - it('should throw when adding a column to a non-existent table', () => { + model = new ERDModel(); + }); + + describe("Table Management", () => { + it("should add a new table", () => { + const table = model.addTable("users"); + expect(table.name).toBe("users"); + expect(model.getTables().length).toBe(1); + expect(model.getTable("users")).toBe(table); + }); + + it("should throw when adding duplicate table", () => { + model.addTable("users"); + expect(() => model.addTable("users")).toThrowError( + "Table 'users' already exists.", + ); + }); + + it("should reject non-snake-case table names", () => { + expect(() => model.addTable("UserProfiles")).toThrowError( + "Table 'UserProfiles' must be snake_case.", + ); + expect(() => model.addTable("user-profiles")).toThrowError( + "Table 'user-profiles' must be snake_case.", + ); + }); + + it("should return undefined for non-existent table", () => { + expect(model.getTable("non_existent")).toBeUndefined(); + }); + }); + + describe("Column Management", () => { + it("should add a column to an existing table", () => { + model.addTable("users"); + model.addColumn("users", { name: "id", type: "integer" }); + const table = model.getTable("users"); + expect(table?.columns.length).toBe(1); + expect(table?.columns[0].name).toBe("id"); + }); + + it("should throw when adding a column to a non-existent table", () => { expect(() => - model.addColumn('non_existent', { name: 'id', type: 'integer' }) - ).toThrowError("Table 'non_existent' does not exist.") - }) + model.addColumn("non_existent", { name: "id", type: "integer" }), + ).toThrowError("Table 'non_existent' does not exist."); + }); - it('should throw when adding a duplicate column to a table', () => { - model.addTable('users') - model.addColumn('users', { name: 'id', type: 'integer' }) + it("should throw when adding a duplicate column to a table", () => { + model.addTable("users"); + model.addColumn("users", { name: "id", type: "integer" }); expect(() => - model.addColumn('users', { name: 'id', type: 'string' }) - ).toThrowError("Column 'id' already exists in table 'users'.") - }) + model.addColumn("users", { name: "id", type: "string" }), + ).toThrowError("Column 'id' already exists in table 'users'."); + }); - it('should reject non-snake-case column names', () => { - model.addTable('users') + it("should reject non-snake-case column names", () => { + model.addTable("users"); expect(() => - model.addColumn('users', { name: 'createdAt', type: 'timestamp' }) - ).toThrowError("Column 'createdAt' must be snake_case.") + model.addColumn("users", { name: "createdAt", type: "timestamp" }), + ).toThrowError("Column 'createdAt' must be snake_case."); expect(() => - model.addColumn('users', { name: 'created__at', type: 'timestamp' }) - ).toThrowError("Column 'created__at' must be snake_case.") - }) - }) + model.addColumn("users", { name: "created__at", type: "timestamp" }), + ).toThrowError("Column 'created__at' must be snake_case."); + }); - describe('Foreign Key Management', () => { + it("should reject invalid characters in column types to prevent DDL injection", () => { + model.addTable("users"); + expect(() => + model.addColumn("users", { + name: "bio", + type: "TEXT\n);\nDROP TABLE users;--", + }), + ).toThrowError( + "Column type 'TEXT\n);\nDROP TABLE users;--' contains invalid characters.", + ); + expect(() => + model.addColumn("users", { + name: "age", + type: "INT; SELECT * FROM users", + }), + ).toThrowError( + "Column type 'INT; SELECT * FROM users' contains invalid characters.", + ); + }); + }); + + describe("Foreign Key Management", () => { beforeEach(() => { - model.addTable('users') - model.addColumn('users', { name: 'id', type: 'integer' }) - model.addTable('posts') - model.addColumn('posts', { name: 'id', type: 'integer' }) - model.addColumn('posts', { name: 'user_id', type: 'integer' }) - }) - - it('should add a foreign key successfully', () => { - model.addForeignKey('posts', { - columnName: 'user_id', - referenceTable: 'users', - referenceColumn: 'id', - }) - const postsTable = model.getTable('posts') - expect(postsTable?.foreignKeys.length).toBe(1) - expect(postsTable?.foreignKeys[0].referenceTable).toBe('users') - }) - - it('should throw when adding foreign key to non-existent table', () => { + model.addTable("users"); + model.addColumn("users", { name: "id", type: "integer" }); + model.addTable("posts"); + model.addColumn("posts", { name: "id", type: "integer" }); + model.addColumn("posts", { name: "user_id", type: "integer" }); + }); + + it("should add a foreign key successfully", () => { + model.addForeignKey("posts", { + columnName: "user_id", + referenceTable: "users", + referenceColumn: "id", + }); + const postsTable = model.getTable("posts"); + expect(postsTable?.foreignKeys.length).toBe(1); + expect(postsTable?.foreignKeys[0].referenceTable).toBe("users"); + }); + + it("should throw when adding foreign key to non-existent table", () => { expect(() => { - model.addForeignKey('non_existent', { - columnName: 'user_id', - referenceTable: 'users', - referenceColumn: 'id', - }) - }).toThrowError("Table 'non_existent' does not exist.") - }) - - it('should throw when foreign key column does not exist', () => { + model.addForeignKey("non_existent", { + columnName: "user_id", + referenceTable: "users", + referenceColumn: "id", + }); + }).toThrowError("Table 'non_existent' does not exist."); + }); + + it("should throw when foreign key column does not exist", () => { expect(() => { - model.addForeignKey('posts', { - columnName: 'non_existent_col', - referenceTable: 'users', - referenceColumn: 'id', - }) - }).toThrowError("Column 'non_existent_col' does not exist in table 'posts'.") - }) - - it('should throw when reference table does not exist', () => { + model.addForeignKey("posts", { + columnName: "non_existent_col", + referenceTable: "users", + referenceColumn: "id", + }); + }).toThrowError( + "Column 'non_existent_col' does not exist in table 'posts'.", + ); + }); + + it("should throw when reference table does not exist", () => { expect(() => { - model.addForeignKey('posts', { - columnName: 'user_id', - referenceTable: 'non_existent_ref', - referenceColumn: 'id', - }) - }).toThrowError("Reference table 'non_existent_ref' does not exist.") - }) - - it('should throw when reference column does not exist in reference table', () => { + model.addForeignKey("posts", { + columnName: "user_id", + referenceTable: "non_existent_ref", + referenceColumn: "id", + }); + }).toThrowError("Reference table 'non_existent_ref' does not exist."); + }); + + it("should throw when reference column does not exist in reference table", () => { expect(() => { - model.addForeignKey('posts', { - columnName: 'user_id', - referenceTable: 'users', - referenceColumn: 'non_existent_col', - }) - }).toThrowError("Reference column 'non_existent_col' does not exist in table 'users'.") - }) - - it('should reject non-snake-case foreign key object names', () => { + model.addForeignKey("posts", { + columnName: "user_id", + referenceTable: "users", + referenceColumn: "non_existent_col", + }); + }).toThrowError( + "Reference column 'non_existent_col' does not exist in table 'users'.", + ); + }); + + it("should reject non-snake-case foreign key object names", () => { expect(() => { - model.addForeignKey('posts', { - columnName: 'userId', - referenceTable: 'users', - referenceColumn: 'id', - }) - }).toThrowError("Column 'userId' must be snake_case.") + model.addForeignKey("posts", { + columnName: "userId", + referenceTable: "users", + referenceColumn: "id", + }); + }).toThrowError("Column 'userId' must be snake_case."); expect(() => { - model.addForeignKey('posts', { - columnName: 'user_id', - referenceTable: 'UserProfiles', - referenceColumn: 'id', - }) - }).toThrowError("Reference table 'UserProfiles' must be snake_case.") - }) - }) - - describe('DDL Generation', () => { - it('should generate empty string if no tables exist', () => { - expect(model.generateDDL()).toBe('') - }) - - it('should generate correct DDL for simple table', () => { - model.addTable('users') - model.addColumn('users', { name: 'id', type: 'SERIAL', isPrimaryKey: true }) - model.addColumn('users', { name: 'name', type: 'VARCHAR(255)', isNullable: false }) - model.addColumn('users', { name: 'bio', type: 'TEXT' }) - - const ddl = model.generateDDL() + model.addForeignKey("posts", { + columnName: "user_id", + referenceTable: "UserProfiles", + referenceColumn: "id", + }); + }).toThrowError("Reference table 'UserProfiles' must be snake_case."); + }); + }); + + describe("DDL Generation", () => { + it("should generate empty string if no tables exist", () => { + expect(model.generateDDL()).toBe(""); + }); + + it("should generate correct DDL for simple table", () => { + model.addTable("users"); + model.addColumn("users", { + name: "id", + type: "SERIAL", + isPrimaryKey: true, + }); + model.addColumn("users", { + name: "name", + type: "VARCHAR(255)", + isNullable: false, + }); + model.addColumn("users", { name: "bio", type: "TEXT" }); + + const ddl = model.generateDDL(); const expected = `CREATE TABLE users ( id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, bio TEXT -);` - expect(ddl).toBe(expected) - }) - - it('should generate correct DDL for multiple tables with foreign keys', () => { - model.addTable('users') - model.addColumn('users', { name: 'id', type: 'SERIAL', isPrimaryKey: true }) - - model.addTable('posts') - model.addColumn('posts', { name: 'id', type: 'SERIAL', isPrimaryKey: true }) - model.addColumn('posts', { name: 'user_id', type: 'INTEGER', isNullable: false }) - - model.addForeignKey('posts', { - columnName: 'user_id', - referenceTable: 'users', - referenceColumn: 'id', - }) - - const ddl = model.generateDDL() +);`; + expect(ddl).toBe(expected); + }); + + it("should generate correct DDL for multiple tables with foreign keys", () => { + model.addTable("users"); + model.addColumn("users", { + name: "id", + type: "SERIAL", + isPrimaryKey: true, + }); + + model.addTable("posts"); + model.addColumn("posts", { + name: "id", + type: "SERIAL", + isPrimaryKey: true, + }); + model.addColumn("posts", { + name: "user_id", + type: "INTEGER", + isNullable: false, + }); + + model.addForeignKey("posts", { + columnName: "user_id", + referenceTable: "users", + referenceColumn: "id", + }); + + const ddl = model.generateDDL(); const expected = `CREATE TABLE users ( id SERIAL PRIMARY KEY ); @@ -191,8 +237,8 @@ CREATE TABLE posts ( id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) -);` - expect(ddl).toBe(expected) - }) - }) -}) +);`; + expect(ddl).toBe(expected); + }); + }); +}); diff --git a/packages/web/src/lib/erd.ts b/packages/web/src/lib/erd.ts index 046a09c5..93cf7e03 100644 --- a/packages/web/src/lib/erd.ts +++ b/packages/web/src/lib/erd.ts @@ -1,111 +1,123 @@ export interface Column { - name: string - type: string - isPrimaryKey?: boolean - isNullable?: boolean + name: string; + type: string; + isPrimaryKey?: boolean; + isNullable?: boolean; } export interface ForeignKey { - columnName: string - referenceTable: string - referenceColumn: string + columnName: string; + referenceTable: string; + referenceColumn: string; } export interface Table { - name: string - columns: Column[] - foreignKeys: ForeignKey[] + name: string; + columns: Column[]; + foreignKeys: ForeignKey[]; } -const SNAKE_CASE_IDENTIFIER = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/ +const SNAKE_CASE_IDENTIFIER = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/; +const VALID_SQL_TYPE = /^[a-zA-Z0-9_(),\s]+$/; function assertSnakeCaseIdentifier(kind: string, name: string): void { if (!SNAKE_CASE_IDENTIFIER.test(name)) { - throw new Error(`${kind} '${name}' must be snake_case.`) + throw new Error(`${kind} '${name}' must be snake_case.`); + } +} + +function assertValidColumnType(type: string): void { + if (!VALID_SQL_TYPE.test(type)) { + throw new Error(`Column type '${type}' contains invalid characters.`); } } export class ERDModel { - private tables: Map = new Map() + private tables: Map = new Map(); addTable(name: string): Table { - assertSnakeCaseIdentifier('Table', name) + assertSnakeCaseIdentifier("Table", name); if (this.tables.has(name)) { - throw new Error(`Table '${name}' already exists.`) + throw new Error(`Table '${name}' already exists.`); } - const table: Table = { name, columns: [], foreignKeys: [] } - this.tables.set(name, table) - return table + const table: Table = { name, columns: [], foreignKeys: [] }; + this.tables.set(name, table); + return table; } getTable(name: string): Table | undefined { - return this.tables.get(name) + return this.tables.get(name); } getTables(): Table[] { - return Array.from(this.tables.values()) + return Array.from(this.tables.values()); } addColumn(tableName: string, column: Column): void { - assertSnakeCaseIdentifier('Table', tableName) - assertSnakeCaseIdentifier('Column', column.name) - const table = this.tables.get(tableName) + assertSnakeCaseIdentifier("Table", tableName); + assertSnakeCaseIdentifier("Column", column.name); + assertValidColumnType(column.type); + const table = this.tables.get(tableName); if (!table) { - throw new Error(`Table '${tableName}' does not exist.`) + throw new Error(`Table '${tableName}' does not exist.`); } if (table.columns.some((c) => c.name === column.name)) { - throw new Error(`Column '${column.name}' already exists in table '${tableName}'.`) + throw new Error( + `Column '${column.name}' already exists in table '${tableName}'.`, + ); } - table.columns.push(column) + table.columns.push(column); } addForeignKey(tableName: string, fk: ForeignKey): void { - assertSnakeCaseIdentifier('Table', tableName) - assertSnakeCaseIdentifier('Column', fk.columnName) - assertSnakeCaseIdentifier('Reference table', fk.referenceTable) - assertSnakeCaseIdentifier('Reference column', fk.referenceColumn) - const table = this.tables.get(tableName) + assertSnakeCaseIdentifier("Table", tableName); + assertSnakeCaseIdentifier("Column", fk.columnName); + assertSnakeCaseIdentifier("Reference table", fk.referenceTable); + assertSnakeCaseIdentifier("Reference column", fk.referenceColumn); + const table = this.tables.get(tableName); if (!table) { - throw new Error(`Table '${tableName}' does not exist.`) + throw new Error(`Table '${tableName}' does not exist.`); } if (!table.columns.some((c) => c.name === fk.columnName)) { - throw new Error(`Column '${fk.columnName}' does not exist in table '${tableName}'.`) + throw new Error( + `Column '${fk.columnName}' does not exist in table '${tableName}'.`, + ); } - const refTable = this.tables.get(fk.referenceTable) + const refTable = this.tables.get(fk.referenceTable); if (!refTable) { - throw new Error(`Reference table '${fk.referenceTable}' does not exist.`) + throw new Error(`Reference table '${fk.referenceTable}' does not exist.`); } if (!refTable.columns.some((c) => c.name === fk.referenceColumn)) { throw new Error( - `Reference column '${fk.referenceColumn}' does not exist in table '${fk.referenceTable}'.` - ) + `Reference column '${fk.referenceColumn}' does not exist in table '${fk.referenceTable}'.`, + ); } - table.foreignKeys.push(fk) + table.foreignKeys.push(fk); } generateDDL(): string { - let ddl = '' + let ddl = ""; for (const table of this.tables.values()) { - ddl += `CREATE TABLE ${table.name} (\n` + ddl += `CREATE TABLE ${table.name} (\n`; const columnDefs = table.columns.map((col) => { - let def = ` ${col.name} ${col.type}` + let def = ` ${col.name} ${col.type}`; if (col.isPrimaryKey) { - def += ' PRIMARY KEY' + def += " PRIMARY KEY"; } if (col.isNullable === false) { - def += ' NOT NULL' + def += " NOT NULL"; } - return def - }) + return def; + }); const fkDefs = table.foreignKeys.map((fk) => { - return ` FOREIGN KEY (${fk.columnName}) REFERENCES ${fk.referenceTable}(${fk.referenceColumn})` - }) + return ` FOREIGN KEY (${fk.columnName}) REFERENCES ${fk.referenceTable}(${fk.referenceColumn})`; + }); - const allDefs = [...columnDefs, ...fkDefs] - ddl += allDefs.join(',\n') - ddl += '\n);\n\n' + const allDefs = [...columnDefs, ...fkDefs]; + ddl += allDefs.join(",\n"); + ddl += "\n);\n\n"; } - return ddl.trim() + return ddl.trim(); } }