feat: Security
This commit is contained in:
@@ -35,6 +35,11 @@ import {
|
||||
canModerateServerMember,
|
||||
resolveServerPermission
|
||||
} from '../services/server-permissions.service';
|
||||
import {
|
||||
getAuthenticatedUserId,
|
||||
requireAuth,
|
||||
rejectSpoofedUserId
|
||||
} from '../middleware/require-auth';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -185,7 +190,7 @@ router.get('/trending', async (req, res) => {
|
||||
res.json({ servers: enrichedResults, total: enrichedResults.length, limit });
|
||||
});
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
router.post('/', requireAuth, async (req, res) => {
|
||||
const {
|
||||
id: clientId,
|
||||
name,
|
||||
@@ -204,12 +209,17 @@ router.post('/', async (req, res) => {
|
||||
if (!name || !ownerId || !ownerPublicKey)
|
||||
return res.status(400).json({ error: 'Missing required fields' });
|
||||
|
||||
if (!rejectSpoofedUserId(req, res, ownerId, 'ownerId')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const authenticatedOwnerId = getAuthenticatedUserId(req);
|
||||
const passwordHash = passwordHashForInput(password);
|
||||
const server: ServerPayload = {
|
||||
id: clientId || uuidv4(),
|
||||
name,
|
||||
description,
|
||||
ownerId,
|
||||
ownerId: authenticatedOwnerId,
|
||||
ownerPublicKey,
|
||||
hasPassword: !!passwordHash,
|
||||
passwordHash,
|
||||
@@ -225,12 +235,12 @@ router.post('/', async (req, res) => {
|
||||
};
|
||||
|
||||
await upsertServer(server);
|
||||
await ensureServerMembership(server.id, ownerId);
|
||||
await ensureServerMembership(server.id, authenticatedOwnerId);
|
||||
|
||||
res.status(201).json(await enrichServer(server, getRequestOrigin(req)));
|
||||
});
|
||||
|
||||
router.put('/:id', async (req, res) => {
|
||||
router.put('/:id', requireAuth, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const {
|
||||
currentOwnerId,
|
||||
@@ -242,13 +252,16 @@ router.put('/:id', async (req, res) => {
|
||||
...updates
|
||||
} = req.body;
|
||||
const existing = await getServerById(id);
|
||||
const authenticatedOwnerId = currentOwnerId ?? req.body.ownerId;
|
||||
const authenticatedOwnerId = getAuthenticatedUserId(req);
|
||||
|
||||
if (!existing)
|
||||
return res.status(404).json({ error: 'Server not found' });
|
||||
|
||||
if (!authenticatedOwnerId) {
|
||||
return res.status(400).json({ error: 'Missing currentOwnerId' });
|
||||
if (currentOwnerId && currentOwnerId !== authenticatedOwnerId) {
|
||||
return res.status(400).json({
|
||||
error: 'currentOwnerId must match the authenticated user',
|
||||
errorCode: 'USER_ID_MISMATCH'
|
||||
});
|
||||
}
|
||||
|
||||
if (!canManageServerUpdate(existing, authenticatedOwnerId, {
|
||||
@@ -276,18 +289,22 @@ router.put('/:id', async (req, res) => {
|
||||
res.json(await enrichServer(server, getRequestOrigin(req)));
|
||||
});
|
||||
|
||||
router.post('/:id/join', async (req, res) => {
|
||||
router.post('/:id/join', requireAuth, async (req, res) => {
|
||||
const { id: serverId } = req.params;
|
||||
const { userId, password, inviteId } = req.body;
|
||||
const authenticatedUserId = getAuthenticatedUserId(req);
|
||||
|
||||
if (!userId) {
|
||||
return res.status(400).json({ error: 'Missing userId', errorCode: 'MISSING_USER' });
|
||||
if (userId && userId !== authenticatedUserId) {
|
||||
return res.status(400).json({
|
||||
error: 'userId must match the authenticated user',
|
||||
errorCode: 'USER_ID_MISMATCH'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await joinServerWithAccess({
|
||||
serverId,
|
||||
userId: String(userId),
|
||||
userId: authenticatedUserId,
|
||||
password: typeof password === 'string' ? password : undefined,
|
||||
inviteId: typeof inviteId === 'string' ? inviteId : undefined
|
||||
});
|
||||
@@ -305,12 +322,16 @@ router.post('/:id/join', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/invites', async (req, res) => {
|
||||
router.post('/:id/invites', requireAuth, async (req, res) => {
|
||||
const { id: serverId } = req.params;
|
||||
const { requesterUserId, requesterDisplayName } = req.body;
|
||||
const authenticatedUserId = getAuthenticatedUserId(req);
|
||||
|
||||
if (!requesterUserId) {
|
||||
return res.status(400).json({ error: 'Missing requesterUserId', errorCode: 'MISSING_USER' });
|
||||
if (requesterUserId && requesterUserId !== authenticatedUserId) {
|
||||
return res.status(400).json({
|
||||
error: 'requesterUserId must match the authenticated user',
|
||||
errorCode: 'USER_ID_MISMATCH'
|
||||
});
|
||||
}
|
||||
|
||||
const server = await getServerById(serverId);
|
||||
@@ -322,7 +343,7 @@ router.post('/:id/invites', async (req, res) => {
|
||||
try {
|
||||
const invite = await createServerInvite(
|
||||
serverId,
|
||||
String(requesterUserId),
|
||||
authenticatedUserId,
|
||||
typeof requesterDisplayName === 'string' ? requesterDisplayName : undefined
|
||||
);
|
||||
|
||||
@@ -332,9 +353,10 @@ router.post('/:id/invites', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/moderation/kick', async (req, res) => {
|
||||
router.post('/:id/moderation/kick', requireAuth, async (req, res) => {
|
||||
const { id: serverId } = req.params;
|
||||
const { actorUserId, targetUserId } = req.body;
|
||||
const authenticatedUserId = getAuthenticatedUserId(req);
|
||||
const server = await getServerById(serverId);
|
||||
|
||||
if (!server) {
|
||||
@@ -345,7 +367,14 @@ router.post('/:id/moderation/kick', async (req, res) => {
|
||||
return res.status(400).json({ error: 'Missing targetUserId', errorCode: 'MISSING_TARGET' });
|
||||
}
|
||||
|
||||
if (!canModerateServerMember(server, String(actorUserId || ''), String(targetUserId), 'kickMembers')) {
|
||||
if (actorUserId && actorUserId !== authenticatedUserId) {
|
||||
return res.status(400).json({
|
||||
error: 'actorUserId must match the authenticated user',
|
||||
errorCode: 'USER_ID_MISMATCH'
|
||||
});
|
||||
}
|
||||
|
||||
if (!canModerateServerMember(server, authenticatedUserId, String(targetUserId), 'kickMembers')) {
|
||||
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
||||
}
|
||||
|
||||
@@ -354,9 +383,10 @@ router.post('/:id/moderation/kick', async (req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post('/:id/moderation/ban', async (req, res) => {
|
||||
router.post('/:id/moderation/ban', requireAuth, async (req, res) => {
|
||||
const { id: serverId } = req.params;
|
||||
const { actorUserId, targetUserId, banId, displayName, reason, expiresAt } = req.body;
|
||||
const authenticatedUserId = getAuthenticatedUserId(req);
|
||||
const server = await getServerById(serverId);
|
||||
|
||||
if (!server) {
|
||||
@@ -367,7 +397,14 @@ router.post('/:id/moderation/ban', async (req, res) => {
|
||||
return res.status(400).json({ error: 'Missing targetUserId', errorCode: 'MISSING_TARGET' });
|
||||
}
|
||||
|
||||
if (!canModerateServerMember(server, String(actorUserId || ''), String(targetUserId), 'banMembers')) {
|
||||
if (actorUserId && actorUserId !== authenticatedUserId) {
|
||||
return res.status(400).json({
|
||||
error: 'actorUserId must match the authenticated user',
|
||||
errorCode: 'USER_ID_MISMATCH'
|
||||
});
|
||||
}
|
||||
|
||||
if (!canModerateServerMember(server, authenticatedUserId, String(targetUserId), 'banMembers')) {
|
||||
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
||||
}
|
||||
|
||||
@@ -375,7 +412,7 @@ router.post('/:id/moderation/ban', async (req, res) => {
|
||||
serverId,
|
||||
userId: String(targetUserId),
|
||||
banId: typeof banId === 'string' ? banId : undefined,
|
||||
bannedBy: String(actorUserId || ''),
|
||||
bannedBy: authenticatedUserId,
|
||||
displayName: typeof displayName === 'string' ? displayName : undefined,
|
||||
reason: typeof reason === 'string' ? reason : undefined,
|
||||
expiresAt: typeof expiresAt === 'number' ? expiresAt : undefined
|
||||
@@ -384,16 +421,24 @@ router.post('/:id/moderation/ban', async (req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post('/:id/moderation/unban', async (req, res) => {
|
||||
router.post('/:id/moderation/unban', requireAuth, async (req, res) => {
|
||||
const { id: serverId } = req.params;
|
||||
const { actorUserId, banId, targetUserId } = req.body;
|
||||
const authenticatedUserId = getAuthenticatedUserId(req);
|
||||
const server = await getServerById(serverId);
|
||||
|
||||
if (!server) {
|
||||
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
|
||||
}
|
||||
|
||||
if (!resolveServerPermission(server, String(actorUserId || ''), 'manageBans')) {
|
||||
if (actorUserId && actorUserId !== authenticatedUserId) {
|
||||
return res.status(400).json({
|
||||
error: 'actorUserId must match the authenticated user',
|
||||
errorCode: 'USER_ID_MISMATCH'
|
||||
});
|
||||
}
|
||||
|
||||
if (!resolveServerPermission(server, authenticatedUserId, 'manageBans')) {
|
||||
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
|
||||
}
|
||||
|
||||
@@ -406,25 +451,29 @@ router.post('/:id/moderation/unban', async (req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post('/:id/leave', async (req, res) => {
|
||||
router.post('/:id/leave', requireAuth, async (req, res) => {
|
||||
const { id: serverId } = req.params;
|
||||
const { userId } = req.body;
|
||||
const authenticatedUserId = getAuthenticatedUserId(req);
|
||||
const server = await getServerById(serverId);
|
||||
|
||||
if (!server) {
|
||||
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return res.status(400).json({ error: 'Missing userId', errorCode: 'MISSING_USER' });
|
||||
if (userId && userId !== authenticatedUserId) {
|
||||
return res.status(400).json({
|
||||
error: 'userId must match the authenticated user',
|
||||
errorCode: 'USER_ID_MISMATCH'
|
||||
});
|
||||
}
|
||||
|
||||
await leaveServerUser(serverId, String(userId));
|
||||
await leaveServerUser(serverId, authenticatedUserId);
|
||||
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post('/:id/heartbeat', async (req, res) => {
|
||||
router.post('/:id/heartbeat', requireAuth, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { currentUsers } = req.body;
|
||||
const existing = await getServerById(id);
|
||||
@@ -442,30 +491,38 @@ router.post('/:id/heartbeat', async (req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.delete('/:id', async (req, res) => {
|
||||
router.delete('/:id', requireAuth, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { ownerId } = req.body;
|
||||
const authenticatedOwnerId = getAuthenticatedUserId(req);
|
||||
const existing = await getServerById(id);
|
||||
|
||||
if (!existing)
|
||||
return res.status(404).json({ error: 'Server not found' });
|
||||
|
||||
if (existing.ownerId !== ownerId)
|
||||
if (ownerId && ownerId !== authenticatedOwnerId) {
|
||||
return res.status(400).json({
|
||||
error: 'ownerId must match the authenticated user',
|
||||
errorCode: 'USER_ID_MISMATCH'
|
||||
});
|
||||
}
|
||||
|
||||
if (existing.ownerId !== authenticatedOwnerId)
|
||||
return res.status(403).json({ error: 'Not authorized' });
|
||||
|
||||
await deleteServer(id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.get('/:id/requests', async (req, res) => {
|
||||
router.get('/:id/requests', requireAuth, async (req, res) => {
|
||||
const { id: serverId } = req.params;
|
||||
const { ownerId } = req.query;
|
||||
const authenticatedUserId = getAuthenticatedUserId(req);
|
||||
const server = await getServerById(serverId);
|
||||
|
||||
if (!server)
|
||||
return res.status(404).json({ error: 'Server not found' });
|
||||
|
||||
if (server.ownerId !== ownerId)
|
||||
if (!resolveServerPermission(server, authenticatedUserId, 'manageServer'))
|
||||
return res.status(403).json({ error: 'Not authorized' });
|
||||
|
||||
const requests = await getPendingRequestsForServer(serverId);
|
||||
|
||||
Reference in New Issue
Block a user