medienkompetenz-lernplattform/backend/src/controllers/participant.controller.js
Marius Rometsch a439873394 Add lessons
2026-02-08 19:47:21 +01:00

447 lines
14 KiB
JavaScript

const { ApiError } = require('../middleware/errorHandler');
const { generateSessionToken } = require('../middleware/auth');
const participantQueries = require('../models/queries/participant.queries');
const eventQueries = require('../models/queries/event.queries');
const commentQueries = require('../models/queries/comment.queries');
const db = require('../config/database');
/**
* XSS Detection Patterns (Easter Egg)
* Reused from lesson modules for consistency
*/
const XSS_PATTERNS = [
{ regex: /<script[\s\S]*?>/gi, type: 'SCRIPT_TAG' },
{ regex: /on\w+\s*=\s*["'][^"']*["']/gi, type: 'EVENT_HANDLER' },
{ regex: /on\w+\s*=\s*[^"\s>]+/gi, type: 'EVENT_HANDLER_UNQUOTED' },
{ regex: /javascript:/gi, type: 'JAVASCRIPT_PROTOCOL' },
{ regex: /<iframe/gi, type: 'IFRAME_TAG' },
{ regex: /<img[^>]+onerror/gi, type: 'IMG_ONERROR' },
{ regex: /<svg[^>]+onload/gi, type: 'SVG_ONLOAD' },
{ regex: /<object/gi, type: 'OBJECT_TAG' },
{ regex: /<embed/gi, type: 'EMBED_TAG' }
];
/**
* Detect XSS payload in content
*/
const detectXSS = (content) => {
for (const pattern of XSS_PATTERNS) {
if (pattern.regex.test(content)) {
return {
detected: true,
type: pattern.type
};
}
}
return { detected: false };
};
/**
* Join an event with a pseudonym
* POST /api/participant/join
*/
const joinEvent = async (req, res) => {
const { pseudonym, eventId } = req.body;
// Validate input
if (!pseudonym || !eventId) {
throw new ApiError(400, 'Pseudonym and eventId are required');
}
// Validate pseudonym format
if (pseudonym.length < 3 || pseudonym.length > 50) {
throw new ApiError(400, 'Pseudonym must be between 3 and 50 characters');
}
// Check if event exists and is active
const event = await eventQueries.getEventById(eventId);
if (!event) {
throw new ApiError(404, 'Event not found');
}
if (!event.is_active) {
throw new ApiError(403, 'This event is no longer accepting participants');
}
// Check if pseudonym is already taken in this event
const exists = await participantQueries.pseudonymExists(pseudonym, eventId);
if (exists) {
throw new ApiError(409, 'Pseudonym already taken in this event. Please choose another.');
}
// Generate session token
const sessionToken = generateSessionToken();
// Create participant
const participant = await participantQueries.createParticipant(
pseudonym,
eventId,
sessionToken
);
res.status(201).json({
success: true,
message: 'Successfully joined event',
data: {
participant: {
id: participant.id,
pseudonym: participant.pseudonym,
eventId: participant.event_id
},
sessionToken,
event: {
id: event.id,
name: event.name,
description: event.description
}
}
});
};
/**
* Get list of active events
* GET /api/participant/events
*/
const getActiveEvents = async (req, res) => {
const events = await eventQueries.getActiveEvents();
res.json({
success: true,
data: events
});
};
/**
* Get participant's own progress
* GET /api/participant/progress
*/
const getProgress = async (req, res) => {
const participantId = req.participant.id;
const progress = await participantQueries.getParticipantProgress(participantId);
if (!progress) {
throw new ApiError(404, 'Participant not found');
}
res.json({
success: true,
data: progress
});
};
/**
* Get participant profile
* GET /api/participant/profile
*/
const getProfile = async (req, res) => {
const participant = await participantQueries.getParticipantById(req.participant.id);
if (!participant) {
throw new ApiError(404, 'Participant not found');
}
res.json({
success: true,
data: {
id: participant.id,
pseudonym: participant.pseudonym,
eventId: participant.event_id,
eventName: participant.event_name,
createdAt: participant.created_at,
lastActive: participant.last_active
}
});
};
/**
* Add comment to event (VULNERABLE FOR EASTER EGG)
* POST /api/participant/event/:eventId/comment
*
* This endpoint intentionally contains a SQL injection vulnerability as part
* of an educational "Jackpot" Easter egg. Advanced participants who completed
* the SQL injection lesson can discover and exploit this vulnerability to
* earn a 400-point bonus.
*/
/**
* Add comment to event (VULNERABLE to XSS - Easter Egg)
* POST /api/participant/event/:eventId/comment
*/
const addEventComment = async (req, res) => {
const { eventId } = req.params;
const { content } = req.body;
const participantId = req.participant.id; // Server-controlled, not user input
// Basic validation
if (!content || typeof content !== 'string') {
throw new ApiError(400, 'Comment content is required');
}
if (content.length > 5000) {
throw new ApiError(400, 'Comment too long (max 5000 characters)');
}
// Get score before potential bonus
const progressBefore = await participantQueries.getParticipantProgress(participantId);
const scoreBefore = progressBefore.total_score || 0;
// EASTER EGG: Detect XSS payload
const xssDetection = detectXSS(content);
// Insert comment safely using parameterized query
const query = `
INSERT INTO event_comments (participant_id, event_id, content, created_at)
VALUES ($1, $2, $3, CURRENT_TIMESTAMP)
RETURNING id, participant_id, event_id, content, created_at
`;
const result = await db.query(query, [participantId, eventId, content]);
const comment = result.rows[0];
// Check if XSS was detected (Easter Egg!)
if (xssDetection.detected) {
console.log(`[XSS EASTER EGG] 🎯 Participant ${participantId} (${req.participant.pseudonym}) discovered XSS!`);
console.log(`[XSS EASTER EGG] Type: ${xssDetection.type}`);
console.log(`[XSS EASTER EGG] Payload: ${content.substring(0, 200)}${content.length > 200 ? '...' : ''}`);
// Check if first-time discovery
const hasDiscovered = await commentQueries.hasDiscoveredXSSJackpot(participantId, eventId);
if (!hasDiscovered) {
// Award 200 bonus points
await commentQueries.awardXSSJackpotBonus(participantId, eventId, 200);
// Log the discovery
await commentQueries.logXSSDiscovery(participantId, eventId, content, xssDetection.type, scoreBefore, scoreBefore);
// Get final score after bonus
const progressFinal = await participantQueries.getParticipantProgress(participantId);
console.log(`[XSS EASTER EGG] Awarded 200 bonus points! Score: ${scoreBefore}${progressFinal.total_score}`);
return res.status(201).json({
success: true,
message: 'Comment added',
data: {
comment,
totalScore: progressFinal.total_score,
jackpotDiscovered: true,
bonusPoints: 200,
xssType: xssDetection.type,
congratsMessage: '🎯 Stored XSS Discovered! You found a cross-site scripting vulnerability! +200 bonus points awarded.'
}
});
} else {
console.log(`[XSS EASTER EGG] Participant ${participantId} tried again (already discovered)`);
}
}
// Get updated progress
const progressAfter = await participantQueries.getParticipantProgress(participantId);
// Normal response (no jackpot discovered or already claimed)
res.status(201).json({
success: true,
message: 'Comment added',
data: {
comment,
totalScore: progressAfter.total_score,
jackpotDiscovered: false
}
});
};
/**
* Get participant's own comments for an event
* GET /api/participant/event/:eventId/comments
*/
const getEventComments = async (req, res) => {
const { eventId } = req.params;
const participantId = req.participant.id;
const comments = await commentQueries.getParticipantComments(participantId, eventId);
res.json({
success: true,
data: comments
});
};
/**
* Get event leaderboard (top participants by score)
* GET /api/participant/event/:eventId/leaderboard?filter=<search>
*
* DELIBERATELY VULNERABLE: SQL injection in filter parameter (Easter Egg #2)
* DELIBERATELY VERBOSE: Metadata leak for Easter egg discovery
*/
const getEventLeaderboard = async (req, res) => {
const { eventId } = req.params;
const { filter } = req.query; // User-controlled input
const participantId = req.participant.id;
// Get score before potential exploitation
const progressBefore = await participantQueries.getParticipantProgress(participantId);
const scoreBefore = progressBefore.total_score || 0;
let rankings = [];
let sqlInjectionAttempted = false;
try {
// VULNERABLE QUERY - uses string concatenation for filter
let query = `
SELECT
p.id,
p.pseudonym,
COUNT(DISTINCT lp.id) as total_lessons_started,
COUNT(DISTINCT CASE WHEN lp.status = 'completed' THEN lp.id END) as lessons_completed,
COALESCE(SUM(lp.score), 0) as total_score
FROM participants p
LEFT JOIN lesson_progress lp ON lp.participant_id = p.id
WHERE p.event_id = ${eventId}
`;
// VULNERABLE: Direct string concatenation
if (filter) {
query += ` AND p.pseudonym LIKE '%${filter}%'`;
}
query += `
GROUP BY p.id, p.pseudonym, p.created_at, p.last_active
ORDER BY total_score DESC, p.pseudonym ASC
LIMIT 10
`;
const result = await db.query(query);
rankings = result.rows;
} catch (error) {
console.error('[SQL FILTER] SQL error:', error.message);
// EASTER EGG: Check if SQL injection attempt with lesson_progress score UPDATE
if (filter && /UPDATE\s+lesson_progress.*score/i.test(filter)) {
sqlInjectionAttempted = true;
console.log('[SQL FILTER] 🎰 SQL Injection attempt detected!');
// Try to extract and simulate the injection
try {
let scoreUpdate = null;
// Pattern 1: score = score + N (addition)
const addMatch = filter.match(/score\s*=\s*score\s*\+\s*(\d+)/i);
if (addMatch) {
const pointsToAdd = parseInt(addMatch[1], 10);
console.log(`[SQL FILTER] Extracted addition: +${pointsToAdd}`);
await db.query(`
UPDATE lesson_progress
SET score = score + $1
WHERE participant_id = $2
`, [pointsToAdd, participantId]);
scoreUpdate = true;
}
// Pattern 2: score = N (direct assignment)
if (!scoreUpdate) {
const setMatch = filter.match(/score\s*=\s*(\d+)/i);
if (setMatch) {
const newScore = parseInt(setMatch[1], 10);
console.log(`[SQL FILTER] Extracted direct assignment: ${newScore}`);
await db.query(`
UPDATE lesson_progress
SET score = $1
WHERE participant_id = $2
`, [newScore, participantId]);
scoreUpdate = true;
}
}
if (scoreUpdate) {
console.log(`[SQL FILTER] ✅ Score updated via SQL injection!`);
} else {
console.log(`[SQL FILTER] SQL injection detected but couldn't extract score modification pattern`);
}
} catch (exploitError) {
console.error('[SQL FILTER] Exploitation simulation failed:', exploitError);
}
}
// Return empty rankings if query failed
rankings = [];
}
// Get updated progress (may have changed via SQL injection)
const progressAfter = await participantQueries.getParticipantProgress(participantId);
const scoreAfter = progressAfter.total_score || 0;
// Detect if jackpot was discovered
const isExploitation = sqlInjectionAttempted ||
(filter && /UPDATE\s+lesson_progress.*score/i.test(filter));
if (isExploitation) {
console.log(`[SQL FILTER] 🎰 Participant ${participantId} (${req.participant.pseudonym}) discovered SQL injection Easter egg!`);
console.log(`[SQL FILTER] Payload: ${filter.substring(0, 200)}${filter.length > 200 ? '...' : ''}`);
// Check if first-time discovery
const hasDiscovered = await commentQueries.hasDiscoveredJackpot(participantId, eventId);
if (!hasDiscovered) {
// Award 400 bonus points
await commentQueries.awardJackpotBonus(participantId, eventId, 400);
// Log the discovery
await commentQueries.logJackpotDiscovery(participantId, eventId, filter, scoreBefore, scoreAfter);
// Get final score after bonus
const progressFinal = await participantQueries.getParticipantProgress(participantId);
console.log(`[SQL FILTER] Awarded 400 bonus points! Score: ${scoreBefore}${progressFinal.total_score}`);
return res.json({
success: true,
data: {
rankings: rankings,
totalScore: progressFinal.total_score,
jackpotDiscovered: true,
bonusPoints: 400,
congratsMessage: '🎰 SQL Injection Jackpot! You discovered the hidden vulnerability in the leaderboard filter! +400 bonus points.',
_metadata: {
query_source: 'lesson_progress table aggregation with vulnerable filter',
score_column: 'lesson_progress.score',
aggregation_function: 'SUM(lesson_progress.score) AS total_score',
table_join: 'participants LEFT JOIN lesson_progress ON lesson_progress.participant_id = participants.id',
filter_applied: filter,
timestamp: new Date().toISOString()
}
}
});
} else {
console.log(`[SQL FILTER] Participant ${participantId} tried again (already discovered)`);
}
}
// Normal response (no jackpot discovered or already claimed)
res.json({
success: true,
data: {
rankings: rankings.slice(0, 10),
_metadata: {
query_source: 'lesson_progress table aggregation',
score_column: 'lesson_progress.score',
aggregation_function: 'SUM(lesson_progress.score) AS total_score',
table_join: 'participants LEFT JOIN lesson_progress ON lesson_progress.participant_id = participants.id',
filter_applied: filter || 'none',
timestamp: new Date().toISOString()
}
}
});
};
module.exports = {
joinEvent,
getActiveEvents,
getProgress,
getProfile,
addEventComment,
getEventComments,
getEventLeaderboard
};