Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### 🛡️ 보안 (Security)

- 조직 세션 CSV 내보내기에서 문자열 셀이 공백·탭·개행 뒤에 `=`, `+`, `-`, `@` 또는 대응 전각 문자를 포함해 수식으로 해석될 수 있으면 앞에 `'`를 붙여 텍스트로 내보냅니다. 쉼표·따옴표·개행 CSV quoting과 숫자 타입의 기존 표현은 유지하며, 회귀 테스트로 위험 문자열과 정상 숫자 경계를 고정했습니다.
- 로그인, 회원가입, 비밀번호 재설정이 하나의 공유 비밀번호 계약을 사용하도록 통합했습니다. 입력 처리량을 1,024자로 먼저 제한하고, 현재 `bcryptjs`가 완전하게 검증할 수 있는 72 UTF-8 바이트를 초과하는 값은 조용히 잘라내지 않고 거부합니다. ASCII와 다중 바이트 Unicode 경계 회귀 테스트 및 운영·표준 근거 문서를 함께 추가했습니다.

### ⚡ 성능 (Performance)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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',
Expand Down
44 changes: 44 additions & 0 deletions packages/web/src/lib/server/csv/export.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
23 changes: 23 additions & 0 deletions packages/web/src/lib/server/csv/export.ts
Original file line number Diff line number Diff line change
@@ -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
}
Loading