Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
13 commits
Select commit Hold shift + click to select a range
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.
## 2024-10-24 - CSV 수식 주입(CSV Formula Injection/Macro Injection) 취약점 수정
**Vulnerability:** 사용자 데이터가 포함된 CSV 파일 다운로드 기능에서 데이터 앞부분에 `=`, `+`, `-`, `@` 등의 문자가 올 경우, 스프레드시트 프로그램에서 이를 수식으로 해석하여 악성 매크로가 실행될 위험이 존재함.
**Learning:** CSV 필드를 단순 문자열로만 이스케이프할 경우, 엑셀 등의 프로그램의 자동 수식 파싱을 방지하지 못함.
**Prevention:** CSV 생성 시, `number` 타입이 아닌 필드 중 수식 시작 문자로 시작하는 경우, 값의 맨 앞에 작은따옴표(`'`)를 추가하여 강제로 일반 텍스트로 인식되도록 처리해야 함.
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@
"undici": "^7.29.0",
"minimatch": "^10.0.0",
"@hono/node-server": "^2.0.5",
"body-parser": "^2.3.0"
"body-parser": "^2.3.0",
"baseline-browser-mapping": "2.10.36",
"browserslist": "4.28.6",
"deepmerge-ts": "7.1.6"
}
}
}
}
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-helper'

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
50 changes: 50 additions & 0 deletions packages/web/src/lib/server/csv-helper.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { csvField } from './csv-helper'

describe('csvField', () => {
it('returns empty string for null or undefined', () => {
expect(csvField(null)).toBe('')
expect(csvField(undefined)).toBe('')
})

it('formats regular strings and numbers properly', () => {
expect(csvField('hello')).toBe('hello')
expect(csvField(123)).toBe('123')
expect(csvField(-123)).toBe('-123')
})

it('quotes strings with commas or newlines', () => {
expect(csvField('hello, world')).toBe('"hello, world"')
expect(csvField('hello\nworld')).toBe('"hello\nworld"')
expect(csvField('hello\rworld')).toBe('"hello\rworld"')
})

it('escapes existing double quotes', () => {
expect(csvField('hello "world"')).toBe('"hello ""world"""')
})

it('prevents CSV Formula Injection (Macro Injection) by prepending a single quote', () => {
// Exact prefixes
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")
// \r and \n are also matched by /[\",\\r\\n]/ so they get quoted
expect(csvField('\r1+2')).toBe("\"'\r1+2\"")
expect(csvField('\n1+2')).toBe("\"'\n1+2\"")

// Full-width prefixes
expect(csvField('\uff1d1+2')).toBe("'\uff1d1+2")
expect(csvField('\uff0b1+2')).toBe("'\uff0b1+2")
expect(csvField('\uff0d1+2')).toBe("'\uff0d1+2")
expect(csvField('\uff201+2')).toBe("'\uff201+2")

// With leading spaces
expect(csvField(' =1+2')).toBe("' =1+2")
expect(csvField(' \t-1+2')).toBe("' \t-1+2")

// Combining prefixes and quotes/commas
expect(csvField('=1,2')).toBe("\"'=1,2\"")
})
})
11 changes: 11 additions & 0 deletions packages/web/src/lib/server/csv-helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export function csvField(value: string | number | null | undefined) {
if (value === null || value === undefined) return ''
let text = String(value)

// Prevent CSV Injection (Macro Injection)
if (typeof value !== 'number' && /^[\s]*[=+\-@\t\r\n\uff1d\uff0b\uff0d\uff20]/.test(text)) {
text = "'" + text
}

return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text
}
60 changes: 34 additions & 26 deletions pnpm-lock.yaml

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

Loading