diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 05da88a1..2b5cfb62 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -20,3 +20,13 @@ **Vulnerability:** The standard user authentication routes (login, register, and reset-password) did not have a maximum length constraint on passwords. This allows an attacker to supply extremely long strings, which `bcrypt` will try to hash, causing CPU exhaustion and creating a Denial of Service (DoS) vulnerability. **Learning:** `bcrypt` (and `bcryptjs`) is intentionally slow. While `bcrypt` may internally truncate passwords to 72 bytes, depending on the implementation the input string processing itself or the full string parsing before truncation can be very costly. In this codebase, the admin authentication correctly checked for a max length, but user schemas did not. **Prevention:** Always enforce a maximum string length limit (e.g. `.max(1024)`) on user inputs that will be passed into expensive algorithms like bcrypt hashing. + +## 2025-02-18 - [ERD 모델의 SQL 인젝션 및 상태 변이 우회 취약점 수정] +**Vulnerability:** `ERDModel` 클래스에서 컬럼 타입 및 기본값에 대한 정규식 검증이 누락되어 악의적인 SQL 문법이 삽입될 수 있었으며, 내부 상태 객체(테이블)가 직접 반환되어 검증 로직을 우회하여 상태 변이가 발생할 수 있었습니다. +**Learning:** DDL 생성 시 자유 텍스트나 타입 입력이 포함될 때는 반드시 허용 목록(Allowlist) 기반 정규식을 사용해야 하며, 객체 상태를 반환할 때는 캡슐화를 보장하기 위해 깊은 복사(Deep Copy)를 수행해야 검증 우회를 방지할 수 있습니다. +**Prevention:** 정규식(예: `SAFE_SQL_TYPE`, `SAFE_SQL_DEFAULT_VALUE`)을 도입하여 타입과 기본값을 검증하고, 객체를 반환할 때 `JSON.parse(JSON.stringify(table))`를 사용하여 원본 참조를 숨깁니다. + +## 2026-07-02 - [Fix High Severity Vulnerability in js-yaml] +**Vulnerability:** `js-yaml` versions 4.0.0 through versions below 4.3.0 were affected by quadratic CPU consumption in YAML merge-key chains (CVE-2026-59869, GHSA-52cp-r559-cp3m), allowing crafted YAML input to cause denial of service. Version 4.3.0 is patched. +**Learning:** `pnpm` 환경에서 서드파티 패키지의 하위 의존성에 존재하는 취약점을 수정할 때, 직접적인 `package.json` 업데이트로 해결되지 않는다면 `pnpm.overrides`를 적극 활용하여 전체 프로젝트 수준에서 특정 안전한 버전을 강제할 수 있습니다. +**Prevention:** `pnpm audit`과 같은 도구를 주기적으로 실행하여 취약점을 점검하고, 루트 `package.json`의 `"pnpm": { "overrides": { ... } }` 구문을 사용해 취약점이 패치된 버전을 고정(pin)합니다. diff --git a/CHANGELOG.md b/CHANGELOG.md index 84f6498d..81ab7bf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### 🛡️ 보안 (Security) +- ERD DDL 생성 경계를 구조적 allow-list 방식으로 강화했습니다. 지원하는 PostgreSQL 타입 문법과 제한된 side-effect-free 기본값만 허용하고, 컬럼 제약·임의 함수 호출·잘못된 런타임 타입을 거부합니다. 검증된 컬럼·외래 키는 모델 소유 복사본으로 저장하며, 모든 식별자를 quoted identifier로 출력해 예약어 충돌과 검증 후 객체 변이 우회를 차단합니다. 관련 회귀 테스트와 `docs/doctoring/erd-ddl-injection-boundary.md`에 위협 모델·롤백·APA 7 근거를 기록했습니다. - 로그인, 회원가입, 비밀번호 재설정이 하나의 공유 비밀번호 계약을 사용하도록 통합했습니다. 입력 처리량을 1,024자로 먼저 제한하고, 현재 `bcryptjs`가 완전하게 검증할 수 있는 72 UTF-8 바이트를 초과하는 값은 조용히 잘라내지 않고 거부합니다. ASCII와 다중 바이트 Unicode 경계 회귀 테스트 및 운영·표준 근거 문서를 함께 추가했습니다. ### ⚡ 성능 (Performance) diff --git a/docs/doctoring/erd-ddl-injection-boundary.md b/docs/doctoring/erd-ddl-injection-boundary.md new file mode 100644 index 00000000..6b87ab3e --- /dev/null +++ b/docs/doctoring/erd-ddl-injection-boundary.md @@ -0,0 +1,76 @@ +# ERD DDL injection boundary + +## Scope + +`packages/web/src/lib/erd.ts` generates PostgreSQL `CREATE TABLE` text from the in-memory ERD model. This is a code-generation boundary rather than a normal parameterized DML query: SQL identifiers, data types, and selected default expressions occupy grammar positions where bind parameters are not generally available. The safe contract is therefore **positive allow-list validation plus explicit identifier quoting**, not deny-listing individual punctuation characters. + +This document governs the `ERDModel` surface only. Generated DDL is still executable administrative input and must be reviewed and executed under least privilege in downstream migration tooling. + +## Threat model + +The relevant attacker controls or influences table/column metadata before `ERDModel.generateDDL()` is called. The security goals are: + +1. A value supplied as a data type cannot smuggle a column constraint, reference, check expression, second statement, comment, operator, or unknown type grammar into the generated DDL. +2. A default value cannot invoke an arbitrary database function or inject another SQL statement. +3. A caller cannot mutate a previously validated `Column` or `ForeignKey` object after insertion and thereby change model-owned state without validation. +4. Accepted snake_case names remain valid even if they coincide with PostgreSQL keywords, because every generated identifier is double-quoted. +5. Malformed runtime values that bypass TypeScript's compile-time types fail closed before they reach grammar validation or model storage. + +## Accepted grammar + +### Identifiers + +Database object identifiers use lower-case snake_case and are always emitted as PostgreSQL quoted identifiers. This keeps the storage naming policy deterministic and prevents accepted words such as `select` or `from` from becoming ambiguous SQL grammar. + +### Data types + +The model accepts only the reviewed type families encoded in `SIMPLE_SQL_TYPES`, `MULTI_WORD_SQL_TYPES`, and `PARAMETERIZED_SQL_TYPE`. Supported parameterized types are bounded to integer arity for `CHAR`, `VARCHAR`, `CHARACTER VARYING`, `NUMERIC`, and `DECIMAL`. + +Column constraints such as `PRIMARY KEY`, `NOT NULL`, `UNIQUE`, `REFERENCES`, and `CHECK` are intentionally not part of `Column.type`. PostgreSQL documents a column definition as a data type followed by separate column-constraint grammar. Keeping those grammar slots distinct prevents a caller from bypassing the model's dedicated constraint fields. + +### Default values + +Defaults accept only: + +- SQL single-quoted scalar strings, including standard doubled-quote escaping; +- signed integer or decimal literals; +- `TRUE`, `FALSE`, and `NULL`; +- reviewed current-time keywords (`CURRENT_DATE`, `CURRENT_TIME`, `CURRENT_TIMESTAMP`, `LOCALTIME`, `LOCALTIMESTAMP`); and +- `now()` as the sole reviewed function-form default. + +Arbitrary function calls, malformed strings, statement terminators, and other expressions are rejected. If future product requirements need additional PostgreSQL expressions, extend the allow-list test-first rather than broadening it to a generic SQL-token regex. + +## Validated-state ownership + +`addColumn()` and `addForeignKey()` validate primitive runtime types and SQL/name contracts, then copy accepted fields into model-owned objects. `addTable()`, `getTable()`, and `getTables()` return independent plain-data snapshots. This makes validation stable across the lifetime of a model: mutating a caller-owned input or returned snapshot cannot alter the internal model. + +## Verification contract + +The ERD regression suite must continue to cover at least: + +- legal simple, multi-word, and parameterized PostgreSQL type forms; +- grammar-smuggling attempts such as `INTEGER PRIMARY KEY`, `TEXT NOT NULL`, `INTEGER UNIQUE`, `INTEGER REFERENCES ...`, and `CHECK` expressions; +- unknown type names; +- scalar and reviewed built-in defaults; +- arbitrary function defaults and malformed quoted strings; +- malformed runtime field types despite TypeScript declarations; +- mutation of caller-owned column and foreign-key objects after insertion; +- independent getter snapshots; +- quoted DDL for normal and reserved-word identifiers; and +- multi-table foreign-key DDL. + +Repository CI, security scanning, supply-chain checks, exact-head automated review, and independent approval remain separate merge gates. A predecessor-head pass does not prove a later head. + +## Failure and rollback + +This boundary is fail-closed. A newly requested type or default expression that is not explicitly supported is rejected instead of passed through. If compatibility pressure reveals a legitimate missing PostgreSQL form, add a focused failing regression, extend the smallest relevant allow-list, and rerun the complete exact-head suite. + +Rollback of this hardening must not restore free-form type/default concatenation or semicolon-only filtering. If the ERD DDL generator must temporarily lose a capability, prefer disabling unsupported DDL export over accepting unvalidated grammar. + +## References (APA 7th) + +Open Worldwide Application Security Project. (n.d.). *Input validation cheat sheet*. OWASP Cheat Sheet Series. Retrieved August 7, 2026, from https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html + +Open Worldwide Application Security Project. (n.d.). *SQL injection prevention cheat sheet*. OWASP Cheat Sheet Series. Retrieved August 7, 2026, from https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html + +PostgreSQL Global Development Group. (2026). *CREATE TABLE*. In *PostgreSQL 18 documentation*. https://www.postgresql.org/docs/18/sql-createtable.html diff --git a/packages/web/src/lib/erd.test.ts b/packages/web/src/lib/erd.test.ts index 0ddcf189..6700997c 100644 --- a/packages/web/src/lib/erd.test.ts +++ b/packages/web/src/lib/erd.test.ts @@ -1,198 +1,421 @@ -import { describe, expect, it, beforeEach } from 'vitest' -import { ERDModel } from './erd' +import { beforeEach, describe, expect, it } 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")).toStrictEqual(table); + }); + + it("should return independent plain-data snapshots", () => { + const table = model.addTable("users"); + table.name = "hacked"; + table.columns.push({ name: "injected", type: "TEXT" }); + + const firstSnapshot = model.getTables(); + firstSnapshot[0].foreignKeys.push({ + columnName: "injected", + referenceTable: "injected", + referenceColumn: "injected", + }); + + expect(model.getTable("users")).toStrictEqual({ + name: "users", + columns: [], + foreignKeys: [], + }); + }); + + 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."); + }); + + 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: "TEXT" }), + ).toThrowError("Column 'id' already exists in table 'users'."); + }); + + it("should reject non-snake-case column names", () => { + model.addTable("users"); expect(() => - model.addColumn('non_existent', { name: 'id', type: 'integer' }) - ).toThrowError("Table 'non_existent' does not exist.") - }) + 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."); + }); + + it.each([ + "integer", + "INT", + "BIGINT", + "SMALLINT", + "SERIAL", + "BIGSERIAL", + "TEXT", + "VARCHAR(255)", + "CHARACTER VARYING(120)", + "CHAR(8)", + "NUMERIC(12, 4)", + "DECIMAL(9,2)", + "REAL", + "DOUBLE PRECISION", + "BOOLEAN", + "DATE", + "TIME", + "TIME WITH TIME ZONE", + "TIMESTAMP", + "TIMESTAMP WITHOUT TIME ZONE", + "UUID", + "JSON", + "JSONB", + "BYTEA", + ])("should accept the supported SQL type %s", (type) => { + model.addTable("users"); + model.addColumn("users", { name: "value_field", type }); + expect(model.getTable("users")?.columns[0].type).toBe(type); + }); - it('should throw when adding a duplicate column to a table', () => { - model.addTable('users') - model.addColumn('users', { name: 'id', type: 'integer' }) + it.each([ + "INT; DROP TABLE users;", + "INTEGER PRIMARY KEY", + "TEXT NOT NULL", + "INTEGER UNIQUE", + "INTEGER REFERENCES users(id)", + "VARCHAR(255) CHECK (true)", + "made_up_type", + ])("should reject unsafe or unsupported SQL type %s", (type) => { + model.addTable("users"); expect(() => - model.addColumn('users', { name: 'id', type: 'string' }) - ).toThrowError("Column 'id' already exists in table 'users'.") - }) + model.addColumn("users", { name: "value_field", type }), + ).toThrowError(`Unsafe SQL type: '${type}'`); + }); - it('should reject non-snake-case column names', () => { - model.addTable('users') + it.each([ + "'active'", + "''", + "42", + "-3.14", + "TRUE", + "false", + "NULL", + "CURRENT_DATE", + "CURRENT_TIME", + "CURRENT_TIMESTAMP", + "LOCALTIME", + "LOCALTIMESTAMP", + "now()", + ])("should accept the supported SQL default %s", (defaultValue) => { + model.addTable("users"); + model.addColumn("users", { + name: "value_field", + type: "TEXT", + defaultValue, + }); + expect(model.getTable("users")?.columns[0].defaultValue).toBe(defaultValue); + }); + + it.each([ + "1; DROP TABLE users;", + "unapproved_function()", + "pg_sleep()", + "CURRENT_TIMESTAMP()", + "'unterminated", + ])("should reject unsafe SQL default %s", (defaultValue) => { + model.addTable("users"); expect(() => - model.addColumn('users', { name: 'createdAt', type: 'timestamp' }) - ).toThrowError("Column 'createdAt' must be snake_case.") + model.addColumn("users", { + name: "value_field", + type: "TEXT", + defaultValue, + }), + ).toThrowError(`Unsafe default value: '${defaultValue}'`); + }); + + it("should store validated column primitives instead of caller-owned objects", () => { + model.addTable("users"); + const column = { + name: "status_field", + type: "TEXT", + isPrimaryKey: false, + isNullable: false, + defaultValue: "'safe'", + }; + + model.addColumn("users", column); + column.name = "changed_name"; + column.type = "TEXT NOT NULL"; + column.isPrimaryKey = true; + column.isNullable = true; + column.defaultValue = "unapproved_function()"; + + expect(model.getTable("users")?.columns[0]).toStrictEqual({ + name: "status_field", + type: "TEXT", + isPrimaryKey: false, + isNullable: false, + defaultValue: "'safe'", + }); + }); + + it.each([ + [{ name: 7, type: "TEXT" }, "Column name must be a string."], + [{ name: "value_field", type: 7 }, "Column type must be a string."], + [ + { name: "value_field", type: "TEXT", isPrimaryKey: "yes" }, + "Column isPrimaryKey must be a boolean when provided.", + ], + [ + { name: "value_field", type: "TEXT", isNullable: "yes" }, + "Column isNullable must be a boolean when provided.", + ], + [ + { name: "value_field", type: "TEXT", defaultValue: 7 }, + "Column defaultValue must be a string when provided.", + ], + ])("should reject malformed runtime column values %#", (column, message) => { + model.addTable("users"); expect(() => - model.addColumn('users', { name: 'created__at', type: 'timestamp' }) - ).toThrowError("Column 'created__at' must be snake_case.") - }) - }) + model.addColumn("users", column as never), + ).toThrowError(message as string); + }); + }); - describe('Foreign Key Management', () => { + 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 store validated foreign-key primitives independently", () => { + const foreignKey = { + columnName: "user_id", + referenceTable: "users", + referenceColumn: "id", + }; + model.addForeignKey("posts", foreignKey); + foreignKey.columnName = "changed_column"; + foreignKey.referenceTable = "changed_table"; + foreignKey.referenceColumn = "changed_reference"; + + expect(model.getTable("posts")?.foreignKeys[0]).toStrictEqual({ + columnName: "user_id", + referenceTable: "users", + referenceColumn: "id", + }); + }); + + 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() - 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() - const expected = `CREATE TABLE users ( - id SERIAL PRIMARY KEY + model.addForeignKey("posts", { + columnName: "user_id", + referenceTable: "UserProfiles", + referenceColumn: "id", + }); + }).toThrowError("Reference table 'UserProfiles' must be snake_case."); + }); + + it.each([ + [{ columnName: 7, referenceTable: "users", referenceColumn: "id" }, "Foreign key columnName must be a string."], + [{ columnName: "user_id", referenceTable: 7, referenceColumn: "id" }, "Foreign key referenceTable must be a string."], + [{ columnName: "user_id", referenceTable: "users", referenceColumn: 7 }, "Foreign key referenceColumn must be a string."], + ])("should reject malformed runtime foreign-key values %#", (foreignKey, message) => { + expect(() => + model.addForeignKey("posts", foreignKey as never), + ).toThrowError(message as string); + }); + }); + + describe("DDL Generation", () => { + it("should generate empty string if no tables exist", () => { + expect(model.generateDDL()).toBe(""); + }); + + it("should generate quoted DDL for a 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" }); + model.addColumn("users", { + name: "status", + type: "VARCHAR(20)", + defaultValue: "'active'", + }); + + const ddl = model.generateDDL(); + const expected = `CREATE TABLE "users" ( + "id" SERIAL PRIMARY KEY, + "name" VARCHAR(255) NOT NULL, + "bio" TEXT, + "status" VARCHAR(20) DEFAULT 'active' +);`; + expect(ddl).toBe(expected); + }); + + it("should generate quoted 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 ); -CREATE TABLE posts ( - id SERIAL PRIMARY KEY, - user_id INTEGER NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) -);` - expect(ddl).toBe(expected) - }) - }) -}) +CREATE TABLE "posts" ( + "id" SERIAL PRIMARY KEY, + "user_id" INTEGER NOT NULL, + FOREIGN KEY ("user_id") REFERENCES "users"("id") +);`; + expect(ddl).toBe(expected); + }); + + it("should quote reserved PostgreSQL words used as accepted identifiers", () => { + model.addTable("select"); + model.addColumn("select", { name: "from", type: "INTEGER" }); + + expect(model.generateDDL()).toBe(`CREATE TABLE "select" ( + "from" INTEGER +);`); + }); + }); +}); diff --git a/packages/web/src/lib/erd.ts b/packages/web/src/lib/erd.ts index 046a09c5..388c368c 100644 --- a/packages/web/src/lib/erd.ts +++ b/packages/web/src/lib/erd.ts @@ -1,111 +1,317 @@ +/** + * One validated relational column stored by {@link ERDModel}. + * + * The `type` and optional `defaultValue` fields are SQL grammar fragments, so + * callers must add columns through {@link ERDModel.addColumn}. That method + * validates both fragments before copying them into model-owned state. + */ export interface Column { - name: string - type: string - isPrimaryKey?: boolean - isNullable?: boolean + name: string; + type: string; + isPrimaryKey?: boolean; + isNullable?: boolean; + defaultValue?: string; } +/** A validated foreign-key relationship between two model-owned columns. */ export interface ForeignKey { - columnName: string - referenceTable: string - referenceColumn: string + columnName: string; + referenceTable: string; + referenceColumn: string; } +/** A plain-data snapshot of one table in the ERD model. */ 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 SIMPLE_SQL_TYPES = new Set([ + "BIGINT", + "BIGSERIAL", + "BOOLEAN", + "BYTEA", + "DATE", + "INT", + "INTEGER", + "JSON", + "JSONB", + "REAL", + "SERIAL", + "SMALLINT", + "TEXT", + "TIME", + "TIMESTAMP", + "UUID", +]); + +const MULTI_WORD_SQL_TYPES = new Set([ + "DOUBLE PRECISION", + "TIME WITH TIME ZONE", + "TIME WITHOUT TIME ZONE", + "TIMESTAMP WITH TIME ZONE", + "TIMESTAMP WITHOUT TIME ZONE", +]); + +const PARAMETERIZED_SQL_TYPE = + /^(?:CHAR|VARCHAR)\([1-9][0-9]*\)$|^(?:NUMERIC|DECIMAL)\([1-9][0-9]*(?:,\s*[0-9]+)?\)$|^CHARACTER VARYING\([1-9][0-9]*\)$/; + +const SQL_STRING_LITERAL = /^'(?:[^']|'')*'$/; +const SQL_NUMERIC_LITERAL = /^-?[0-9]+(?:\.[0-9]+)?$/; +const SQL_CONSTANT_DEFAULT = /^(?:TRUE|FALSE|NULL|CURRENT_DATE|CURRENT_TIME|CURRENT_TIMESTAMP|LOCALTIME|LOCALTIMESTAMP)$/i; +const SQL_NOW_DEFAULT = /^now\(\)$/i; + +/** Reject a database object name that is not one lower-case snake_case identifier. */ 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.`); } } +/** + * Reject a type fragment unless it is one explicitly supported PostgreSQL type. + * + * Column constraints intentionally are not accepted here. They belong to their + * dedicated model fields and therefore cannot be smuggled through `type`. + */ +function assertSafeSQLType(type: string): void { + const normalized = type.trim().replace(/\s+/g, " ").toUpperCase(); + const supported = + SIMPLE_SQL_TYPES.has(normalized) || + MULTI_WORD_SQL_TYPES.has(normalized) || + PARAMETERIZED_SQL_TYPE.test(normalized); + if (!supported) { + throw new Error(`Unsafe SQL type: '${type}'`); + } +} + +/** + * Reject a default fragment unless it is a scalar literal or reviewed built-in. + * + * Arbitrary function calls are deliberately excluded because generated DDL may + * later execute in a privileged database migration context. + */ +function assertSafeDefaultValue(value: string): void { + const supported = + SQL_STRING_LITERAL.test(value) || + SQL_NUMERIC_LITERAL.test(value) || + SQL_CONSTANT_DEFAULT.test(value) || + SQL_NOW_DEFAULT.test(value); + if (!supported) { + throw new Error(`Unsafe default value: '${value}'`); + } +} + +/** Validate the runtime shape of caller-provided column primitives. */ +function assertColumnRuntimeTypes(column: Column): void { + if (typeof column.name !== "string") { + throw new Error("Column name must be a string."); + } + if (typeof column.type !== "string") { + throw new Error("Column type must be a string."); + } + if ( + column.isPrimaryKey !== undefined && + typeof column.isPrimaryKey !== "boolean" + ) { + throw new Error("Column isPrimaryKey must be a boolean when provided."); + } + if ( + column.isNullable !== undefined && + typeof column.isNullable !== "boolean" + ) { + throw new Error("Column isNullable must be a boolean when provided."); + } + if ( + column.defaultValue !== undefined && + typeof column.defaultValue !== "string" + ) { + throw new Error("Column defaultValue must be a string when provided."); + } +} + +/** Validate the runtime shape of caller-provided foreign-key primitives. */ +function assertForeignKeyRuntimeTypes(foreignKey: ForeignKey): void { + if (typeof foreignKey.columnName !== "string") { + throw new Error("Foreign key columnName must be a string."); + } + if (typeof foreignKey.referenceTable !== "string") { + throw new Error("Foreign key referenceTable must be a string."); + } + if (typeof foreignKey.referenceColumn !== "string") { + throw new Error("Foreign key referenceColumn must be a string."); + } +} + +/** + * Quote one already-validated snake_case SQL identifier. + * + * Quoting protects accepted identifiers that also happen to be PostgreSQL + * reserved words, such as `select` or `from`. + */ +function quoteIdentifier(identifier: string): string { + return `"${identifier}"`; +} + +/** Return a deep plain-data copy so callers never receive mutable model state. */ +function cloneTable(table: Table): Table { + return { + name: table.name, + columns: table.columns.map((column) => ({ ...column })), + foreignKeys: table.foreignKeys.map((foreignKey) => ({ ...foreignKey })), + }; +} + +/** + * In-memory ERD model that emits bounded PostgreSQL `CREATE TABLE` statements. + * + * The model owns all stored state. Inputs are validated and copied on ingress, + * and getters return independent snapshots, so caller mutation cannot bypass a + * previously completed security check. + */ export class ERDModel { - private tables: Map = new Map() + private tables: Map = new Map(); + /** Add a new table and return an independent snapshot of it. */ 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 cloneTable(table); } + /** Return an independent snapshot of a table, or `undefined` when absent. */ getTable(name: string): Table | undefined { - return this.tables.get(name) + const table = this.tables.get(name); + return table ? cloneTable(table) : undefined; } + /** Return independent snapshots of all tables in insertion order. */ getTables(): Table[] { - return Array.from(this.tables.values()) + return Array.from(this.tables.values(), cloneTable); } + /** + * Validate and add one column to an existing table. + * + * The stored object contains only copied validated primitives, preventing a + * caller from mutating the model after this method returns. + */ addColumn(tableName: string, column: Column): void { - assertSnakeCaseIdentifier('Table', tableName) - assertSnakeCaseIdentifier('Column', column.name) - const table = this.tables.get(tableName) + assertColumnRuntimeTypes(column); + assertSnakeCaseIdentifier("Table", tableName); + assertSnakeCaseIdentifier("Column", column.name); + assertSafeSQLType(column.type); + if (column.defaultValue !== undefined) { + assertSafeDefaultValue(column.defaultValue); + } + + 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}'.`) + if (table.columns.some((candidate) => candidate.name === column.name)) { + throw new Error( + `Column '${column.name}' already exists in table '${tableName}'.`, + ); } - table.columns.push(column) + + table.columns.push({ + name: column.name, + type: column.type, + isPrimaryKey: column.isPrimaryKey, + isNullable: column.isNullable, + defaultValue: column.defaultValue, + }); } - 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) + /** + * Validate and add one foreign-key relationship to an existing table. + * + * Both local and referenced columns must already exist. The relationship is + * copied so later mutation of the caller-owned object cannot alter the model. + */ + addForeignKey(tableName: string, foreignKey: ForeignKey): void { + assertForeignKeyRuntimeTypes(foreignKey); + assertSnakeCaseIdentifier("Table", tableName); + assertSnakeCaseIdentifier("Column", foreignKey.columnName); + assertSnakeCaseIdentifier("Reference table", foreignKey.referenceTable); + assertSnakeCaseIdentifier("Reference column", foreignKey.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}'.`) - } - const refTable = this.tables.get(fk.referenceTable) - if (!refTable) { - throw new Error(`Reference table '${fk.referenceTable}' does not exist.`) + if (!table.columns.some((column) => column.name === foreignKey.columnName)) { + throw new Error( + `Column '${foreignKey.columnName}' does not exist in table '${tableName}'.`, + ); } - if (!refTable.columns.some((c) => c.name === fk.referenceColumn)) { + + const referenceTable = this.tables.get(foreignKey.referenceTable); + if (!referenceTable) { throw new Error( - `Reference column '${fk.referenceColumn}' does not exist in table '${fk.referenceTable}'.` + `Reference table '${foreignKey.referenceTable}' does not exist.`, + ); + } + if ( + !referenceTable.columns.some( + (column) => column.name === foreignKey.referenceColumn, ) + ) { + throw new Error( + `Reference column '${foreignKey.referenceColumn}' does not exist in table '${foreignKey.referenceTable}'.`, + ); } - table.foreignKeys.push(fk) + + table.foreignKeys.push({ + columnName: foreignKey.columnName, + referenceTable: foreignKey.referenceTable, + referenceColumn: foreignKey.referenceColumn, + }); } + /** + * Generate deterministic PostgreSQL DDL for the current model. + * + * Every identifier is quoted and every SQL grammar fragment was validated at + * ingress, so reserved words remain valid without reopening an injection path. + */ generateDDL(): string { - let ddl = '' + let ddl = ""; + for (const table of this.tables.values()) { - ddl += `CREATE TABLE ${table.name} (\n` - const columnDefs = table.columns.map((col) => { - let def = ` ${col.name} ${col.type}` - if (col.isPrimaryKey) { - def += ' PRIMARY KEY' + ddl += `CREATE TABLE ${quoteIdentifier(table.name)} (\n`; + + const columnDefinitions = table.columns.map((column) => { + let definition = ` ${quoteIdentifier(column.name)} ${column.type}`; + if (column.isPrimaryKey) { + definition += " PRIMARY KEY"; } - if (col.isNullable === false) { - def += ' NOT NULL' + if (column.isNullable === false) { + definition += " NOT NULL"; } - return def - }) + if (column.defaultValue !== undefined) { + definition += ` DEFAULT ${column.defaultValue}`; + } + return definition; + }); - const fkDefs = table.foreignKeys.map((fk) => { - return ` FOREIGN KEY (${fk.columnName}) REFERENCES ${fk.referenceTable}(${fk.referenceColumn})` - }) + const foreignKeyDefinitions = table.foreignKeys.map( + (foreignKey) => + ` FOREIGN KEY (${quoteIdentifier(foreignKey.columnName)}) REFERENCES ${quoteIdentifier(foreignKey.referenceTable)}(${quoteIdentifier(foreignKey.referenceColumn)})`, + ); - const allDefs = [...columnDefs, ...fkDefs] - ddl += allDefs.join(',\n') - ddl += '\n);\n\n' + ddl += [...columnDefinitions, ...foreignKeyDefinitions].join(",\n"); + ddl += "\n);\n\n"; } - return ddl.trim() + + return ddl.trim(); } }