From 5aee42d2f4068756d6eb617a37fdac98ead06982 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 08:14:42 +0000 Subject: [PATCH] Harden security, stability, and performance (quick wins) Security: - Enable Content Security Policy via Helmet (was disabled) - Hash refresh tokens (SHA-256) before storing in DB - Use distinct JWT dev secrets for access vs refresh tokens - Add rate limiting on PvP fight endpoint (2 req/10s) - Add escapeHtml utility for safe user content rendering Stability: - Wrap clan leave + ownership transfer in a single transaction Performance: - Reduce PvP opponent pool from 100 to 20 with Elo-proximity filter - Fall back to broader search when few nearby opponents exist Co-Authored-By: Claude Opus 4.6 (1M context) Claude-Session: https://claude.ai/code/session_01FVTWJfLx3L6nzMmJKPUJAf --- server/config.js | 2 +- server/index.js | 14 +++++++++++- server/routes/auth.js | 13 +++++++---- server/routes/clans.js | 47 ++++++++++++++++++++++----------------- server/routes/pvp.js | 37 +++++++++++++++++++++++++----- src/screens/components.js | 15 +++++++++++++ 6 files changed, 97 insertions(+), 31 deletions(-) diff --git a/server/config.js b/server/config.js index cbb809a..6ee70f7 100644 --- a/server/config.js +++ b/server/config.js @@ -4,7 +4,7 @@ export const PORT = process.env.PORT || 3000; export const NODE_ENV = process.env.NODE_ENV || 'development'; export const JWT_SECRET = process.env.JWT_SECRET || (NODE_ENV === 'production' ? (() => { throw new Error('JWT_SECRET env var is required in production'); })() : 'dev-jwt-secret-change-in-production'); -export const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || (NODE_ENV === 'production' ? (() => { throw new Error('JWT_REFRESH_SECRET env var is required in production'); })() : 'dev-jwt-secret-change-in-production'); +export const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || (NODE_ENV === 'production' ? (() => { throw new Error('JWT_REFRESH_SECRET env var is required in production'); })() : 'dev-jwt-refresh-secret-change-in-production'); export const JWT_ACCESS_EXPIRY = '15m'; export const JWT_REFRESH_EXPIRY = '30d'; diff --git a/server/index.js b/server/index.js index d9da8ac..018f05b 100644 --- a/server/index.js +++ b/server/index.js @@ -45,7 +45,19 @@ app.set('trust proxy', NODE_ENV === 'production' ? 1 : false); // Security headers app.use(helmet({ - contentSecurityPolicy: false, + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'", "https://js.stripe.com"], + styleSrc: ["'self'", "'unsafe-inline'"], + imgSrc: ["'self'", "data:", "blob:"], + connectSrc: ["'self'", "wss:", "ws:", "https://api.stripe.com"], + frameSrc: ["'self'", "https://js.stripe.com"], + fontSrc: ["'self'"], + objectSrc: ["'none'"], + baseUri: ["'self'"], + }, + }, hsts: NODE_ENV === 'production' ? { maxAge: 31536000, includeSubDomains: true } : false, })); diff --git a/server/routes/auth.js b/server/routes/auth.js index 32b8ee7..81a30b1 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -39,6 +39,11 @@ function generateRefreshToken(user) { ); } +/** SHA-256 hash a token before storing — the raw token is only sent to the client */ +function hashToken(token) { + return crypto.createHash('sha256').update(token).digest('hex'); +} + /** Store refresh token in DB and return both tokens as JSON */ async function issueTokens(user, res, statusCode = 200) { const accessToken = generateAccessToken(user); @@ -47,7 +52,7 @@ async function issueTokens(user, res, statusCode = 200) { const decoded = jwt.decode(refreshToken); await prisma.refreshToken.create({ data: { - token: refreshToken, + token: hashToken(refreshToken), userId: user.id, expiresAt: new Date(decoded.exp * 1000), } @@ -476,7 +481,7 @@ router.post('/refresh', async (req, res) => { // Check token exists in DB (not revoked) const stored = await prisma.refreshToken.findUnique({ - where: { token: refreshToken } + where: { token: hashToken(refreshToken) } }); if (!stored) { return res.status(401).json({ error: 'Token revoked' }); @@ -496,7 +501,7 @@ router.post('/refresh', async (req, res) => { prisma.refreshToken.delete({ where: { id: stored.id } }), prisma.refreshToken.create({ data: { - token: newRefreshToken, + token: hashToken(newRefreshToken), userId: user.id, expiresAt: new Date(decoded.exp * 1000), } @@ -520,7 +525,7 @@ router.post('/logout', requireAuth, async (req, res) => { try { if (refreshToken) { await prisma.refreshToken.deleteMany({ - where: { token: refreshToken, userId: req.user.userId } + where: { token: hashToken(refreshToken), userId: req.user.userId } }); } else { // Delete all refresh tokens for this user diff --git a/server/routes/clans.js b/server/routes/clans.js index aebfb2f..4eaf8ed 100644 --- a/server/routes/clans.js +++ b/server/routes/clans.js @@ -563,28 +563,35 @@ router.post('/:id/join', requireAuth, async (req, res) => { // POST /api/clans/leave — leave the current clan (owner transfers or disbands) router.post('/leave', requireAuth, async (req, res) => { try { - const membership = await getMembership(req.user.userId); - if (!membership) return res.status(400).json({ error: 'You are not in a clan' }); - - const clanId = membership.clanId; - await prisma.clanMember.delete({ where: { userId: req.user.userId } }); + // Check membership outside the transaction for an early 400 response. + const existing = await getMembership(req.user.userId); + if (!existing) return res.status(400).json({ error: 'You are not in a clan' }); - // If the owner left, transfer ownership to the next-oldest member, or disband if empty. - const clan = await prisma.clan.findUnique({ where: { id: clanId } }); - if (clan && clan.ownerId === req.user.userId) { - const next = await prisma.clanMember.findFirst({ - where: { clanId }, - orderBy: [{ role: 'asc' }, { joinedAt: 'asc' }], - }); - if (next) { - await prisma.$transaction([ - prisma.clan.update({ where: { id: clanId }, data: { ownerId: next.userId } }), - prisma.clanMember.update({ where: { id: next.id }, data: { role: 'owner' } }), - ]); - } else { - await prisma.clan.delete({ where: { id: clanId } }); + await prisma.$transaction(async (tx) => { + // Re-fetch membership inside the transaction for consistency. + const membership = await tx.clanMember.findUnique({ where: { userId: req.user.userId } }); + if (!membership) return; // already gone — nothing to do + + const clanId = membership.clanId; + const wasOwner = membership.role === 'owner'; + + await tx.clanMember.delete({ where: { userId: req.user.userId } }); + + // If the owner left, transfer ownership to the next-oldest member, or disband if empty. + if (wasOwner) { + const next = await tx.clanMember.findFirst({ + where: { clanId }, + orderBy: [{ role: 'asc' }, { joinedAt: 'asc' }], + }); + if (next) { + await tx.clan.update({ where: { id: clanId }, data: { ownerId: next.userId } }); + await tx.clanMember.update({ where: { id: next.id }, data: { role: 'owner' } }); + } else { + await tx.clan.delete({ where: { id: clanId } }); + } } - } + }); + res.json({ ok: true }); } catch (err) { console.error('Leave clan error:', err); diff --git a/server/routes/pvp.js b/server/routes/pvp.js index fcfc0c1..70ba565 100644 --- a/server/routes/pvp.js +++ b/server/routes/pvp.js @@ -2,6 +2,7 @@ // server-side with the shared deterministic combat engine (anti-cheat) and // replayed identically on the client. No live opponent, no real-time timers. import { Router } from 'express'; +import rateLimit from 'express-rate-limit'; import { requireAuth } from '../middleware/auth.js'; import prisma from '../lib/prisma.js'; import { computeStatsFromEquipment, playerPowerScore } from '../../shared/stats.js'; @@ -11,7 +12,16 @@ import { pickOpponent, attackerEloChange } from '../lib/pvp-match.js'; const router = Router(); -const CANDIDATE_POOL = 100; // recent players considered as opponents per fight +const fightLimiter = rateLimit({ + windowMs: 10 * 1000, // 10 seconds + max: 2, // max 2 fights per 10 seconds + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many fights, please wait a moment' }, +}); + +const CANDIDATE_POOL = 20; // players considered as opponents per fight +const RATING_RANGE = 300; // preferred pvpRating proximity for matchmaking const USER_SELECT = { id: true, username: true, profilePicture: true, pvpRating: true, @@ -85,7 +95,7 @@ export function mirrorBot(attacker) { } // POST /api/pvp/fight — resolve one async fight and (for real opponents) apply Elo. -router.post('/fight', requireAuth, async (req, res) => { +router.post('/fight', requireAuth, fightLimiter, async (req, res) => { try { const me = await prisma.user.findUnique({ where: { id: req.user.userId }, @@ -106,12 +116,29 @@ router.post('/fight', requireAuth, async (req, res) => { if (!target || !target.gameState) return res.status(400).json({ error: 'That player has no battle data yet' }); opponent = fighterFromUser(target); } else { - const others = await prisma.user.findMany({ - where: { id: { not: me.id }, gameState: { isNot: null } }, + // Prefer opponents whose Elo is close to the attacker's; fall back + // to a wider search when the narrow band returns too few candidates. + const myRating = me.pvpRating ?? 1000; + const ratingFilter = { + id: { not: me.id }, + gameState: { isNot: null }, + pvpRating: { gte: myRating - RATING_RANGE, lte: myRating + RATING_RANGE }, + }; + let others = await prisma.user.findMany({ + where: ratingFilter, select: USER_SELECT, - orderBy: { updatedAt: 'desc' }, + orderBy: { pvpRating: 'asc' }, take: CANDIDATE_POOL, }); + // Broaden if too few nearby — drop the rating constraint entirely. + if (others.length < 3) { + others = await prisma.user.findMany({ + where: { id: { not: me.id }, gameState: { isNot: null } }, + select: USER_SELECT, + orderBy: { pvpRating: 'asc' }, + take: CANDIDATE_POOL, + }); + } opponent = pickOpponent(others.map(fighterFromUser), attacker.power) || mirrorBot(attacker); } diff --git a/src/screens/components.js b/src/screens/components.js index 2ee1035..cd275a9 100644 --- a/src/screens/components.js +++ b/src/screens/components.js @@ -26,6 +26,21 @@ export function h(tag, props = {}, ...children) { return el; } +/** + * Escape user-generated strings for safe insertion into HTML. + * Use this when content must go through innerHTML; prefer textContent (or the + * h() helper's `text` prop) whenever possible — it escapes automatically. + */ +export function escapeHtml(str) { + if (typeof str !== 'string') return ''; + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + export function clear(node) { while (node.firstChild) node.removeChild(node.firstChild); return node;