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]
**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.
240 changes: 134 additions & 106 deletions packages/web/src/app/api/orgs/[orgSlug]/dashboard/sessions/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Comment on lines +1 to +7

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

πŸ” Formatting churn obscures CSV change

The route-wide quote and semicolon rewrite hides the small CSV change in a 240-line diff. Keeping the existing formatting makes the change easier to review and merge.

Devin Review


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

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 } },
Expand All @@ -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,
Expand All @@ -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;
Comment on lines +82 to +88

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

πŸ” No CSV regression test added

The new CSV handling has no accompanying test. A route-level export test could cover leading formula characters alongside commas, quotes, and newlines.

Devin Review


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

}

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<SessionItem> = { items: [], total: 0, page, pageSize }
return NextResponse.json(body)
const body: PaginatedResult<SessionItem> = {
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<Array<{ id: string }>>`
SELECT cs.id
FROM claude_sessions cs
Expand All @@ -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([
Expand 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<SessionItem> = { items, total, page, pageSize }
return NextResponse.json(body)
const body: PaginatedResult<SessionItem> = { items, total, page, pageSize };
return NextResponse.json(body);
} catch (err) {
return handleRouteError(err)
return handleRouteError(err);
}
}
Loading