From b2129ad62668f2666eb4903daef94873acc42280 Mon Sep 17 00:00:00 2001 From: Marko Djordjevic Date: Wed, 18 Feb 2026 11:20:36 +0100 Subject: [PATCH] security: add CSV injection protection to all export routes Add sanitizeCsvCell() helper to both export routes that prefixes cell values starting with =, +, @, or - with a single quote to prevent CSV formula injection attacks. Applied to: - src/app/api/export/route.ts: timestamp and label_type columns - src/app/api/span-annotations/export/route.ts: start_time, end_time, label, and outcome columns Closes task 4.10. Co-Authored-By: Claude Sonnet 4.6 --- openspec/changes/code-review-fix/tasks.md | 2 +- src/app/api/export/route.ts | 9 ++++++++- src/app/api/span-annotations/export/route.ts | 11 +++++++++-- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/openspec/changes/code-review-fix/tasks.md b/openspec/changes/code-review-fix/tasks.md index f1ca6b8..ba9a79d 100644 --- a/openspec/changes/code-review-fix/tasks.md +++ b/openspec/changes/code-review-fix/tasks.md @@ -35,7 +35,7 @@ - [x] 4.7 `[sonnet]` Require `chartId` for bulk delete in `src/app/api/annotations/route.ts` — reject `?all=true` without chartId with HTTP 400 - [x] 4.8 `[sonnet]` Wrap chart cascade delete in `db.transaction()` and add `spanAnnotations` deletion in `src/app/api/charts/[id]/route.ts` - [x] 4.9 `[haiku]` Add `parseInt(value, 10)` with `isNaN()` guard to all routes parsing integer query params -- [ ] 4.10 `[sonnet]` Add CSV injection protection (prefix `=+@-` cells with `'`) to all export routes +- [x] 4.10 `[sonnet]` Add CSV injection protection (prefix `=+@-` cells with `'`) to all export routes - [ ] 4.11 `[sonnet]` Add `response.ok` checks before `.json()` in `src/app/page.tsx` (lines 214, 230, 245, 257) - [ ] 4.12 `[sonnet]` Add `response.ok` checks before `.json()` in `src/components/CandleChart.tsx` (lines 163, 178, 192) diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts index 74355d5..d39ba09 100644 --- a/src/app/api/export/route.ts +++ b/src/app/api/export/route.ts @@ -3,6 +3,13 @@ import { db } from '@/lib/db'; import { annotations, candles, charts } from '@/lib/db/schema'; import { eq, and, desc } from 'drizzle-orm'; +function sanitizeCsvCell(value: string): string { + if (/^[=+@\-]/.test(value)) { + return `'${value}`; + } + return value; +} + export async function GET(request: NextRequest) { try { const { searchParams } = request.nextUrl; @@ -57,7 +64,7 @@ export async function GET(request: NextRequest) { } csvRows.push( - `${annotation.timestamp},${annotation.label_type},${price !== null ? price : ''}` + `${sanitizeCsvCell(String(annotation.timestamp))},${sanitizeCsvCell(annotation.label_type)},${price !== null ? price : ''}` ); } diff --git a/src/app/api/span-annotations/export/route.ts b/src/app/api/span-annotations/export/route.ts index 28e589c..a3e964e 100644 --- a/src/app/api/span-annotations/export/route.ts +++ b/src/app/api/span-annotations/export/route.ts @@ -3,6 +3,13 @@ import { db } from '@/lib/db'; import { spanAnnotations, charts } from '@/lib/db/schema'; import { eq, desc } from 'drizzle-orm'; +function sanitizeCsvCell(value: string): string { + if (/^[=+@\-]/.test(value)) { + return `'${value}`; + } + return value; +} + /** * GET /api/span-annotations/export * @@ -97,9 +104,9 @@ export async function GET(request: NextRequest) { for (const span of spans) { const notes = span.notes ? `"${span.notes.replace(/"/g, '""')}"` : ''; - + csvRows.push( - `${span.id},${span.start_time},${span.end_time},${span.label},${span.confidence || ''},${span.outcome || ''},${notes}` + `${span.id},${sanitizeCsvCell(String(span.start_time))},${sanitizeCsvCell(String(span.end_time))},${sanitizeCsvCell(span.label)},${span.confidence || ''},${span.outcome ? sanitizeCsvCell(span.outcome) : ''},${notes}` ); }