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:
@@ -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");
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user