447 lines
14 KiB
JavaScript
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
|
|
};
|