Skip to content
Open
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
47 changes: 41 additions & 6 deletions backend/controllers/csvDownload.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,18 @@ function escapeIcsText(value = '') {
.replace(/;/g, '\\;');
}

// Complies with RFC 5545 (Max 75 characters per line)
// Folds lines at 75 characters, appending CRLF + Space for continuation
function foldIcsLine(line) {
if (line.length <= 75) return line;
return line.match(/.{1,74}/g).join('\r\n ');
}

function formatIcsDate(dateInput) {
const date = new Date(dateInput);

// Guard against Invalid Dates
if (isNaN(date.getTime())) return null;

const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
Expand All @@ -38,17 +48,19 @@ function formatIcsDate(dateInput) {

function buildCalendarIcs(tasks = []) {
const dtstamp = formatIcsDate(new Date().toISOString());

const events = tasks
.filter(task => task.due_at)
.filter(task => task.due_at && !isNaN(new Date(task.due_at).getTime())) // Strict date validation
.map(task => {
const start = new Date(task.due_at);
const end = new Date(start.getTime() + 60 * 60 * 1000);
const end = new Date(start.getTime() + 60 * 60 * 1000);

const description = [
`Subject: ${task.subject_name || 'General'}`,
`Status: ${task.status || 'Not Started'}`,
`Priority: ${task.priority || 'medium'}`,
`Notes: ${task.notes || 'None'}`,
].join('\n');
].join('\\n');

return [
'BEGIN:VEVENT',
Expand All @@ -59,7 +71,7 @@ function buildCalendarIcs(tasks = []) {
`SUMMARY:${escapeIcsText(task.title || 'Untitled task')}`,
`DESCRIPTION:${escapeIcsText(description)}`,
'END:VEVENT',
].join('\r\n');
].map(foldIcsLine).join('\r\n'); // Apply line folding to every event attribute
});

return [
Expand All @@ -74,6 +86,26 @@ function buildCalendarIcs(tasks = []) {
].join('\r\n');
}

// Prevents CSV Injection and formatting corruption
function escapeCsvValue(value) {
if (value === null || value === undefined) return '';
let str = String(value);

// 1. MITIGATE CSV INJECTION (Formula Injection)
// If string starts with a dangerous character (=, +, -, @, \t, \r), prepend a single quote
if (/^[=+\-@\t\r]/.test(str)) {
str = "'" + str;
}

// 2. PREVENT FORMATTING CORRUPTION
// If string contains a quote, comma, or newline, it MUST be wrapped in quotes
if (/[",\n\r]/.test(str)) {
return `"${str.replace(/"/g, '""')}"`; // Escape inner double-quotes
}

return str;
}

async function downloadData(req, res) {
try {
const data = await getAllTasksWithSubjects();
Expand All @@ -88,11 +120,14 @@ async function downloadData(req, res) {
task.status,
task.priority,
task.confidence_score,
`"${(task.notes || '').replace(/"/g, '""')}"`,
task.notes,
]),
];

const csvString = rows.map(row => row.join(',')).join('\n');
// Apply the secure escape function to every single cell before joining
const csvString = rows
.map(row => row.map(escapeCsvValue).join(','))
.join('\n');

res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', 'attachment; filename="study_data.csv"');
Expand Down