diff --git a/CHANGELOG.md b/CHANGELOG.md index 19694452..99f2d37d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### ๐Ÿ›ก๏ธ ๋ณด์•ˆ (Security) +- ์กฐ์ง ์„ธ์…˜ CSV ๋‚ด๋ณด๋‚ด๊ธฐ์—์„œ ๋ฌธ์ž์—ด ์…€์ด ๊ณต๋ฐฑยทํƒญยท๊ฐœํ–‰ ๋’ค์— `=`, `+`, `-`, `@` ๋˜๋Š” ๋Œ€์‘ ์ „๊ฐ ๋ฌธ์ž๋ฅผ ํฌํ•จํ•ด ์ˆ˜์‹์œผ๋กœ ํ•ด์„๋  ์ˆ˜ ์žˆ์œผ๋ฉด ์•ž์— `'`๋ฅผ ๋ถ™์—ฌ ํ…์ŠคํŠธ๋กœ ๋‚ด๋ณด๋ƒ…๋‹ˆ๋‹ค. ์‰ผํ‘œยท๋”ฐ์˜ดํ‘œยท๊ฐœํ–‰ CSV quoting๊ณผ ์ˆซ์ž ํƒ€์ž…์˜ ๊ธฐ์กด ํ‘œํ˜„์€ ์œ ์ง€ํ•˜๋ฉฐ, ํšŒ๊ท€ ํ…Œ์ŠคํŠธ๋กœ ์œ„ํ—˜ ๋ฌธ์ž์—ด๊ณผ ์ •์ƒ ์ˆซ์ž ๊ฒฝ๊ณ„๋ฅผ ๊ณ ์ •ํ–ˆ์Šต๋‹ˆ๋‹ค. - ๋กœ๊ทธ์ธ, ํšŒ์›๊ฐ€์ž…, ๋น„๋ฐ€๋ฒˆํ˜ธ ์žฌ์„ค์ •์ด ํ•˜๋‚˜์˜ ๊ณต์œ  ๋น„๋ฐ€๋ฒˆํ˜ธ ๊ณ„์•ฝ์„ ์‚ฌ์šฉํ•˜๋„๋ก ํ†ตํ•ฉํ–ˆ์Šต๋‹ˆ๋‹ค. ์ž…๋ ฅ ์ฒ˜๋ฆฌ๋Ÿ‰์„ 1,024์ž๋กœ ๋จผ์ € ์ œํ•œํ•˜๊ณ , ํ˜„์žฌ `bcryptjs`๊ฐ€ ์™„์ „ํ•˜๊ฒŒ ๊ฒ€์ฆํ•  ์ˆ˜ ์žˆ๋Š” 72 UTF-8 ๋ฐ”์ดํŠธ๋ฅผ ์ดˆ๊ณผํ•˜๋Š” ๊ฐ’์€ ์กฐ์šฉํžˆ ์ž˜๋ผ๋‚ด์ง€ ์•Š๊ณ  ๊ฑฐ๋ถ€ํ•ฉ๋‹ˆ๋‹ค. ASCII์™€ ๋‹ค์ค‘ ๋ฐ”์ดํŠธ Unicode ๊ฒฝ๊ณ„ ํšŒ๊ท€ ํ…Œ์ŠคํŠธ ๋ฐ ์šด์˜ยทํ‘œ์ค€ ๊ทผ๊ฑฐ ๋ฌธ์„œ๋ฅผ ํ•จ๊ป˜ ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค. ### โšก ์„ฑ๋Šฅ (Performance) 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..1e02bf60 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,12 +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 = [ 'Session ID', 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..6ec28895 --- /dev/null +++ b/packages/web/src/lib/server/csv/export.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest' +import { csvField } from './export' + +describe('csvField', () => { + it('handles null and undefined', () => { + expect(csvField(null)).toBe('') + expect(csvField(undefined)).toBe('') + }) + + it('escapes quotes and wraps in quotes if necessary', () => { + expect(csvField('normal string')).toBe('normal string') + expect(csvField('string with "quotes"')).toBe('"string with ""quotes"""') + expect(csvField('string,with,commas')).toBe('"string,with,commas"') + expect(csvField('string\r\nwith\nnewlines')).toBe('"string\r\nwith\nnewlines"') + }) + + it('neutralizes formula-leading string cells before CSV quoting', () => { + expect(csvField('=1+1')).toBe(`'=1+1`) + expect(csvField('+1')).toBe(`'+1`) + expect(csvField('-1')).toBe(`'-1`) + expect(csvField('@sum')).toBe(`'@sum`) + expect(csvField('\t=1+1')).toBe(`'\t=1+1`) + expect(csvField('\r=1+1')).toBe(`"'\r=1+1"`) + expect(csvField('\n=1+1')).toBe(`"'\n=1+1"`) + expect(csvField(' =1+1')).toBe(`' =1+1`) + expect(csvField('\u00a0=1+1')).toBe(`'\u00a0=1+1`) + expect(csvField('=SUM(1,2)')).toBe(`"'=SUM(1,2)"`) + }) + + it('neutralizes full-width formula initiators used by some spreadsheet locales', () => { + expect(csvField('\uff1d1+1')).toBe(`'\uff1d1+1`) + expect(csvField('\uff0b1')).toBe(`'\uff0b1`) + expect(csvField('\uff0d1')).toBe(`'\uff0d1`) + expect(csvField('\uff20sum')).toBe(`'\uff20sum`) + }) + + it('preserves formatting of safe values', () => { + expect(csvField('')).toBe('') + expect(csvField("'already-text")).toBe("'already-text") + expect(csvField(123)).toBe('123') + expect(csvField(-123)).toBe('-123') + expect(csvField(0)).toBe('0') + }) +}) 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..47cf1aca --- /dev/null +++ b/packages/web/src/lib/server/csv/export.ts @@ -0,0 +1,23 @@ +/** + * Serialize one CSV cell while reducing spreadsheet formula-interpretation risk. + * + * String values whose first non-whitespace character is a known formula trigger + * receive a leading apostrophe before ordinary CSV quoting. Numeric values keep + * their numeric representation. This is an export-boundary mitigation, not a + * universal spreadsheet sandbox: downstream applications may reinterpret CSV + * content after edits or re-saving, so callers must not treat the output as a + * trusted executable document format. + */ +export function csvField(value: string | number | null | undefined) { + if (value === null || value === undefined) return '' + + if (typeof value === 'string') { + const trimmed = value.trimStart() + if (/^[=+\-@\t\r\n\uFF1D\uFF0B\uFF0D\uFF20]/.test(trimmed)) { + value = `'${value}` + } + } + + const text = String(value) + return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text +}