From dd272fd519f3e52c226e0ee898b15f85bc7dd37c Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:02:12 +0000 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRITIC?= =?UTF-8?q?AL]=20ERD=20DDL=20=EC=83=9D=EC=84=B1=20=EC=8B=9C=20SQL=20?= =?UTF-8?q?=EC=9D=B8=EC=A0=9D=EC=85=98=20=EC=B7=A8=EC=95=BD=EC=A0=90=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .jules/sentinel.md | 5 +++++ packages/web/src/lib/erd.test.ts | 7 +++++++ packages/web/src/lib/erd.ts | 7 +++++++ 3 files changed, 19 insertions(+) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 159c6dc5..f5222d60 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. + +## 2025-08-04 - [DDL 생성 시 SQL 인젝션 방지] +**Vulnerability:** ERD 도구에서 DDL을 생성할 때, 컬럼 타입 문자열에 세미콜론(;)을 포함시켜 기존 문장을 종료하고 악의적인 SQL 문(예: `DROP TABLE`)을 삽입할 수 있는 SQL 인젝션 취약점이 존재했습니다. +**Learning:** 사용자 입력(이 경우 컬럼 타입)을 직접 연결하여 SQL 쿼리나 DDL을 생성하는 경우, 구문 종결자(Statement Terminator)를 필터링하지 않으면 데이터베이스 조작으로 이어질 수 있습니다. +**Prevention:** DDL과 같은 동적 쿼리를 생성할 때 구문 종결자가 입력에 포함되어 있는지 확인(`assertNoStatementTerminator`)하여 악의적인 SQL 문 실행을 사전에 차단해야 합니다. diff --git a/packages/web/src/lib/erd.test.ts b/packages/web/src/lib/erd.test.ts index 0ddcf189..f08024dc 100644 --- a/packages/web/src/lib/erd.test.ts +++ b/packages/web/src/lib/erd.test.ts @@ -67,6 +67,13 @@ describe('ERDModel', () => { model.addColumn('users', { name: 'created__at', type: 'timestamp' }) ).toThrowError("Column 'created__at' must be snake_case.") }) + + it('should reject column types containing statement terminators', () => { + model.addTable('users') + expect(() => + model.addColumn('users', { name: 'id', type: 'integer PRIMARY KEY); DROP TABLE users; --' }) + ).toThrowError('Statement terminators are not allowed to prevent SQL injection.') + }) }) describe('Foreign Key Management', () => { diff --git a/packages/web/src/lib/erd.ts b/packages/web/src/lib/erd.ts index 046a09c5..3408ad34 100644 --- a/packages/web/src/lib/erd.ts +++ b/packages/web/src/lib/erd.ts @@ -25,6 +25,12 @@ function assertSnakeCaseIdentifier(kind: string, name: string): void { } } +function assertNoStatementTerminator(value: string): void { + if (value.includes(';')) { + throw new Error('Statement terminators are not allowed to prevent SQL injection.') + } +} + export class ERDModel { private tables: Map = new Map() @@ -49,6 +55,7 @@ export class ERDModel { addColumn(tableName: string, column: Column): void { assertSnakeCaseIdentifier('Table', tableName) assertSnakeCaseIdentifier('Column', column.name) + assertNoStatementTerminator(column.type) const table = this.tables.get(tableName) if (!table) { throw new Error(`Table '${tableName}' does not exist.`) From 36ca179abba6f2832bf1147ad55717179a9b4c32 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:20:16 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRITIC?= =?UTF-8?q?AL]=20ERD=20DDL=20=EC=83=9D=EC=84=B1=20=EC=8B=9C=20SQL=20?= =?UTF-8?q?=EC=9D=B8=EC=A0=9D=EC=85=98=20=EC=B7=A8=EC=95=BD=EC=A0=90=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EC=B7=A8=EC=95=BD=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 10 +++++---- pnpm-lock.yaml | 56 ++++++++++++++++++++++++++------------------------ 2 files changed, 35 insertions(+), 31 deletions(-) diff --git a/package.json b/package.json index 98091908..ad089fcf 100644 --- a/package.json +++ b/package.json @@ -21,15 +21,17 @@ "@babel/core": "7.29.7", "esbuild": "0.28.1", "js-yaml": "^4.3.0", - "fast-uri": "^3.1.4", + "fast-uri": "3.1.5", "@auth/core": "^0.41.3", "sharp": "^0.35.0", "postcss": "^8.5.18", - "hono": "^4.12.27", - "brace-expansion": "5.0.8", + "hono": "4.13.0", + "brace-expansion": "5.0.9", "minimatch": "^10.0.0", "@hono/node-server": "^2.0.5", - "body-parser": "^2.3.0" + "body-parser": "^2.3.0", + "ip-address": "10.4.0", + "undici": "7.29.0" } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f897678..74dcaaf7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,15 +8,17 @@ overrides: '@babel/core': 7.29.7 esbuild: 0.28.1 js-yaml: ^4.3.0 - fast-uri: ^3.1.4 + fast-uri: 3.1.5 '@auth/core': ^0.41.3 sharp: ^0.35.0 postcss: ^8.5.18 - hono: ^4.12.27 - brace-expansion: 5.0.8 + hono: 4.13.0 + brace-expansion: 5.0.9 minimatch: ^10.0.0 '@hono/node-server': ^2.0.5 body-parser: ^2.3.0 + ip-address: 10.4.0 + undici: 7.29.0 pnpmfileChecksum: qsp27c6veblwg3gxusbbzrumtm @@ -701,7 +703,7 @@ packages: resolution: {integrity: sha512-eWpQYr67tqJLeaSUl0Q+TquuYfUdTibpOJlUMV2FfUP7+KqCC5TufnwnlXL6mobZBJbGAYRd7ZvEBDCbLInjhg==} engines: {node: '>=20'} peerDependencies: - hono: ^4.12.27 + hono: 4.13.0 '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} @@ -1946,8 +1948,8 @@ packages: resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} - brace-expansion@5.0.8: - resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} engines: {node: 20 || >=22} braces@3.0.3: @@ -2660,8 +2662,8 @@ packages: fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} - fast-uri@3.1.4: - resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==} + fast-uri@3.1.5: + resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} fast-wrap-ansi@0.2.2: resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} @@ -2876,8 +2878,8 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} - hono@4.12.32: - resolution: {integrity: sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==} + hono@4.13.0: + resolution: {integrity: sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==} engines: {node: '>=16.9.0'} html-encoding-sniffer@6.0.0: @@ -2944,8 +2946,8 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} - ip-address@10.2.0: - resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + ip-address@10.4.0: + resolution: {integrity: sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -4476,8 +4478,8 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici@7.28.0: - resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} engines: {node: '>=20.18.1'} unicorn-magic@0.3.0: @@ -5226,9 +5228,9 @@ snapshots: '@floating-ui/utils@0.2.11': {} - '@hono/node-server@2.0.12(hono@4.12.32)': + '@hono/node-server@2.0.12(hono@4.13.0)': dependencies: - hono: 4.12.32 + hono: 4.13.0 '@humanfs/core@0.19.1': {} @@ -5532,7 +5534,7 @@ snapshots: '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': dependencies: - '@hono/node-server': 2.0.12(hono@4.12.32) + '@hono/node-server': 2.0.12(hono@4.13.0) ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) content-type: 1.0.5 @@ -5542,7 +5544,7 @@ snapshots: eventsource-parser: 3.1.0 express: 5.2.1 express-rate-limit: 8.5.2(express@5.2.1) - hono: 4.12.32 + hono: 4.13.0 jose: 6.2.3 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 @@ -6223,7 +6225,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.4 + fast-uri: 3.1.5 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -6366,7 +6368,7 @@ snapshots: transitivePeerDependencies: - supports-color - brace-expansion@5.0.8: + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -7150,7 +7152,7 @@ snapshots: express-rate-limit@8.5.2(express@5.2.1): dependencies: express: 5.2.1 - ip-address: 10.2.0 + ip-address: 10.4.0 express@5.2.1: dependencies: @@ -7223,7 +7225,7 @@ snapshots: dependencies: fast-string-truncated-width: 3.0.3 - fast-uri@3.1.4: {} + fast-uri@3.1.5: {} fast-wrap-ansi@0.2.2: dependencies: @@ -7461,7 +7463,7 @@ snapshots: dependencies: hermes-estree: 0.25.1 - hono@4.12.32: {} + hono@4.13.0: {} html-encoding-sniffer@6.0.0(@noble/hashes@1.8.0): dependencies: @@ -7521,7 +7523,7 @@ snapshots: internmap@2.0.3: {} - ip-address@10.2.0: {} + ip-address@10.4.0: {} ipaddr.js@1.9.1: {} @@ -7763,7 +7765,7 @@ snapshots: saxes: 6.0.0 symbol-tree: 3.2.4 tough-cookie: 6.0.1 - undici: 7.28.0 + undici: 7.29.0 w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 5.0.0 @@ -8301,7 +8303,7 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.8 + brace-expansion: 5.0.9 minimist@1.2.8: {} @@ -9408,7 +9410,7 @@ snapshots: undici-types@6.21.0: {} - undici@7.28.0: {} + undici@7.29.0: {} unicorn-magic@0.3.0: {} From 57d8f3d71e7c1bf39fa60d3a57fcd1ade6efd9e0 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:51:47 +0000 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRITIC?= =?UTF-8?q?AL]=20ERD=20=EB=AA=A8=EB=8D=B8=EC=9D=98=20=EC=8B=AC=ED=99=94=20?= =?UTF-8?q?SQL=20=EC=9D=B8=EC=A0=9D=EC=85=98=20=EB=B0=8F=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=A1=B0=EC=9E=91=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .jules/sentinel.md | 5 +++++ packages/web/src/lib/erd.test.ts | 30 +++++++++++++++++++++++++++++- packages/web/src/lib/erd.ts | 15 +++++++++++---- 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index f5222d60..feeffc10 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -21,3 +21,8 @@ **Vulnerability:** ERD 도구에서 DDL을 생성할 때, 컬럼 타입 문자열에 세미콜론(;)을 포함시켜 기존 문장을 종료하고 악의적인 SQL 문(예: `DROP TABLE`)을 삽입할 수 있는 SQL 인젝션 취약점이 존재했습니다. **Learning:** 사용자 입력(이 경우 컬럼 타입)을 직접 연결하여 SQL 쿼리나 DDL을 생성하는 경우, 구문 종결자(Statement Terminator)를 필터링하지 않으면 데이터베이스 조작으로 이어질 수 있습니다. **Prevention:** DDL과 같은 동적 쿼리를 생성할 때 구문 종결자가 입력에 포함되어 있는지 확인(`assertNoStatementTerminator`)하여 악의적인 SQL 문 실행을 사전에 차단해야 합니다. + +## 2025-08-04 - [ERD 모델의 심화 SQL 인젝션 및 상태 조작 방지] +**Vulnerability:** 기존의 세미콜론 검사만으로는 SQL 주석(`--`, `/*`)이나 `UNION` 공격을 통한 SQL 인젝션을 막을 수 없었으며, `getTable()` 등을 통해 내부 상태 객체가 참조로 반환되어 유효성 검사 이후에 임의로 악성 SQL을 주입(Prototype Pollution/State Manipulation)할 수 있는 취약점이 있었습니다. +**Learning:** 내부 상태를 외부로 노출할 때 참조형 객체를 그대로 반환하면 외부에서 상태를 조작하여 모든 내부 보안 검증을 우회할 수 있습니다. 또한 SQL 인젝션은 세미콜론 외에도 주석 및 예약어(UNION 등)를 이용한 공격 경로가 존재합니다. +**Prevention:** 컬럼 타입과 같은 사용자 입력은 엄격한 화이트리스트나 상세한 차단 목록(주석, UNION 등)으로 검증해야 하며, 객체 상태를 반환할 때는 복사본(예: 깊은 복사 `JSON.parse(JSON.stringify(obj))`)을 반환하여 내부 맵이나 속성을 직접 조작할 수 없도록 격리해야 합니다. diff --git a/packages/web/src/lib/erd.test.ts b/packages/web/src/lib/erd.test.ts index f08024dc..033a79cb 100644 --- a/packages/web/src/lib/erd.test.ts +++ b/packages/web/src/lib/erd.test.ts @@ -13,7 +13,7 @@ describe('ERDModel', () => { const table = model.addTable('users') expect(table.name).toBe('users') expect(model.getTables().length).toBe(1) - expect(model.getTable('users')).toBe(table) + expect(model.getTable('users')).toStrictEqual(table) }) it('should throw when adding duplicate table', () => { @@ -33,6 +33,17 @@ describe('ERDModel', () => { it('should return undefined for non-existent table', () => { expect(model.getTable('non_existent')).toBeUndefined() }) + + it('should return deeply copied objects from getTable to prevent prototype pollution', () => { + model.addTable('users') + model.addColumn('users', { name: 'id', type: 'integer' }) + const table = model.getTable('users') + if (table) { + table.columns[0].type = 'integer PRIMARY KEY; DROP TABLE users; --' + } + const unchangedTable = model.getTable('users') + expect(unchangedTable?.columns[0].type).toBe('integer') + }) }) describe('Column Management', () => { @@ -74,6 +85,23 @@ describe('ERDModel', () => { model.addColumn('users', { name: 'id', type: 'integer PRIMARY KEY); DROP TABLE users; --' }) ).toThrowError('Statement terminators are not allowed to prevent SQL injection.') }) + + it('should reject column types containing SQL comments', () => { + model.addTable('users') + expect(() => + model.addColumn('users', { name: 'id', type: 'integer PRIMARY KEY -- injected comment' }) + ).toThrowError('SQL comments are not allowed in column types.') + expect(() => + model.addColumn('users', { name: 'id', type: 'integer /* comment */ PRIMARY KEY' }) + ).toThrowError('SQL comments are not allowed in column types.') + }) + + it('should reject column types containing UNION attacks', () => { + model.addTable('users') + expect(() => + model.addColumn('users', { name: 'id', type: 'integer UNION SELECT password FROM users' }) + ).toThrowError('UNION statements are not allowed in column types.') + }) }) describe('Foreign Key Management', () => { diff --git a/packages/web/src/lib/erd.ts b/packages/web/src/lib/erd.ts index 3408ad34..dcbfe87b 100644 --- a/packages/web/src/lib/erd.ts +++ b/packages/web/src/lib/erd.ts @@ -25,10 +25,16 @@ function assertSnakeCaseIdentifier(kind: string, name: string): void { } } -function assertNoStatementTerminator(value: string): void { +function assertValidSqlType(value: string): void { if (value.includes(';')) { throw new Error('Statement terminators are not allowed to prevent SQL injection.') } + if (value.includes('--') || value.includes('/*')) { + throw new Error('SQL comments are not allowed in column types.') + } + if (/UNION/i.test(value)) { + throw new Error('UNION statements are not allowed in column types.') + } } export class ERDModel { @@ -45,17 +51,18 @@ export class ERDModel { } getTable(name: string): Table | undefined { - return this.tables.get(name) + const table = this.tables.get(name) + return table ? JSON.parse(JSON.stringify(table)) : undefined } getTables(): Table[] { - return Array.from(this.tables.values()) + return Array.from(this.tables.values()).map(table => JSON.parse(JSON.stringify(table))) } addColumn(tableName: string, column: Column): void { assertSnakeCaseIdentifier('Table', tableName) assertSnakeCaseIdentifier('Column', column.name) - assertNoStatementTerminator(column.type) + assertValidSqlType(column.type) const table = this.tables.get(tableName) if (!table) { throw new Error(`Table '${tableName}' does not exist.`)