diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 7902c442..54a164e3 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. +## 2025-02-18 - [Fix CSV Formula Injection (Macro Injection)] +**Vulnerability:** CSV 내보내기 기능에서 사용자 입력값(세션 타이틀, 프로젝트 이름 등)이 이스케이프 되지 않아 CSV Formula Injection (Macro Injection) 취약점이 발생할 수 있었습니다. +**Learning:** Excel, Google Sheets 등은 CSV 파일에서 `=, +, -, @, \t, \r, \n` 등의 문자로 시작하는 필드를 수식으로 해석하고 실행합니다. +**Prevention:** CSV로 데이터를 내보낼 때, 원시 `number` 타입이 아닌 문자열 필드가 수식 트리거 문자로 시작하는 경우, 값 앞에 작은따옴표(`'`)를 추가하여 일반 문자열로 처리되도록 해야 합니다. diff --git a/package.json b/package.json index d085ba62..1dc69f7d 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0", "pnpm": { "overrides": { + "browserslist": "^4.28.9", + "deepmerge-ts": "^8.0.2", "@babel/core": "7.29.7", "esbuild": "0.28.1", "hono": "^4.12.34", 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..a2432faf 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 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.test.ts b/packages/web/src/lib/server/csv.test.ts new file mode 100644 index 00000000..0cd4c5c5 --- /dev/null +++ b/packages/web/src/lib/server/csv.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { csvField } from './csv' + +describe('csvField', () => { + it('handles null and undefined', () => { + expect(csvField(null)).toBe('') + expect(csvField(undefined)).toBe('') + }) + + it('preserves numbers', () => { + expect(csvField(123)).toBe('123') + expect(csvField(-456)).toBe('-456') + expect(csvField(0)).toBe('0') + }) + + it('prepends single quote to formula injection triggers', () => { + expect(csvField('=cmd')).toBe("'=cmd") + expect(csvField('+cmd')).toBe("'+cmd") + expect(csvField('-cmd')).toBe("'-cmd") + expect(csvField('@cmd')).toBe("'@cmd") + expect(csvField('\tcmd')).toBe("'\tcmd") + // \r and \n are handled by the next check and will be quoted + expect(csvField('\rcmd')).toBe('"\'\rcmd"') + expect(csvField('\ncmd')).toBe('"\'\ncmd"') + }) + + it('prepends single quote to full-width formula injection triggers', () => { + expect(csvField('\uff1dcmd')).toBe("'\uff1dcmd") + expect(csvField('\uff0bcmd')).toBe("'\uff0bcmd") + expect(csvField('\uff0dcmd')).toBe("'\uff0dcmd") + expect(csvField('\uff20cmd')).toBe("'\uff20cmd") + }) + + it('prepends single quote even with leading spaces', () => { + expect(csvField(' =cmd')).toBe("' =cmd") + expect(csvField(' -cmd')).toBe("' -cmd") + expect(csvField(' \tcmd')).toBe("' \tcmd") + }) + + it('escapes quotes and handles commas', () => { + expect(csvField('normal,string')).toBe('"normal,string"') + expect(csvField('string with "quotes"')).toBe('"string with ""quotes"""') + expect(csvField('multi\nline')).toBe('"multi\nline"') + }) + + it('does not prepend quote for normal strings', () => { + expect(csvField('normal')).toBe('normal') + expect(csvField(' normal')).toBe(' normal') + expect(csvField('123')).toBe('123') + }) + + it('combines formula injection prevention with quote escaping', () => { + expect(csvField('=cmd,test')).toBe('"\'=cmd,test"') + expect(csvField('=-"test"')).toBe('"\'=-""test"""') + }) +}) diff --git a/packages/web/src/lib/server/csv.ts b/packages/web/src/lib/server/csv.ts new file mode 100644 index 00000000..d258613e --- /dev/null +++ b/packages/web/src/lib/server/csv.ts @@ -0,0 +1,9 @@ +export function csvField(value: string | number | null | undefined) { + if (value === null || value === undefined) return '' + let text = String(value) + // Prevent CSV Formula Injection (Macro Injection) + if (typeof value !== 'number' && /^ *[=+\-@\t\r\n\uff1d\uff0b\uff0d\uff20]/.test(text)) { + text = "'" + text + } + return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6dfd315f..3e67acba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,8 @@ settings: excludeLinksFromLockfile: false overrides: + browserslist: ^4.28.9 + deepmerge-ts: ^8.0.2 '@babel/core': 7.29.7 esbuild: 0.28.1 hono: ^4.12.34 @@ -1939,8 +1941,8 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.10.33: - resolution: {integrity: sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==} + baseline-browser-mapping@2.11.21: + resolution: {integrity: sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==} engines: {node: '>=6.0.0'} hasBin: true @@ -1962,8 +1964,8 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.28.2: - resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + browserslist@4.28.9: + resolution: {integrity: sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -2006,6 +2008,9 @@ packages: caniuse-lite@1.0.30001793: resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -2278,9 +2283,9 @@ 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==} - engines: {node: '>=16.0.0'} + deepmerge-ts@8.0.2: + resolution: {integrity: sha512-uqbvqLUMrc6p0MO+WBRtTxY55hmyh94WRwI5a++PZe54X+bfVh59FSN7uWCBCW1CCVjzjnrwzfI8zidE2obMMw==} + engines: {node: '>=16.9.0'} deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} @@ -2369,8 +2374,8 @@ packages: effect@3.21.0: resolution: {integrity: sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==} - electron-to-chromium@1.5.364: - resolution: {integrity: sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==} + electron-to-chromium@1.5.422: + resolution: {integrity: sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -3673,8 +3678,8 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - node-releases@2.0.46: - resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==} + node-releases@2.0.54: + resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} engines: {node: '>=18'} npm-run-path@4.0.1: @@ -4526,11 +4531,11 @@ packages: until-async@3.0.2: resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + update-browserslist-db@1.3.2: + resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==} hasBin: true peerDependencies: - browserslist: '>= 4.21.0' + browserslist: ^4.28.9 uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -4845,7 +4850,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.7 '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.2 + browserslist: 4.28.9 lru-cache: 5.1.1 semver: 6.3.1 @@ -5659,7 +5664,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: 8.0.2 effect: 3.21.0 empathic: 2.0.0 transitivePeerDependencies: @@ -6359,7 +6364,7 @@ snapshots: balanced-match@4.0.4: {} - baseline-browser-mapping@2.10.33: {} + baseline-browser-mapping@2.11.21: {} bcryptjs@2.4.3: {} @@ -6389,13 +6394,13 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.28.2: + browserslist@4.28.9: dependencies: - baseline-browser-mapping: 2.10.33 - caniuse-lite: 1.0.30001793 - electron-to-chromium: 1.5.364 - node-releases: 2.0.46 - update-browserslist-db: 1.2.3(browserslist@4.28.2) + baseline-browser-mapping: 2.11.21 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.422 + node-releases: 2.0.54 + update-browserslist-db: 1.3.2(browserslist@4.28.9) bundle-name@4.1.0: dependencies: @@ -6443,6 +6448,8 @@ snapshots: caniuse-lite@1.0.30001793: {} + caniuse-lite@1.0.30001810: {} + ccount@2.0.1: {} chai@5.3.3: @@ -6660,7 +6667,7 @@ snapshots: deep-is@0.1.4: {} - deepmerge-ts@7.1.5: {} + deepmerge-ts@8.0.2: {} deepmerge@4.3.1: {} @@ -6740,7 +6747,7 @@ snapshots: '@standard-schema/spec': 1.1.0 fast-check: 3.23.2 - electron-to-chromium@1.5.364: {} + electron-to-chromium@1.5.422: {} emoji-regex@10.6.0: {} @@ -8406,7 +8413,7 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 - node-releases@2.0.46: {} + node-releases@2.0.54: {} npm-run-path@4.0.1: dependencies: @@ -8999,7 +9006,7 @@ snapshots: '@dotenvx/dotenvx': 1.70.0 '@modelcontextprotocol/sdk': 1.29.0(zod@3.25.76) '@types/validate-npm-package-name': 4.0.2 - browserslist: 4.28.2 + browserslist: 4.28.9 commander: 14.0.3 cosmiconfig: 9.0.1(typescript@5.9.3) dedent: 1.7.2 @@ -9491,9 +9498,9 @@ snapshots: until-async@3.0.2: {} - update-browserslist-db@1.2.3(browserslist@4.28.2): + update-browserslist-db@1.3.2(browserslist@4.28.9): dependencies: - browserslist: 4.28.2 + browserslist: 4.28.9 escalade: 3.2.0 picocolors: 1.1.1