From b2c2527c4607a01e8c211ede76e492a538930786 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Gierwia=C5=82o?= Date: Thu, 13 Nov 2025 21:43:58 +0100 Subject: [PATCH] feat: add event slugs to prevent ID enumeration attacks Replace sequential event IDs in URLs with unique alphanumeric slugs to prevent enumeration attacks. Event URLs now use format /events/{slug}/chat instead of /events/{id}/chat. Backend changes: - Add slug field (VARCHAR 50, unique) to Event model - Create migration with auto-generated 12-char MD5-based slugs for existing events - Update GET /api/events/:slug endpoint (changed from :id) - Update GET /api/events/:slug/messages endpoint (changed from :eventId) - Modify Socket.IO join_event_room to accept slug parameter - Update send_event_message to use stored event context instead of passing eventId Frontend changes: - Update eventsAPI.getBySlug() method (changed from getById) - Update eventsAPI.getMessages() to use slug parameter - Change route from /events/:eventId/chat to /events/:slug/chat - Update EventsPage to navigate using event.slug - Update EventChatPage to fetch event data via slug and use slug in socket events Security impact: Prevents attackers from discovering all events by iterating sequential IDs. --- .../migration.sql | 11 ++++ backend/prisma/schema.prisma | 1 + backend/src/routes/events.js | 31 ++++++++--- backend/src/socket/index.js | 50 +++++++++++++---- frontend/src/App.jsx | 2 +- frontend/src/pages/EventChatPage.jsx | 55 +++++++++++++++---- frontend/src/pages/EventsPage.jsx | 6 +- frontend/src/services/api.js | 8 +-- 8 files changed, 127 insertions(+), 37 deletions(-) create mode 100644 backend/prisma/migrations/20251113202500_add_event_slug/migration.sql diff --git a/backend/prisma/migrations/20251113202500_add_event_slug/migration.sql b/backend/prisma/migrations/20251113202500_add_event_slug/migration.sql new file mode 100644 index 0000000..ea64f83 --- /dev/null +++ b/backend/prisma/migrations/20251113202500_add_event_slug/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +ALTER TABLE "events" ADD COLUMN "slug" VARCHAR(50); + +-- Generate unique slugs for existing events +UPDATE "events" SET "slug" = lower( + substring(md5(random()::text || clock_timestamp()::text) from 1 for 12) +) WHERE "slug" IS NULL; + +-- Make slug NOT NULL and add unique constraint +ALTER TABLE "events" ALTER COLUMN "slug" SET NOT NULL; +CREATE UNIQUE INDEX "events_slug_key" ON "events"("slug"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index d010229..f9c09df 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -61,6 +61,7 @@ model User { // Events table (dance events from worldsdc.com) model Event { id Int @id @default(autoincrement()) + slug String @unique @db.VarChar(50) name String @db.VarChar(255) location String @db.VarChar(255) startDate DateTime @map("start_date") @db.Date diff --git a/backend/src/routes/events.js b/backend/src/routes/events.js index 2be5077..4aaf7bf 100644 --- a/backend/src/routes/events.js +++ b/backend/src/routes/events.js @@ -13,6 +13,7 @@ router.get('/', authenticate, async (req, res, next) => { const events = await prisma.event.findMany({ select: { id: true, + slug: true, name: true, location: true, startDate: true, @@ -35,6 +36,7 @@ router.get('/', authenticate, async (req, res, next) => { // Transform data and add isJoined flag const eventsWithJoinedStatus = events.map(event => ({ id: event.id, + slug: event.slug, name: event.name, location: event.location, startDate: event.startDate, @@ -67,14 +69,14 @@ router.get('/', authenticate, async (req, res, next) => { } }); -// GET /api/events/:id - Get event by ID -router.get('/:id', async (req, res, next) => { +// GET /api/events/:slug - Get event by slug +router.get('/:slug', async (req, res, next) => { try { - const { id } = req.params; + const { slug } = req.params; const event = await prisma.event.findUnique({ where: { - id: parseInt(id), + slug: slug, }, include: { chatRooms: true, @@ -102,16 +104,29 @@ router.get('/:id', async (req, res, next) => { } }); -// GET /api/events/:eventId/messages - Get event chat messages with pagination -router.get('/:eventId/messages', authenticate, async (req, res, next) => { +// GET /api/events/:slug/messages - Get event chat messages with pagination +router.get('/:slug/messages', authenticate, async (req, res, next) => { try { - const { eventId } = req.params; + const { slug } = req.params; const { before, limit = 20 } = req.query; + // Find event by slug + const event = await prisma.event.findUnique({ + where: { slug }, + select: { id: true }, + }); + + if (!event) { + return res.status(404).json({ + success: false, + error: 'Event not found', + }); + } + // Find event chat room const chatRoom = await prisma.chatRoom.findFirst({ where: { - eventId: parseInt(eventId), + eventId: event.id, type: 'event', }, }); diff --git a/backend/src/socket/index.js b/backend/src/socket/index.js index ba87a6b..cef1966 100644 --- a/backend/src/socket/index.js +++ b/backend/src/socket/index.js @@ -54,25 +54,38 @@ function initializeSocket(httpServer) { console.log(`✅ User connected: ${socket.user.username} (${socket.id})`); // Join event room - socket.on('join_event_room', async ({ eventId }) => { + socket.on('join_event_room', async ({ slug }) => { try { + // Find event by slug + const event = await prisma.event.findUnique({ + where: { slug }, + select: { id: true, slug: true }, + }); + + if (!event) { + socket.emit('error', { message: 'Event not found' }); + return; + } + + const eventId = event.id; const roomName = `event_${eventId}`; socket.join(roomName); socket.currentEventRoom = roomName; socket.currentEventId = eventId; + socket.currentEventSlug = slug; // Record event participation in database await prisma.eventParticipant.upsert({ where: { userId_eventId: { userId: socket.user.id, - eventId: parseInt(eventId), + eventId: eventId, }, }, update: {}, // Don't update anything if already exists create: { userId: socket.user.id, - eventId: parseInt(eventId), + eventId: eventId, }, }); @@ -90,7 +103,7 @@ function initializeSocket(httpServer) { activeUsers.get(eventId).add(JSON.stringify(userInfo)); - console.log(`👤 ${socket.user.username} joined event room ${eventId}`); + console.log(`👤 ${socket.user.username} joined event room ${slug} (ID: ${eventId})`); // Load last 20 messages from database const chatRoom = await prisma.chatRoom.findFirst({ @@ -146,8 +159,13 @@ function initializeSocket(httpServer) { }); // Leave event room - socket.on('leave_event_room', ({ eventId }) => { - const roomName = `event_${eventId}`; + socket.on('leave_event_room', () => { + if (!socket.currentEventId || !socket.currentEventRoom) { + return; + } + + const eventId = socket.currentEventId; + const roomName = socket.currentEventRoom; socket.leave(roomName); // Remove from active users @@ -166,18 +184,28 @@ function initializeSocket(httpServer) { io.to(roomName).emit('active_users', updatedUsers); } - console.log(`👤 ${socket.user.username} left event room ${eventId}`); + console.log(`👤 ${socket.user.username} left event room ${socket.currentEventSlug}`); + + // Clear current event data + socket.currentEventId = null; + socket.currentEventRoom = null; + socket.currentEventSlug = null; }); // Send message to event room - socket.on('send_event_message', async ({ eventId, content }) => { + socket.on('send_event_message', async ({ content }) => { try { - const roomName = `event_${eventId}`; + if (!socket.currentEventId || !socket.currentEventRoom) { + return socket.emit('error', { message: 'Not in an event room' }); + } + + const eventId = socket.currentEventId; + const roomName = socket.currentEventRoom; // Save message to database const chatRoom = await prisma.chatRoom.findFirst({ where: { - eventId: parseInt(eventId), + eventId: eventId, type: 'event', }, }); @@ -216,7 +244,7 @@ function initializeSocket(httpServer) { createdAt: message.createdAt, }); - console.log(`💬 Message in event ${eventId} from ${socket.user.username}`); + console.log(`💬 Message in event ${socket.currentEventSlug} from ${socket.user.username}`); } catch (error) { console.error('Send message error:', error); socket.emit('error', { message: 'Failed to send message' }); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 89b7771..1472153 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -93,7 +93,7 @@ function App() { } /> diff --git a/frontend/src/pages/EventChatPage.jsx b/frontend/src/pages/EventChatPage.jsx index 07a37a8..0c96a38 100644 --- a/frontend/src/pages/EventChatPage.jsx +++ b/frontend/src/pages/EventChatPage.jsx @@ -2,15 +2,16 @@ import { useState, useRef, useEffect } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import Layout from '../components/layout/Layout'; import { useAuth } from '../contexts/AuthContext'; -import { mockEvents } from '../mocks/events'; import { Send, UserPlus, Loader2 } from 'lucide-react'; import { connectSocket, disconnectSocket, getSocket } from '../services/socket'; import { eventsAPI } from '../services/api'; const EventChatPage = () => { - const { eventId } = useParams(); + const { slug } = useParams(); const { user } = useAuth(); const navigate = useNavigate(); + const [event, setEvent] = useState(null); + const [loading, setLoading] = useState(true); const [messages, setMessages] = useState([]); const [newMessage, setNewMessage] = useState(''); const [activeUsers, setActiveUsers] = useState([]); @@ -20,17 +21,35 @@ const EventChatPage = () => { const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); - const event = mockEvents.find(e => e.id === parseInt(eventId)); - const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }; + // Fetch event data + useEffect(() => { + const fetchEvent = async () => { + try { + setLoading(true); + const data = await eventsAPI.getBySlug(slug); + setEvent(data); + } catch (err) { + console.error('Error loading event:', err); + setEvent(null); + } finally { + setLoading(false); + } + }; + + fetchEvent(); + }, [slug]); + useEffect(() => { scrollToBottom(); }, [messages]); useEffect(() => { + if (!event) return; + // Connect to Socket.IO const socket = connectSocket(); @@ -43,7 +62,7 @@ const EventChatPage = () => { socket.on('connect', () => { setIsConnected(true); // Join event room - socket.emit('join_event_room', { eventId: parseInt(eventId) }); + socket.emit('join_event_room', { slug }); }); socket.on('disconnect', () => { @@ -84,7 +103,7 @@ const EventChatPage = () => { // Cleanup return () => { - socket.emit('leave_event_room', { eventId: parseInt(eventId) }); + socket.emit('leave_event_room'); socket.off('connect'); socket.off('disconnect'); socket.off('message_history'); @@ -93,7 +112,7 @@ const EventChatPage = () => { socket.off('user_joined'); socket.off('user_left'); }; - }, [eventId, user.id]); + }, [event, slug, user.id]); const handleSendMessage = (e) => { e.preventDefault(); @@ -107,7 +126,6 @@ const EventChatPage = () => { // Send message via Socket.IO socket.emit('send_event_message', { - eventId: parseInt(eventId), content: newMessage, }); @@ -120,7 +138,7 @@ const EventChatPage = () => { setLoadingOlder(true); try { const oldestMessageId = messages[0].id; - const response = await eventsAPI.getMessages(eventId, oldestMessageId, 20); + const response = await eventsAPI.getMessages(slug, oldestMessageId, 20); if (response.data.length > 0) { // Save current scroll position @@ -170,10 +188,27 @@ const EventChatPage = () => { return () => container.removeEventListener('scroll', handleScroll); }, [loadingOlder, hasMore, messages]); + if (loading) { + return ( + +
+
+ +

Loading event...

+
+
+
+ ); + } + if (!event) { return ( -
Event not found
+
+
+ Event not found +
+
); } diff --git a/frontend/src/pages/EventsPage.jsx b/frontend/src/pages/EventsPage.jsx index ebfbc9c..3b10d11 100644 --- a/frontend/src/pages/EventsPage.jsx +++ b/frontend/src/pages/EventsPage.jsx @@ -27,8 +27,8 @@ const EventsPage = () => { fetchEvents(); }, []); - const handleJoinEvent = (eventId) => { - navigate(`/events/${eventId}/chat`); + const handleJoinEvent = (slug) => { + navigate(`/events/${slug}/chat`); }; if (loading) { @@ -102,7 +102,7 @@ const EventsPage = () => { )}