diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 7902c442..46eeec21 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -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] +**Vulnerability:** CSV exports generated user-controlled fields without escaping leading formula characters (e.g., `=`, `+`, `-`, `@`), enabling CSV Macro Injection. +**Learning:** When exporting user-generated content to CSV, any string starting with spreadsheet formula characters can execute arbitrary commands or leak data when opened in tools like Excel. +**Prevention:** Prepend a single quote (`'`) to non-numeric strings that start with `=, +, -, @` (including full-width equivalents and those preceded by whitespace) when serializing CSV fields. diff --git a/packages/web/src/app/api/orgs/[orgSlug]/dashboard/sessions/route.ts b/packages/web/src/app/api/orgs/[orgSlug]/dashboard/sessions/route.ts index 7d6a4d4a..b486b75b 100644 --- a/packages/web/src/app/api/orgs/[orgSlug]/dashboard/sessions/route.ts +++ b/packages/web/src/app/api/orgs/[orgSlug]/dashboard/sessions/route.ts @@ -1,18 +1,18 @@ -import { NextRequest, NextResponse } from 'next/server' -import type { PaginatedResult, SessionItem } from '@argos/shared' -import { Prisma } from '@prisma/client' -import { db } from '@/lib/server/db' -import { requireAuth } from '@/lib/server/auth-helper' -import { handleRouteError } from '@/lib/server/error-helper' -import { parseDateRange, parsePagination } from '@/lib/server/dashboard' +import { NextRequest, NextResponse } from "next/server"; +import type { PaginatedResult, SessionItem } from "@argos/shared"; +import { Prisma } from "@prisma/client"; +import { db } from "@/lib/server/db"; +import { requireAuth } from "@/lib/server/auth-helper"; +import { handleRouteError } from "@/lib/server/error-helper"; +import { parseDateRange, parsePagination } from "@/lib/server/dashboard"; import { assertOrgAccessBySlugOrResponse, resolveOrgScopedProjectIds, -} from '@/lib/server/dashboard-route-helper' -import { canAccessIndividualData, forbiddenByRole } from '@/lib/server/rbac' +} from "@/lib/server/dashboard-route-helper"; +import { canAccessIndividualData, forbiddenByRole } from "@/lib/server/rbac"; -export const runtime = 'nodejs' -export const dynamic = 'force-dynamic' +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; const sessionInclude = { user: { select: { id: true, name: true } }, @@ -22,34 +22,37 @@ const sessionInclude = { }, // Title fallback — 저장된 title이 없는 세션용으로 첫 HUMAN 메시지 1건만 로딩 messages: { - where: { role: 'HUMAN' as const }, - orderBy: [{ timestamp: 'asc' as const }, { sequence: 'asc' as const }], + where: { role: "HUMAN" as const }, + orderBy: [{ timestamp: "asc" as const }, { sequence: "asc" as const }], take: 1, select: { content: true }, }, _count: { select: { events: true } }, -} satisfies Prisma.ClaudeSessionInclude +} satisfies Prisma.ClaudeSessionInclude; -type SessionWithInclude = Prisma.ClaudeSessionGetPayload<{ include: typeof sessionInclude }> +type SessionWithInclude = Prisma.ClaudeSessionGetPayload<{ + include: typeof sessionInclude; +}>; function getSessionTotals(session: SessionWithInclude) { - let inputTokens = 0 - let outputTokens = 0 - let estimatedCostUsd = 0 + let inputTokens = 0; + let outputTokens = 0; + let estimatedCostUsd = 0; for (const r of session.usageRecords) { - inputTokens += r.inputTokens - outputTokens += r.outputTokens - estimatedCostUsd += r.estimatedCostUsd ?? 0 + inputTokens += r.inputTokens; + outputTokens += r.outputTokens; + estimatedCostUsd += r.estimatedCostUsd ?? 0; } - return { inputTokens, outputTokens, estimatedCostUsd } + return { inputTokens, outputTokens, estimatedCostUsd }; } function mapSessionItem(session: SessionWithInclude): SessionItem { - const totals = getSessionTotals(session) - const fallbackTitle = session.messages[0]?.content.slice(0, 200).trim() || null - const title = session.title?.trim() || fallbackTitle + const totals = getSessionTotals(session); + const fallbackTitle = + session.messages[0]?.content.slice(0, 200).trim() || null; + const title = session.title?.trim() || fallbackTitle; return { id: session.id, @@ -68,113 +71,136 @@ function mapSessionItem(session: SessionWithInclude): SessionItem { slug: session.project.slug, name: session.project.name, }, - } + }; } 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 + if (value === null || value === undefined) return ""; + let text = String(value); + + // Prevent CSV Formula Injection (Spreadsheet Macro Injection) + if (typeof value !== "number") { + if (/^\s*[=+\-@\t\r\n=+-@]/.test(text)) { + text = "'" + text; + } + } + + return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text; } function buildSessionsCsv(sessions: SessionWithInclude[]) { const headers = [ - 'Session ID', - 'User', - 'Project', - 'Title', - 'First Prompt', - 'Input Tokens', - 'Output Tokens', - 'Estimated Cost USD', - 'Event Count', - 'Started At', - 'Ended At', - ] + "Session ID", + "User", + "Project", + "Title", + "First Prompt", + "Input Tokens", + "Output Tokens", + "Estimated Cost USD", + "Event Count", + "Started At", + "Ended At", + ]; const rows = sessions.map((session) => { - const totals = getSessionTotals(session) - const title = session.title?.trim() || session.messages[0]?.content.slice(0, 200).trim() || '' + const totals = getSessionTotals(session); + const title = + session.title?.trim() || + session.messages[0]?.content.slice(0, 200).trim() || + ""; return [ session.id, session.user.name, session.project.name, title, - session.messages[0]?.content ?? '', + session.messages[0]?.content ?? "", totals.inputTokens, totals.outputTokens, totals.estimatedCostUsd, session._count.events, session.startedAt.toISOString(), - session.endedAt?.toISOString() ?? '', - ].map(csvField).join(',') - }) + session.endedAt?.toISOString() ?? "", + ] + .map(csvField) + .join(","); + }); - return `\uFEFF${[headers.join(','), ...rows].join('\r\n')}` + return `\uFEFF${[headers.join(","), ...rows].join("\r\n")}`; } // GET /api/orgs/:orgSlug/dashboard/sessions?from=&to=&projectId=&page=&pageSize=&sort=recent|cost export async function GET( req: NextRequest, - { params }: { params: Promise<{ orgSlug: string }> } + { params }: { params: Promise<{ orgSlug: string }> }, ) { try { - const auth = await requireAuth(req) - if (auth instanceof NextResponse) return auth - const { userId } = auth - const { orgSlug } = await params + const auth = await requireAuth(req); + if (auth instanceof NextResponse) return auth; + const { userId } = auth; + const { orgSlug } = await params; - const access = await assertOrgAccessBySlugOrResponse(orgSlug, userId) - if (access instanceof NextResponse) return access + const access = await assertOrgAccessBySlugOrResponse(orgSlug, userId); + if (access instanceof NextResponse) return access; // Viewer 차단: 세션 목록은 title에 HUMAN 프롬프트 원문(PII 가능)이 포함되므로 전체 금지. if (!canAccessIndividualData(access.role)) { - return forbiddenByRole(access.role, 'MEMBER 이상') + return forbiddenByRole(access.role, "MEMBER 이상"); } - const projectIdParam = req.nextUrl.searchParams.get('projectId') - const projectIds = await resolveOrgScopedProjectIds(access.org.id, userId, access.role, projectIdParam) - if (projectIds instanceof NextResponse) return projectIds + const projectIdParam = req.nextUrl.searchParams.get("projectId"); + const projectIds = await resolveOrgScopedProjectIds( + access.org.id, + userId, + access.role, + projectIdParam, + ); + if (projectIds instanceof NextResponse) return projectIds; - const fromQuery = req.nextUrl.searchParams.get('from') ?? undefined - const toQuery = req.nextUrl.searchParams.get('to') ?? undefined - const { from, to } = parseDateRange(fromQuery, toQuery) + const fromQuery = req.nextUrl.searchParams.get("from") ?? undefined; + const toQuery = req.nextUrl.searchParams.get("to") ?? undefined; + const { from, to } = parseDateRange(fromQuery, toQuery); const { page, pageSize, skip, take } = parsePagination( - req.nextUrl.searchParams.get('page'), - req.nextUrl.searchParams.get('pageSize'), - ) + req.nextUrl.searchParams.get("page"), + req.nextUrl.searchParams.get("pageSize"), + ); - const sortBy: 'cost' | 'recent' = - req.nextUrl.searchParams.get('sort') === 'cost' ? 'cost' : 'recent' - const wantsCsv = req.nextUrl.searchParams.get('format') === 'csv' + const sortBy: "cost" | "recent" = + req.nextUrl.searchParams.get("sort") === "cost" ? "cost" : "recent"; + const wantsCsv = req.nextUrl.searchParams.get("format") === "csv"; - const filenameFrom = from.toISOString().slice(0, 10) - const filenameTo = to.toISOString().slice(0, 10) + const filenameFrom = from.toISOString().slice(0, 10); + const filenameTo = to.toISOString().slice(0, 10); const csvHeaders = { - 'Content-Type': 'text/csv; charset=utf-8', - 'Content-Disposition': `attachment; filename="sessions-${orgSlug}-${filenameFrom}-to-${filenameTo}.csv"`, - } + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": `attachment; filename="sessions-${orgSlug}-${filenameFrom}-to-${filenameTo}.csv"`, + }; // projectIds 가 비었으면 (org 에 project 가 하나도 없음) 즉시 빈 결과 반환 if (projectIds.length === 0) { if (wantsCsv) { - return new NextResponse(buildSessionsCsv([]), { headers: csvHeaders }) + return new NextResponse(buildSessionsCsv([]), { headers: csvHeaders }); } - const body: PaginatedResult = { items: [], total: 0, page, pageSize } - return NextResponse.json(body) + const body: PaginatedResult = { + items: [], + total: 0, + page, + pageSize, + }; + return NextResponse.json(body); } const where = { projectId: { in: projectIds }, startedAt: { gte: from, lte: to }, - } + }; if (wantsCsv) { - let csvSessions: SessionWithInclude[] + let csvSessions: SessionWithInclude[]; - if (sortBy === 'cost') { + if (sortBy === "cost") { const rankedRows = await db.$queryRaw>` SELECT cs.id FROM claude_sessions cs @@ -186,35 +212,37 @@ export async function GET( ORDER BY COALESCE(SUM(ur.estimated_cost_usd), 0) DESC, cs.started_at DESC, cs.id ASC - ` - const ids = rankedRows.map((r) => r.id) + `; + const ids = rankedRows.map((r) => r.id); if (ids.length === 0) { - csvSessions = [] + csvSessions = []; } else { const rows = await db.claudeSession.findMany({ where: { id: { in: ids } }, include: sessionInclude, - }) - const byId = new Map(rows.map((s) => [s.id, s])) + }); + const byId = new Map(rows.map((s) => [s.id, s])); csvSessions = ids .map((id) => byId.get(id)) - .filter((s): s is SessionWithInclude => !!s) + .filter((s): s is SessionWithInclude => !!s); } } else { csvSessions = await db.claudeSession.findMany({ where, include: sessionInclude, - orderBy: { startedAt: 'desc' }, - }) + orderBy: { startedAt: "desc" }, + }); } - return new NextResponse(buildSessionsCsv(csvSessions), { headers: csvHeaders }) + return new NextResponse(buildSessionsCsv(csvSessions), { + headers: csvHeaders, + }); } - let sessions: SessionWithInclude[] - let total: number + let sessions: SessionWithInclude[]; + let total: number; - if (sortBy === 'cost') { + if (sortBy === "cost") { // 1) SUM(estimated_cost_usd) 기준으로 해당 페이지의 session id만 먼저 뽑는다. // tie-breaker: started_at desc → id asc 로 페이지 경계를 안정화. const [rankedRows, countResult] = await Promise.all([ @@ -232,43 +260,43 @@ export async function GET( LIMIT ${take} OFFSET ${skip} `, db.claudeSession.count({ where }), - ]) - total = countResult + ]); + total = countResult; - const ids = rankedRows.map((r) => r.id) + const ids = rankedRows.map((r) => r.id); if (ids.length === 0) { - sessions = [] + sessions = []; } else { // 2) 해당 id들만 기존 include 구조로 하이드레이션 후 rank 순서 복원. const rows = await db.claudeSession.findMany({ where: { id: { in: ids } }, include: sessionInclude, - }) - const byId = new Map(rows.map((s) => [s.id, s])) + }); + const byId = new Map(rows.map((s) => [s.id, s])); sessions = ids .map((id) => byId.get(id)) - .filter((s): s is SessionWithInclude => !!s) + .filter((s): s is SessionWithInclude => !!s); } } else { const [rows, countResult] = await Promise.all([ db.claudeSession.findMany({ where, include: sessionInclude, - orderBy: { startedAt: 'desc' }, + orderBy: { startedAt: "desc" }, skip, take, }), db.claudeSession.count({ where }), - ]) - sessions = rows - total = countResult + ]); + sessions = rows; + total = countResult; } - const items: SessionItem[] = sessions.map(mapSessionItem) + const items: SessionItem[] = sessions.map(mapSessionItem); - const body: PaginatedResult = { items, total, page, pageSize } - return NextResponse.json(body) + const body: PaginatedResult = { items, total, page, pageSize }; + return NextResponse.json(body); } catch (err) { - return handleRouteError(err) + return handleRouteError(err); } }