Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 타입이 아닌 문자열 필드가 수식 트리거 문자로 시작하는 경우, 값 앞에 작은따옴표(`'`)를 추가하여 일반 문자열로 처리되도록 해야 합니다.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
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 const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
Expand Down Expand Up @@ -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 = [
Expand Down
56 changes: 56 additions & 0 deletions packages/web/src/lib/server/csv.test.ts
Original file line number Diff line number Diff line change
@@ -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"""')
})
})
9 changes: 9 additions & 0 deletions packages/web/src/lib/server/csv.ts
Original file line number Diff line number Diff line change
@@ -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
}
65 changes: 36 additions & 29 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading