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.
This commit is contained in:
Radosław Gierwiało
2025-11-13 21:43:58 +01:00
parent 20f405cab3
commit b2c2527c46
8 changed files with 127 additions and 37 deletions

View File

@@ -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");

View File

@@ -61,6 +61,7 @@ model User {
// Events table (dance events from worldsdc.com) // Events table (dance events from worldsdc.com)
model Event { model Event {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
slug String @unique @db.VarChar(50)
name String @db.VarChar(255) name String @db.VarChar(255)
location String @db.VarChar(255) location String @db.VarChar(255)
startDate DateTime @map("start_date") @db.Date startDate DateTime @map("start_date") @db.Date

View File

@@ -13,6 +13,7 @@ router.get('/', authenticate, async (req, res, next) => {
const events = await prisma.event.findMany({ const events = await prisma.event.findMany({
select: { select: {
id: true, id: true,
slug: true,
name: true, name: true,
location: true, location: true,
startDate: true, startDate: true,
@@ -35,6 +36,7 @@ router.get('/', authenticate, async (req, res, next) => {
// Transform data and add isJoined flag // Transform data and add isJoined flag
const eventsWithJoinedStatus = events.map(event => ({ const eventsWithJoinedStatus = events.map(event => ({
id: event.id, id: event.id,
slug: event.slug,
name: event.name, name: event.name,
location: event.location, location: event.location,
startDate: event.startDate, startDate: event.startDate,
@@ -67,14 +69,14 @@ router.get('/', authenticate, async (req, res, next) => {
} }
}); });
// GET /api/events/:id - Get event by ID // GET /api/events/:slug - Get event by slug
router.get('/:id', async (req, res, next) => { router.get('/:slug', async (req, res, next) => {
try { try {
const { id } = req.params; const { slug } = req.params;
const event = await prisma.event.findUnique({ const event = await prisma.event.findUnique({
where: { where: {
id: parseInt(id), slug: slug,
}, },
include: { include: {
chatRooms: true, chatRooms: true,
@@ -102,16 +104,29 @@ router.get('/:id', async (req, res, next) => {
} }
}); });
// GET /api/events/:eventId/messages - Get event chat messages with pagination // GET /api/events/:slug/messages - Get event chat messages with pagination
router.get('/:eventId/messages', authenticate, async (req, res, next) => { router.get('/:slug/messages', authenticate, async (req, res, next) => {
try { try {
const { eventId } = req.params; const { slug } = req.params;
const { before, limit = 20 } = req.query; 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 // Find event chat room
const chatRoom = await prisma.chatRoom.findFirst({ const chatRoom = await prisma.chatRoom.findFirst({
where: { where: {
eventId: parseInt(eventId), eventId: event.id,
type: 'event', type: 'event',
}, },
}); });

View File

@@ -54,25 +54,38 @@ function initializeSocket(httpServer) {
console.log(`✅ User connected: ${socket.user.username} (${socket.id})`); console.log(`✅ User connected: ${socket.user.username} (${socket.id})`);
// Join event room // Join event room
socket.on('join_event_room', async ({ eventId }) => { socket.on('join_event_room', async ({ slug }) => {
try { 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}`; const roomName = `event_${eventId}`;
socket.join(roomName); socket.join(roomName);
socket.currentEventRoom = roomName; socket.currentEventRoom = roomName;
socket.currentEventId = eventId; socket.currentEventId = eventId;
socket.currentEventSlug = slug;
// Record event participation in database // Record event participation in database
await prisma.eventParticipant.upsert({ await prisma.eventParticipant.upsert({
where: { where: {
userId_eventId: { userId_eventId: {
userId: socket.user.id, userId: socket.user.id,
eventId: parseInt(eventId), eventId: eventId,
}, },
}, },
update: {}, // Don't update anything if already exists update: {}, // Don't update anything if already exists
create: { create: {
userId: socket.user.id, userId: socket.user.id,
eventId: parseInt(eventId), eventId: eventId,
}, },
}); });
@@ -90,7 +103,7 @@ function initializeSocket(httpServer) {
activeUsers.get(eventId).add(JSON.stringify(userInfo)); 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 // Load last 20 messages from database
const chatRoom = await prisma.chatRoom.findFirst({ const chatRoom = await prisma.chatRoom.findFirst({
@@ -146,8 +159,13 @@ function initializeSocket(httpServer) {
}); });
// Leave event room // Leave event room
socket.on('leave_event_room', ({ eventId }) => { socket.on('leave_event_room', () => {
const roomName = `event_${eventId}`; if (!socket.currentEventId || !socket.currentEventRoom) {
return;
}
const eventId = socket.currentEventId;
const roomName = socket.currentEventRoom;
socket.leave(roomName); socket.leave(roomName);
// Remove from active users // Remove from active users
@@ -166,18 +184,28 @@ function initializeSocket(httpServer) {
io.to(roomName).emit('active_users', updatedUsers); 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 // Send message to event room
socket.on('send_event_message', async ({ eventId, content }) => { socket.on('send_event_message', async ({ content }) => {
try { 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 // Save message to database
const chatRoom = await prisma.chatRoom.findFirst({ const chatRoom = await prisma.chatRoom.findFirst({
where: { where: {
eventId: parseInt(eventId), eventId: eventId,
type: 'event', type: 'event',
}, },
}); });
@@ -216,7 +244,7 @@ function initializeSocket(httpServer) {
createdAt: message.createdAt, 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) { } catch (error) {
console.error('Send message error:', error); console.error('Send message error:', error);
socket.emit('error', { message: 'Failed to send message' }); socket.emit('error', { message: 'Failed to send message' });

View File

@@ -93,7 +93,7 @@ function App() {
} }
/> />
<Route <Route
path="/events/:eventId/chat" path="/events/:slug/chat"
element={ element={
<ProtectedRoute> <ProtectedRoute>
<EventChatPage /> <EventChatPage />

View File

@@ -2,15 +2,16 @@ import { useState, useRef, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import Layout from '../components/layout/Layout'; import Layout from '../components/layout/Layout';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { mockEvents } from '../mocks/events';
import { Send, UserPlus, Loader2 } from 'lucide-react'; import { Send, UserPlus, Loader2 } from 'lucide-react';
import { connectSocket, disconnectSocket, getSocket } from '../services/socket'; import { connectSocket, disconnectSocket, getSocket } from '../services/socket';
import { eventsAPI } from '../services/api'; import { eventsAPI } from '../services/api';
const EventChatPage = () => { const EventChatPage = () => {
const { eventId } = useParams(); const { slug } = useParams();
const { user } = useAuth(); const { user } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [event, setEvent] = useState(null);
const [loading, setLoading] = useState(true);
const [messages, setMessages] = useState([]); const [messages, setMessages] = useState([]);
const [newMessage, setNewMessage] = useState(''); const [newMessage, setNewMessage] = useState('');
const [activeUsers, setActiveUsers] = useState([]); const [activeUsers, setActiveUsers] = useState([]);
@@ -20,17 +21,35 @@ const EventChatPage = () => {
const messagesEndRef = useRef(null); const messagesEndRef = useRef(null);
const messagesContainerRef = useRef(null); const messagesContainerRef = useRef(null);
const event = mockEvents.find(e => e.id === parseInt(eventId));
const scrollToBottom = () => { const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); 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(() => { useEffect(() => {
scrollToBottom(); scrollToBottom();
}, [messages]); }, [messages]);
useEffect(() => { useEffect(() => {
if (!event) return;
// Connect to Socket.IO // Connect to Socket.IO
const socket = connectSocket(); const socket = connectSocket();
@@ -43,7 +62,7 @@ const EventChatPage = () => {
socket.on('connect', () => { socket.on('connect', () => {
setIsConnected(true); setIsConnected(true);
// Join event room // Join event room
socket.emit('join_event_room', { eventId: parseInt(eventId) }); socket.emit('join_event_room', { slug });
}); });
socket.on('disconnect', () => { socket.on('disconnect', () => {
@@ -84,7 +103,7 @@ const EventChatPage = () => {
// Cleanup // Cleanup
return () => { return () => {
socket.emit('leave_event_room', { eventId: parseInt(eventId) }); socket.emit('leave_event_room');
socket.off('connect'); socket.off('connect');
socket.off('disconnect'); socket.off('disconnect');
socket.off('message_history'); socket.off('message_history');
@@ -93,7 +112,7 @@ const EventChatPage = () => {
socket.off('user_joined'); socket.off('user_joined');
socket.off('user_left'); socket.off('user_left');
}; };
}, [eventId, user.id]); }, [event, slug, user.id]);
const handleSendMessage = (e) => { const handleSendMessage = (e) => {
e.preventDefault(); e.preventDefault();
@@ -107,7 +126,6 @@ const EventChatPage = () => {
// Send message via Socket.IO // Send message via Socket.IO
socket.emit('send_event_message', { socket.emit('send_event_message', {
eventId: parseInt(eventId),
content: newMessage, content: newMessage,
}); });
@@ -120,7 +138,7 @@ const EventChatPage = () => {
setLoadingOlder(true); setLoadingOlder(true);
try { try {
const oldestMessageId = messages[0].id; 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) { if (response.data.length > 0) {
// Save current scroll position // Save current scroll position
@@ -170,10 +188,27 @@ const EventChatPage = () => {
return () => container.removeEventListener('scroll', handleScroll); return () => container.removeEventListener('scroll', handleScroll);
}, [loadingOlder, hasMore, messages]); }, [loadingOlder, hasMore, messages]);
if (loading) {
return (
<Layout>
<div className="max-w-4xl mx-auto flex items-center justify-center min-h-[400px]">
<div className="flex flex-col items-center gap-3">
<Loader2 className="w-12 h-12 animate-spin text-primary-600" />
<p className="text-gray-600">Loading event...</p>
</div>
</div>
</Layout>
);
}
if (!event) { if (!event) {
return ( return (
<Layout> <Layout>
<div className="text-center">Event not found</div> <div className="max-w-4xl mx-auto">
<div className="bg-red-50 border border-red-200 rounded-md p-4 text-red-700">
Event not found
</div>
</div>
</Layout> </Layout>
); );
} }

View File

@@ -27,8 +27,8 @@ const EventsPage = () => {
fetchEvents(); fetchEvents();
}, []); }, []);
const handleJoinEvent = (eventId) => { const handleJoinEvent = (slug) => {
navigate(`/events/${eventId}/chat`); navigate(`/events/${slug}/chat`);
}; };
if (loading) { if (loading) {
@@ -102,7 +102,7 @@ const EventsPage = () => {
)} )}
<button <button
onClick={() => handleJoinEvent(event.id)} onClick={() => handleJoinEvent(event.slug)}
className="w-full px-4 py-2 bg-primary-600 text-white rounded-md hover:bg-primary-700 transition-colors" className="w-full px-4 py-2 bg-primary-600 text-white rounded-md hover:bg-primary-700 transition-colors"
> >
{event.isJoined ? 'Open chat' : 'Join chat'} {event.isJoined ? 'Open chat' : 'Join chat'}

View File

@@ -186,17 +186,17 @@ export const eventsAPI = {
return data.data; return data.data;
}, },
async getById(id) { async getBySlug(slug) {
const data = await fetchAPI(`/events/${id}`); const data = await fetchAPI(`/events/${slug}`);
return data.data; return data.data;
}, },
async getMessages(eventId, before = null, limit = 20) { async getMessages(slug, before = null, limit = 20) {
const params = new URLSearchParams({ limit: limit.toString() }); const params = new URLSearchParams({ limit: limit.toString() });
if (before) { if (before) {
params.append('before', before.toString()); params.append('before', before.toString());
} }
const data = await fetchAPI(`/events/${eventId}/messages?${params}`); const data = await fetchAPI(`/events/${slug}/messages?${params}`);
return data; return data;
}, },
}; };