feat: Security

This commit is contained in:
2026-06-05 18:34:01 +02:00
parent ee293d7daf
commit 45675192a5
134 changed files with 4128 additions and 446 deletions

View File

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