From 572bd28bc2ca4939a15d4d59098eb4126601e3e9 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:14:57 +0000 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[HIGH]?= =?UTF-8?q?=20CSV=20Formula=20Injection=20=EC=B7=A8=EC=95=BD=EC=A0=90=20?= =?UTF-8?q?=ED=8C=A8=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .jules/sentinel.md | 4 ++++ .../src/app/api/orgs/[orgSlug]/dashboard/sessions/route.ts | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 7902c442..5768d83a 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -30,3 +30,7 @@ **Vulnerability:** Known high-severity vulnerabilities discovered by the audit in `js-yaml` and `nanoid` packages. **Learning:** Deeply nested dependencies (`js-yaml` via `eslint`, `nanoid` via `vitest/vite`) may expose the application to DoS or logic loops. **Prevention:** Use `pnpm.overrides` in the root `package.json` to enforce patched versions across all transitive paths in a pnpm workspace. +## 2024-05-19 - [CSV Formula Injection 방지] +**Vulnerability:** [CSV 익스포트 기능에서 사용자 입력값이 필터링 없이 그대로 출력되어 Spreadsheet Macro Injection 취약점 발견] +**Learning:** [사용자 입력값이 스프레드시트에서 수식으로 인식될 수 있는 특수문자(=, +, -, @)로 시작할 경우, 실행 가능한 코드로 해석되어 보안 위협이 될 수 있음을 학습함] +**Prevention:** [CSV 익스포트 전용 필드 매핑 함수를 작성하여, 숫자 타입을 제외하고 특수문자로 시작하는 모든 필드값 앞에 작은따옴표(')를 접두사로 추가하도록 공통 처리함] diff --git a/packages/web/src/app/api/orgs/[orgSlug]/dashboard/sessions/route.ts b/packages/web/src/app/api/orgs/[orgSlug]/dashboard/sessions/route.ts index 7d6a4d4a..7eb1a578 100644 --- a/packages/web/src/app/api/orgs/[orgSlug]/dashboard/sessions/route.ts +++ b/packages/web/src/app/api/orgs/[orgSlug]/dashboard/sessions/route.ts @@ -73,7 +73,10 @@ function mapSessionItem(session: SessionWithInclude): SessionItem { function csvField(value: string | number | null | undefined) { if (value === null || value === undefined) return '' - const text = String(value) + let text = String(value) + if (typeof value !== 'number' && /^[=+\-@\t\r\n]|^[=+-@]/.test(text)) { + text = "'" + text + } return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text } From 2484892671c0dff6af8414bb2e32ff22530b5d53 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:21:36 +0000 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[HIGH]?= =?UTF-8?q?=20=EC=84=B8=EC=85=98=20=EC=9D=B5=EC=8A=A4=ED=8F=AC=ED=8A=B8?= =?UTF-8?q?=EC=9D=98=20CSV=20Formula=20Injection=20=EC=B7=A8=EC=95=BD?= =?UTF-8?q?=EC=A0=90=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .jules/sentinel.md | 4 -- .../dashboard/sessions/route.test.ts | 43 +++++++++++++++++++ .../[orgSlug]/dashboard/sessions/route.ts | 2 +- 3 files changed, 44 insertions(+), 5 deletions(-) create mode 100644 packages/web/src/app/api/orgs/[orgSlug]/dashboard/sessions/route.test.ts diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 5768d83a..7902c442 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -30,7 +30,3 @@ **Vulnerability:** Known high-severity vulnerabilities discovered by the audit in `js-yaml` and `nanoid` packages. **Learning:** Deeply nested dependencies (`js-yaml` via `eslint`, `nanoid` via `vitest/vite`) may expose the application to DoS or logic loops. **Prevention:** Use `pnpm.overrides` in the root `package.json` to enforce patched versions across all transitive paths in a pnpm workspace. -## 2024-05-19 - [CSV Formula Injection 방지] -**Vulnerability:** [CSV 익스포트 기능에서 사용자 입력값이 필터링 없이 그대로 출력되어 Spreadsheet Macro Injection 취약점 발견] -**Learning:** [사용자 입력값이 스프레드시트에서 수식으로 인식될 수 있는 특수문자(=, +, -, @)로 시작할 경우, 실행 가능한 코드로 해석되어 보안 위협이 될 수 있음을 학습함] -**Prevention:** [CSV 익스포트 전용 필드 매핑 함수를 작성하여, 숫자 타입을 제외하고 특수문자로 시작하는 모든 필드값 앞에 작은따옴표(')를 접두사로 추가하도록 공통 처리함] diff --git a/packages/web/src/app/api/orgs/[orgSlug]/dashboard/sessions/route.test.ts b/packages/web/src/app/api/orgs/[orgSlug]/dashboard/sessions/route.test.ts new file mode 100644 index 00000000..4716bf14 --- /dev/null +++ b/packages/web/src/app/api/orgs/[orgSlug]/dashboard/sessions/route.test.ts @@ -0,0 +1,43 @@ +import { describe, test, expect, vi } from 'vitest' +import { csvField } from './route' + +vi.mock('server-only', () => ({})) + +describe('csvField', () => { + test('handles null and undefined', () => { + expect(csvField(null)).toBe('') + expect(csvField(undefined)).toBe('') + }) + + test('does not add prefix to numbers', () => { + expect(csvField(123)).toBe('123') + expect(csvField(0)).toBe('0') + expect(csvField(-456)).toBe('-456') + }) + + test('adds single quote prefix to prevent Formula Injection', () => { + expect(csvField('=cmd|C')).toBe("'=cmd|C") + expect(csvField('+1+2')).toBe("'+1+2") + expect(csvField('-3-4')).toBe("'-3-4") + expect(csvField('@sum()')).toBe("'@sum()") + expect(csvField('\tdata')).toBe("'\tdata") + + // 탭, 엔터로 시작하면 따옴표(quote)에 의해 감싸짐 + prefix ' + expect(csvField('\rdata')).toBe("\"'\rdata\"") + expect(csvField('\ndata')).toBe("\"'\ndata\"") + + // Full-width characters + expect(csvField('=test')).toBe("'=test") + expect(csvField('+test')).toBe("'+test") + expect(csvField('-test')).toBe("'-test") + expect(csvField('@test')).toBe("'@test") + }) + + test('escapes quotes correctly', () => { + expect(csvField('hello "world"')).toBe('"hello ""world"""') + }) + + test('escapes newlines correctly', () => { + expect(csvField('hello\nworld')).toBe("\"hello\nworld\"") + }) +}) diff --git a/packages/web/src/app/api/orgs/[orgSlug]/dashboard/sessions/route.ts b/packages/web/src/app/api/orgs/[orgSlug]/dashboard/sessions/route.ts index 7eb1a578..b5e5e58d 100644 --- a/packages/web/src/app/api/orgs/[orgSlug]/dashboard/sessions/route.ts +++ b/packages/web/src/app/api/orgs/[orgSlug]/dashboard/sessions/route.ts @@ -71,7 +71,7 @@ function mapSessionItem(session: SessionWithInclude): SessionItem { } } -function csvField(value: string | number | null | undefined) { +export function csvField(value: string | number | null | undefined) { if (value === null || value === undefined) return '' let text = String(value) if (typeof value !== 'number' && /^[=+\-@\t\r\n]|^[=+-@]/.test(text)) { From 6ef41f6b7b2147a63de7207b2e574bd0c0fe17dd Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:24:27 +0000 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[HIGH]?= =?UTF-8?q?=20CSV=20Formula=20Injection=20=EC=B7=A8=EC=95=BD=EC=A0=90=20?= =?UTF-8?q?=ED=8C=A8=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/sessions/route.test.ts | 43 ------------------- .../[orgSlug]/dashboard/sessions/route.ts | 2 +- 2 files changed, 1 insertion(+), 44 deletions(-) delete mode 100644 packages/web/src/app/api/orgs/[orgSlug]/dashboard/sessions/route.test.ts diff --git a/packages/web/src/app/api/orgs/[orgSlug]/dashboard/sessions/route.test.ts b/packages/web/src/app/api/orgs/[orgSlug]/dashboard/sessions/route.test.ts deleted file mode 100644 index 4716bf14..00000000 --- a/packages/web/src/app/api/orgs/[orgSlug]/dashboard/sessions/route.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, test, expect, vi } from 'vitest' -import { csvField } from './route' - -vi.mock('server-only', () => ({})) - -describe('csvField', () => { - test('handles null and undefined', () => { - expect(csvField(null)).toBe('') - expect(csvField(undefined)).toBe('') - }) - - test('does not add prefix to numbers', () => { - expect(csvField(123)).toBe('123') - expect(csvField(0)).toBe('0') - expect(csvField(-456)).toBe('-456') - }) - - test('adds single quote prefix to prevent Formula Injection', () => { - expect(csvField('=cmd|C')).toBe("'=cmd|C") - expect(csvField('+1+2')).toBe("'+1+2") - expect(csvField('-3-4')).toBe("'-3-4") - expect(csvField('@sum()')).toBe("'@sum()") - expect(csvField('\tdata')).toBe("'\tdata") - - // 탭, 엔터로 시작하면 따옴표(quote)에 의해 감싸짐 + prefix ' - expect(csvField('\rdata')).toBe("\"'\rdata\"") - expect(csvField('\ndata')).toBe("\"'\ndata\"") - - // Full-width characters - expect(csvField('=test')).toBe("'=test") - expect(csvField('+test')).toBe("'+test") - expect(csvField('-test')).toBe("'-test") - expect(csvField('@test')).toBe("'@test") - }) - - test('escapes quotes correctly', () => { - expect(csvField('hello "world"')).toBe('"hello ""world"""') - }) - - test('escapes newlines correctly', () => { - expect(csvField('hello\nworld')).toBe("\"hello\nworld\"") - }) -}) diff --git a/packages/web/src/app/api/orgs/[orgSlug]/dashboard/sessions/route.ts b/packages/web/src/app/api/orgs/[orgSlug]/dashboard/sessions/route.ts index b5e5e58d..7eb1a578 100644 --- a/packages/web/src/app/api/orgs/[orgSlug]/dashboard/sessions/route.ts +++ b/packages/web/src/app/api/orgs/[orgSlug]/dashboard/sessions/route.ts @@ -71,7 +71,7 @@ function mapSessionItem(session: SessionWithInclude): SessionItem { } } -export function csvField(value: string | number | null | undefined) { +function csvField(value: string | number | null | undefined) { if (value === null || value === undefined) return '' let text = String(value) if (typeof value !== 'number' && /^[=+\-@\t\r\n]|^[=+-@]/.test(text)) { From 5e434d87d3761b9ac7e88f50dd50f1b492941ce4 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:27:09 +0000 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[HIGH]?= =?UTF-8?q?=20CSV=20Formula=20Injection=20=EC=B7=A8=EC=95=BD=EC=A0=90=20?= =?UTF-8?q?=ED=8C=A8=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit