Skip to content
Open
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
5 changes: 5 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,8 @@
**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 in Export]
**Vulnerability:** CSV Formula Injection (Spreadsheet Macro Injection) vulnerability in the CSV export functionality for sessions.
**Learning:** If user-controlled data starting with `=, +, -, @, \t, or \r` is exported directly to CSV and opened in Excel or similar applications, it can be executed as a malicious formula or macro. Simply quoting the CSV field (`"value"`) is insufficient to prevent formula execution in spreadsheet software.
**Prevention:** Prepend a single quote (`'`) to values that start with a formula prefix (`=, +, -, @, \t, \r`) unless the type is explicitly a number. This safely forces spreadsheet applications to treat the value as plain text. Ensure this utility is tested in isolation.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"hono": "^4.12.34",
"js-yaml": "4.3.1",
"nanoid": "3.3.18",
"deepmerge-ts": "8.0.0",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ” Unrelated deepmerge-ts override added

package.json pins deepmerge-ts to 8.0.0 in pnpm overrides, unrelated to the CSV fix. Confirm the pin is intentional and version-compatible across transitive paths.

Open in Devin Review

Was this helpful? React with πŸ‘ or πŸ‘Ž to provide feedback.

"@auth/core": "^0.41.3",
"sharp": "^0.35.3",
"postcss": "^8.5.18",
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
41 changes: 41 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,41 @@
import { describe, it, expect } from 'vitest'
import { csvField } from './export'

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

it('formats normal strings correctly', () => {
expect(csvField('hello')).toBe('hello')
expect(csvField('hello world')).toBe('hello world')
})

it('formats numbers correctly', () => {
expect(csvField(123)).toBe('123')
expect(csvField(0)).toBe('0')
expect(csvField(-123)).toBe('-123')
})

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

it('prepends single quote for strings vulnerable to CSV Formula Injection', () => {
expect(csvField('=1+1')).toBe('"\'=1+1"')
expect(csvField('=SUM(A1:A2)')).toBe('"\'=SUM(A1:A2)"')
expect(csvField('+1+1')).toBe('"\'+1+1"')
expect(csvField('-1+1')).toBe('"\'-1+1"')
expect(csvField('@SUM(A1)')).toBe('"\'@SUM(A1)"')
expect(csvField('\t1+1')).toBe('"\'\t1+1"')
expect(csvField('\r1+1')).toBe('"\'\r1+1"')
})

it('escapes quotes in malicious inputs properly', () => {
expect(csvField('="hello"')).toBe('"\'=""hello"""')
})
})
13 changes: 13 additions & 0 deletions packages/web/src/lib/server/csv/export.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export function csvField(value: string | number | null | undefined) {
if (value === null || value === undefined) return ''

if (typeof value !== 'number') {
const text = String(value)
if (/^[=+\-@\t\r]/.test(text)) {
return `"'${text.replaceAll('"', '""')}"`
}
Comment on lines +6 to +8

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ“ Info: Prompts starting with a dash get an apostrophe prefix

The formula guard (export.ts:6) matches any value starting with = + - @ \t \r. Prompts that legitimately begin with - are exported as '-..., changing the visible cell text. This is the accepted OWASP mitigation tradeoff, worth noting for anyone comparing exported values against source data.

Open in Devin Review

Was this helpful? React with πŸ‘ or πŸ‘Ž to provide feedback.

}

const text = String(value)
return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text
}
9 changes: 5 additions & 4 deletions pnpm-lock.yaml

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

Loading