diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 159c6dc5..e2377ca8 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -16,3 +16,8 @@ **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. + +## 2024-10-24 - [SQL Injection 방지] +**Vulnerability:** ERD 도구의 DDL 생성 시 컬럼 타입(column.type)에 대한 검증이 없어 SQL 인젝션(예: 세미콜론을 통한 쿼리 종료 및 악성 쿼리 실행) 취약점이 존재함. +**Learning:** 식별자(테이블명, 컬럼명)에 대해서는 snake_case 검증이 있었으나, 자유 형식이 허용되는 SQL 타입에 대해서는 검증이 누락되어 발생함. +**Prevention:** 사용자 입력이 DDL 문자열에 직접 삽입될 때는 세미콜론(;)과 같은 구문 종료 문자를 엄격히 차단해야 함. diff --git a/packages/web/src/lib/erd.test.ts b/packages/web/src/lib/erd.test.ts index 0ddcf189..319a15c3 100644 --- a/packages/web/src/lib/erd.test.ts +++ b/packages/web/src/lib/erd.test.ts @@ -67,6 +67,30 @@ describe('ERDModel', () => { model.addColumn('users', { name: 'created__at', type: 'timestamp' }) ).toThrowError("Column 'created__at' must be snake_case.") }) + + it('should reject unsafe SQL type syntax while preserving legitimate parameters', () => { + model.addTable('users') + + expect(() => + model.addColumn('users', { name: 'injected_column', type: 'integer, admin boolean' }) + ).toThrowError("Invalid SQL type 'integer, admin boolean'.") + expect(() => + model.addColumn('users', { name: 'commented_type', type: 'integer -- comment' }) + ).toThrowError("Invalid SQL type 'integer -- comment'.") + expect(() => + model.addColumn('users', { name: 'unterminated_type', type: 'numeric(10,2' }) + ).toThrowError("Invalid SQL type 'numeric(10,2'.") + expect(() => + model.addColumn('users', { name: 'terminated_type', type: 'integer;' }) + ).toThrowError("Invalid SQL type 'integer;'.") + + expect(() => + model.addColumn('users', { name: 'amount', type: 'numeric(10,2)' }) + ).not.toThrow() + expect(() => + model.addColumn('users', { name: 'recorded_at', type: 'timestamp with time zone' }) + ).not.toThrow() + }) }) describe('Foreign Key Management', () => { diff --git a/packages/web/src/lib/erd.ts b/packages/web/src/lib/erd.ts index 046a09c5..1cf779f6 100644 --- a/packages/web/src/lib/erd.ts +++ b/packages/web/src/lib/erd.ts @@ -18,6 +18,7 @@ export interface Table { } const SNAKE_CASE_IDENTIFIER = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/ +const SAFE_SQL_TYPE = /^[A-Za-z][A-Za-z0-9_]*(?:\s+[A-Za-z][A-Za-z0-9_]*)*(?:\(\s*\d+(?:\s*,\s*\d+)?\s*\))?(?:\[\])?$/ function assertSnakeCaseIdentifier(kind: string, name: string): void { if (!SNAKE_CASE_IDENTIFIER.test(name)) { @@ -25,6 +26,21 @@ function assertSnakeCaseIdentifier(kind: string, name: string): void { } } +/** + * Reject SQL type fragments that can escape a column definition. + * + * The accepted grammar intentionally covers common scalar SQL types, optional + * numeric parameters such as `numeric(10,2)`, multi-word types such as + * `timestamp with time zone`, and array suffixes. It rejects statement + * terminators, comments, top-level commas, quotes, operators, and unbalanced + * parentheses so untrusted type text cannot introduce sibling DDL clauses. + */ +function assertValidSqlType(type: string): void { + if (!SAFE_SQL_TYPE.test(type)) { + throw new Error(`Invalid SQL type '${type}'.`) + } +} + export class ERDModel { private tables: Map = new Map() @@ -49,6 +65,7 @@ export class ERDModel { addColumn(tableName: string, column: Column): void { assertSnakeCaseIdentifier('Table', tableName) assertSnakeCaseIdentifier('Column', column.name) + assertValidSqlType(column.type) const table = this.tables.get(tableName) if (!table) { throw new Error(`Table '${tableName}' does not exist.`)