From b1cac2ffc0ccfd8854bbfe32dd17ef6c5f4424f6 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Thu, 20 Aug 2026 21:39:39 +0000 Subject: [PATCH 1/4] =?UTF-8?q?=EC=84=B8=EC=85=98=20=EB=82=B4=EB=B3=B4?= =?UTF-8?q?=EB=82=B4=EA=B8=B0=EC=97=90=EC=84=9C=20CSV=20=EC=88=98=EC=8B=9D?= =?UTF-8?q?=20=EC=A3=BC=EC=9E=85(Spreadsheet=20Macro=20Injection)=20?= =?UTF-8?q?=EC=B7=A8=EC=95=BD=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 ++ .../[orgSlug]/dashboard/sessions/route.ts | 6 +-- .../web/src/lib/server/csv/export.test.ts | 41 +++++++++++++++++++ packages/web/src/lib/server/csv/export.ts | 12 ++++++ 4 files changed, 58 insertions(+), 5 deletions(-) create mode 100644 packages/web/src/lib/server/csv/export.test.ts create mode 100644 packages/web/src/lib/server/csv/export.ts diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 7902c442..33d630cf 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. +## 2026-08-20 - [Fix CSV Formula Injection] +**Vulnerability:** When exporting CSV, fields starting with `=`, `+`, `-`, `@`, ` `, or ` ` were not escaped which led to Spreadsheet Macro Injection vulnerabilities when the exported CSV file is opened in tools like Excel. +**Learning:** When creating CSV files, untrusted input must be sanitized. If an entry begins with a character that could be interpreted as a macro, a single quote should be prepended to force the spreadsheet to read it as a string. +**Prevention:** Create a shared `csvField` utility function and consistently apply it across all CSV endpoints, ensuring that values that could trigger macros are prepended with a single quote. 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..83c23c8d 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 @@ -10,6 +10,7 @@ import { resolveOrgScopedProjectIds, } from '@/lib/server/dashboard-route-helper' import { canAccessIndividualData, forbiddenByRole } from '@/lib/server/rbac' +import { csvField } from '@/lib/server/csv/export' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -71,11 +72,6 @@ function mapSessionItem(session: SessionWithInclude): SessionItem { } } -function csvField(value: string | number | null | undefined) { - if (value === null || value === undefined) return '' - const text = String(value) - return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text -} function buildSessionsCsv(sessions: SessionWithInclude[]) { const headers = [ diff --git a/packages/web/src/lib/server/csv/export.test.ts b/packages/web/src/lib/server/csv/export.test.ts new file mode 100644 index 00000000..dbf2f6a6 --- /dev/null +++ b/packages/web/src/lib/server/csv/export.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest' +import { csvField } from './export' + +describe('csvField', () => { + it('returns empty string for null or undefined', () => { + expect(csvField(null)).toBe('') + expect(csvField(undefined)).toBe('') + }) + + it('escapes quotes and wraps in quotes if text contains a quote', () => { + expect(csvField('hello "world"')).toBe('"hello ""world"""') + }) + + it('wraps in quotes if text contains comma', () => { + expect(csvField('hello, world')).toBe('"hello, world"') + }) + + it('wraps in quotes if text contains newlines', () => { + expect(csvField('hello\nworld')).toBe('"hello\nworld"') + expect(csvField('hello\rworld')).toBe('"hello\rworld"') + }) + + it('prepends a single quote to string values starting with formula chars', () => { + expect(csvField('=1+2')).toBe("'=1+2") + expect(csvField('+1+2')).toBe("'+1+2") + expect(csvField('-1+2')).toBe("'-1+2") + expect(csvField('@1+2')).toBe("'@1+2") + expect(csvField('\t1+2')).toBe("'\t1+2") + expect(csvField('\r1+2')).toBe('"\'\r1+2"') + }) + + it('does not prepend single quote to raw numbers', () => { + expect(csvField(123)).toBe('123') + expect(csvField(-123)).toBe('-123') + expect(csvField(0)).toBe('0') + }) + + it('handles formula characters combined with quotes or commas properly', () => { + expect(csvField('="hello"')).toBe('"\'=""hello"""') + }) +}) diff --git a/packages/web/src/lib/server/csv/export.ts b/packages/web/src/lib/server/csv/export.ts new file mode 100644 index 00000000..90b5e4d1 --- /dev/null +++ b/packages/web/src/lib/server/csv/export.ts @@ -0,0 +1,12 @@ +export function csvField(value: string | number | null | undefined) { + if (value === null || value === undefined) return '' + let text = String(value) + + // Prevent CSV Formula Injection (Spreadsheet Macro Injection) + // Ensure that numbers retain their original formatting without injection prepending + if (typeof value !== 'number' && /^[=+\-@\t\r]/.test(text)) { + text = "'" + text + } + + return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text +} From 664b18fa80588409320cf48d7f64cee5549ccc0e Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:35:37 +0000 Subject: [PATCH 2/4] =?UTF-8?q?chore:=20deepmerge-ts=20=ED=8C=A8=ED=82=A4?= =?UTF-8?q?=EC=A7=80=20=EB=B2=84=EC=A0=84=207.1.6=20=EA=B0=95=EC=A0=9C=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=ED=95=98=EC=97=AC=20CVE-2026-40345=20?= =?UTF-8?q?=EC=B7=A8=EC=95=BD=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 --- package.json | 3 ++- pnpm-lock.yaml | 9 +++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index d085ba62..439b1631 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "undici": "^7.29.0", "minimatch": "^10.0.0", "@hono/node-server": "^2.0.5", - "body-parser": "^2.3.0" + "body-parser": "^2.3.0", + "deepmerge-ts": "7.1.6" } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6dfd315f..ba768075 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,6 +22,7 @@ overrides: minimatch: ^10.0.0 '@hono/node-server': ^2.0.5 body-parser: ^2.3.0 + deepmerge-ts: 7.1.6 pnpmfileChecksum: qsp27c6veblwg3gxusbbzrumtm @@ -2278,8 +2279,8 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - deepmerge-ts@7.1.5: - resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} + deepmerge-ts@7.1.6: + resolution: {integrity: sha512-gQhL1ksGBLQbHeAo47YU6cs2ahd3Pv+8PFYoWogNZbQnNwQyOh9Ad5kbeHFiWwbKdeWQJ5Z7Y7mwb9ew2hXBLw==} engines: {node: '>=16.0.0'} deepmerge@4.3.1: @@ -5659,7 +5660,7 @@ snapshots: '@prisma/config@6.19.3(magicast@0.3.5)': dependencies: c12: 3.1.0(magicast@0.3.5) - deepmerge-ts: 7.1.5 + deepmerge-ts: 7.1.6 effect: 3.21.0 empathic: 2.0.0 transitivePeerDependencies: @@ -6660,7 +6661,7 @@ snapshots: deep-is@0.1.4: {} - deepmerge-ts@7.1.5: {} + deepmerge-ts@7.1.6: {} deepmerge@4.3.1: {} From 476e187a341485bd4871be8dbe6c781ad2fbf62b Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:43:24 +0000 Subject: [PATCH 3/4] =?UTF-8?q?chore:=20deepmerge-ts=20=ED=8C=A8=ED=82=A4?= =?UTF-8?q?=EC=A7=80=208.0.0=20=EB=B2=84=EC=A0=84=20=EA=B0=95=EC=A0=9C=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=ED=95=98=EC=97=AC=20CVE-2026-40345=20?= =?UTF-8?q?=EC=B7=A8=EC=95=BD=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 --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 439b1631..3b3196c1 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "minimatch": "^10.0.0", "@hono/node-server": "^2.0.5", "body-parser": "^2.3.0", - "deepmerge-ts": "7.1.6" + "deepmerge-ts": "8.0.0" } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba768075..126f48d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,7 +22,7 @@ overrides: minimatch: ^10.0.0 '@hono/node-server': ^2.0.5 body-parser: ^2.3.0 - deepmerge-ts: 7.1.6 + deepmerge-ts: 8.0.0 pnpmfileChecksum: qsp27c6veblwg3gxusbbzrumtm @@ -2279,8 +2279,8 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - deepmerge-ts@7.1.6: - resolution: {integrity: sha512-gQhL1ksGBLQbHeAo47YU6cs2ahd3Pv+8PFYoWogNZbQnNwQyOh9Ad5kbeHFiWwbKdeWQJ5Z7Y7mwb9ew2hXBLw==} + deepmerge-ts@8.0.0: + resolution: {integrity: sha512-ICNjaP0ML+eSdEpJYQC46XiAn/UjAdwbEl0dE8p85ZTeNDinN4Kd4+9jS4OSAuH7st6eC7rQhsqTF5zIDaUm2g==} engines: {node: '>=16.0.0'} deepmerge@4.3.1: @@ -5660,7 +5660,7 @@ snapshots: '@prisma/config@6.19.3(magicast@0.3.5)': dependencies: c12: 3.1.0(magicast@0.3.5) - deepmerge-ts: 7.1.6 + deepmerge-ts: 8.0.0 effect: 3.21.0 empathic: 2.0.0 transitivePeerDependencies: @@ -6661,7 +6661,7 @@ snapshots: deep-is@0.1.4: {} - deepmerge-ts@7.1.6: {} + deepmerge-ts@8.0.0: {} deepmerge@4.3.1: {} From da09a8e107e1238e55f8d45c07df7d37d582f232 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Fri, 21 Aug 2026 01:33:15 +0000 Subject: [PATCH 4/4] =?UTF-8?q?chore:=20deepmerge-ts=20=ED=8C=A8=ED=82=A4?= =?UTF-8?q?=EC=A7=80=208.0.1=20=EB=B2=84=EC=A0=84=20=EA=B0=95=EC=A0=9C=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=ED=95=98=EC=97=AC=20CVE-2026-40345=20?= =?UTF-8?q?=EC=B7=A8=EC=95=BD=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 --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 3b3196c1..66dbf948 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "minimatch": "^10.0.0", "@hono/node-server": "^2.0.5", "body-parser": "^2.3.0", - "deepmerge-ts": "8.0.0" + "deepmerge-ts": "8.0.1" } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 126f48d9..dabf7706 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,7 +22,7 @@ overrides: minimatch: ^10.0.0 '@hono/node-server': ^2.0.5 body-parser: ^2.3.0 - deepmerge-ts: 8.0.0 + deepmerge-ts: 8.0.1 pnpmfileChecksum: qsp27c6veblwg3gxusbbzrumtm @@ -2279,8 +2279,8 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - deepmerge-ts@8.0.0: - resolution: {integrity: sha512-ICNjaP0ML+eSdEpJYQC46XiAn/UjAdwbEl0dE8p85ZTeNDinN4Kd4+9jS4OSAuH7st6eC7rQhsqTF5zIDaUm2g==} + deepmerge-ts@8.0.1: + resolution: {integrity: sha512-szCXE7YLCvLKR9bFPJcvsezOShdalctSvrgN/LM/QGUEPZQajwjmsMObZ6/DuANT5lxzM/wtO8Feubwdkz8myA==} engines: {node: '>=16.0.0'} deepmerge@4.3.1: @@ -5660,7 +5660,7 @@ snapshots: '@prisma/config@6.19.3(magicast@0.3.5)': dependencies: c12: 3.1.0(magicast@0.3.5) - deepmerge-ts: 8.0.0 + deepmerge-ts: 8.0.1 effect: 3.21.0 empathic: 2.0.0 transitivePeerDependencies: @@ -6661,7 +6661,7 @@ snapshots: deep-is@0.1.4: {} - deepmerge-ts@8.0.0: {} + deepmerge-ts@8.0.1: {} deepmerge@4.3.1: {}