From bb8fbac8de4aeaa3cb6fc93e1540a6c0c2826545 Mon Sep 17 00:00:00 2001 From: Ammaar Alam Date: Fri, 5 Dec 2025 17:49:15 -0500 Subject: [PATCH 1/4] adding anticheat and hiding websocket emits to prevent scripting --- client/src/components/Typing.css | 22 +++ client/src/components/Typing.jsx | 57 +++++- client/src/context/AuthContext.jsx | 11 +- client/src/context/RaceContext.jsx | 94 ++++++++- client/src/context/SocketContext.jsx | 21 +- server/controllers/socket-handlers.js | 275 +++++++++++++++++++------- 6 files changed, 393 insertions(+), 87 deletions(-) diff --git a/client/src/components/Typing.css b/client/src/components/Typing.css index e3cd4282..cf777f38 100644 --- a/client/src/components/Typing.css +++ b/client/src/components/Typing.css @@ -304,6 +304,28 @@ font-size: 1rem; } +.anticheat-block { + margin: 1rem auto 0; + padding: 0.75rem 1rem; + max-width: 480px; + border-radius: 10px; + background: rgba(255, 80, 80, 0.18); + border: 1px solid rgba(255, 80, 80, 0.35); + color: #ff7b7b; + text-align: center; + font-size: 0.9rem; + line-height: 1.4; +} + +.anticheat-block strong { + display: block; + font-weight: 700; + color: #ff9c9c; + margin-bottom: 0.25rem; + text-transform: uppercase; + letter-spacing: 0.06em; +} + /* Caps Lock Warning styles */ /* Caps Lock warning — banner above snippet */ .caps-lock-warning { diff --git a/client/src/components/Typing.jsx b/client/src/components/Typing.jsx index 6a56fd99..c97c5ffb 100644 --- a/client/src/components/Typing.jsx +++ b/client/src/components/Typing.jsx @@ -42,7 +42,7 @@ function Typing({ snippetType, snippetDepartment }) { - const { raceState, setRaceState, typingState, setTypingState, updateProgress, handleInput: raceHandleInput, loadNewSnippet } = useRace(); + const { raceState, setRaceState, typingState, setTypingState, updateProgress, handleInput: raceHandleInput, loadNewSnippet, anticheatState, flagSuspicious, markTrustedInteraction } = useRace(); const { socket } = useSocket(); const { user } = useAuth(); const [input, setInput] = useState(''); @@ -625,8 +625,46 @@ function Typing({ }; }, [raceState.inProgress, raceState.startTime, raceState.completed, typingState.correctChars]); // Include typingState.correctChars + const handleBeforeInputGuard = (e) => { + if (anticheatState.locked) { + e.preventDefault(); + return; + } + if (e.nativeEvent && e.nativeEvent.isTrusted === false) { + e.preventDefault(); + flagSuspicious('synthetic-beforeinput', { inputType: e.nativeEvent.inputType }); + return; + } + markTrustedInteraction(); + }; + + const handleKeyDownGuard = (e) => { + if (anticheatState.locked) { + e.preventDefault(); + return; + } + const nativeEvent = e.nativeEvent || e; + if (nativeEvent && nativeEvent.isTrusted === false) { + e.preventDefault(); + flagSuspicious('synthetic-keydown', { key: e.key }); + return; + } + markTrustedInteraction(); + }; + // Handle typing input with word locking (snippet) or free-typing (timed) const handleComponentInput = (e) => { + if (anticheatState.locked) { + e.preventDefault(); + return; + } + const nativeEvent = e.nativeEvent; + if (nativeEvent && nativeEvent.isTrusted === false) { + e.preventDefault(); + flagSuspicious('synthetic-input-change', { length: e.target?.value?.length ?? 0 }); + return; + } + markTrustedInteraction(); const newInput = e.target.value; const text = raceState.snippet?.text || ''; const isTimedMode = !!(raceState.snippet?.is_timed_test); @@ -738,6 +776,12 @@ function Typing({ setInput(typingState.input); } }, [typingState.input, raceState.inProgress, raceState.snippet?.is_timed_test]); + + useEffect(() => { + if (anticheatState.locked && inputRef.current) { + inputRef.current.blur(); + } + }, [anticheatState.locked]); // Prevent paste const handlePaste = (e) => { @@ -1163,11 +1207,15 @@ function Typing({ )} + {anticheatState.locked && ( +
+ automated input blocked + {anticheatState.message || 'suspicious typing blocked to protect races'} +
+ )} + {renderPracticeTooltip()} ); diff --git a/client/src/context/AuthContext.jsx b/client/src/context/AuthContext.jsx index 706d83a1..14dc1973 100644 --- a/client/src/context/AuthContext.jsx +++ b/client/src/context/AuthContext.jsx @@ -90,7 +90,7 @@ export const AuthProvider = ({ children }) => { // Monitor socket connection status to update user data on reconnect useEffect(() => { let isInitialConnection = true; - const handleSocketConnect = () => { + const handleSocketReconnect = () => { if (authenticated && !isInitialConnection) { console.log('Socket reconnected, refreshing user profile data'); fetchUserProfile(); @@ -98,14 +98,9 @@ export const AuthProvider = ({ children }) => { isInitialConnection = false; }; - if (window.socket) { - window.socket.on('connect', handleSocketConnect); - } - + window.addEventListener('tigertype:connect', handleSocketReconnect); return () => { - if (window.socket) { - window.socket.off('connect', handleSocketConnect); - } + window.removeEventListener('tigertype:connect', handleSocketReconnect); }; }, [authenticated, fetchUserProfile]); // Depend on auth status and fetch function diff --git a/client/src/context/RaceContext.jsx b/client/src/context/RaceContext.jsx index e0cd2377..b4192191 100644 --- a/client/src/context/RaceContext.jsx +++ b/client/src/context/RaceContext.jsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; +import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react'; import { useSocket } from './SocketContext'; import { useAuth } from './AuthContext'; @@ -54,6 +54,12 @@ const loadRaceState = () => { } }; +const INITIAL_ANTICHEAT_STATE = Object.freeze({ + locked: false, + reasons: [], + message: null +}); + export const RaceProvider = ({ children }) => { const { socket, connected } = useSocket(); const { user } = useAuth(); @@ -134,6 +140,49 @@ export const RaceProvider = ({ children }) => { accuracy: 0, lockedPosition: 0 // Pos up to which text is locked }); + const [anticheatState, setAnticheatState] = useState(INITIAL_ANTICHEAT_STATE); + const lastTrustedInteractionRef = useRef(Date.now()); + + const markTrustedInteraction = useCallback(() => { + lastTrustedInteractionRef.current = Date.now(); + }, []); + + const resetAnticheatState = useCallback(() => { + setAnticheatState(() => ({ + locked: false, + reasons: [], + message: null + })); + lastTrustedInteractionRef.current = Date.now(); + }, []); + + const flagSuspicious = useCallback((reason, metadata = {}) => { + if (!reason) return; + let shouldNotifyServer = false; + + setAnticheatState(prev => { + const alreadyReported = prev.reasons.some(entry => entry.reason === reason); + if (!alreadyReported) { + shouldNotifyServer = true; + } + return { + locked: true, + reasons: alreadyReported + ? prev.reasons + : [...prev.reasons, { reason, metadata, at: Date.now(), source: 'client' }], + message: metadata?.message || prev.message || 'Suspicious automation detected' + }; + }); + + if (shouldNotifyServer && socket && connected) { + socket.emit('anticheat:flag', { + reason, + metadata, + code: raceState.code, + lobbyId: raceState.lobbyId + }); + } + }, [socket, connected, raceState.code, raceState.lobbyId]); // Initialize inactivity state from session storage or default values const savedInactivityState = loadInactivityState(); @@ -228,6 +277,7 @@ export const RaceProvider = ({ children }) => { // Event handlers const handleRaceJoined = (data) => { // console.log('Joined race:', data); + resetAnticheatState(); setRaceState(prev => ({ ...prev, code: data.code, @@ -475,6 +525,24 @@ export const RaceProvider = ({ children }) => { }); }; + const handleAnticheatLock = (payload = {}) => { + const { reason = 'server_lock', details = {}, message } = payload; + setAnticheatState(prev => { + const alreadyLogged = prev.reasons.some(entry => entry.reason === reason && entry.source === 'server'); + return { + locked: true, + reasons: alreadyLogged + ? prev.reasons + : [...prev.reasons, { reason, metadata: details, at: Date.now(), source: 'server' }], + message: message || prev.message || 'Suspicious automation detected by server' + }; + }); + }; + + const handleAnticheatReset = () => { + resetAnticheatState(); + }; + // Register event listeners socket.on('race:joined', handleRaceJoined); socket.on('race:playersUpdate', handlePlayersUpdate); @@ -494,6 +562,8 @@ export const RaceProvider = ({ children }) => { socket.on('race:countdown', handleRaceCountdown); socket.on('lobby:newHost', handleNewHost); socket.on('race:playerLeft', handlePlayerLeft); + socket.on('anticheat:lock', handleAnticheatLock); + socket.on('anticheat:reset', handleAnticheatReset); // Clean up on unmount return () => { @@ -515,10 +585,12 @@ export const RaceProvider = ({ children }) => { socket.off('race:countdown', handleRaceCountdown); socket.off('lobby:newHost', handleNewHost); // Added cleanup socket.off('race:playerLeft', handlePlayerLeft); + socket.off('anticheat:lock', handleAnticheatLock); + socket.off('anticheat:reset', handleAnticheatReset); socket.off('snippetNotFound', handleSnippetNotFound); // Cleanup snippet not found listener }; // Add raceState.snippet?.id to dependency array to reset typing state on snippet change - }, [socket, connected, raceState.type, raceState.manuallyStarted, raceState.snippet?.id]); + }, [socket, connected, raceState.type, raceState.manuallyStarted, raceState.snippet?.id, resetAnticheatState]); // Methods for race actions const joinPracticeMode = () => { @@ -621,6 +693,9 @@ export const RaceProvider = ({ children }) => { // Handle text input, enforce word locking (snippet mode) or free-flow (timed mode) const handleInput = (incomingInput) => { + if (anticheatState.locked) { + return typingState.input; + } let newInput = incomingInput; // Disable input handling for non-practice races before countdown begins if (raceState.type !== 'practice' && !raceState.inProgress && raceState.countdown === null) { @@ -694,6 +769,9 @@ export const RaceProvider = ({ children }) => { }; const updateProgress = (input) => { + if (anticheatState.locked) { + return; + } const now = Date.now(); const elapsedSeconds = (now - raceState.startTime) / 1000; @@ -835,8 +913,11 @@ export const RaceProvider = ({ children }) => { code: raceState.code, position: progressPosition, total: text.length, - isCompleted, - hasError + isCompleted: isCompleted, // Send explicit completion status to server + correctChars, + errors: totalErrors, + accuracy, + wpm }); } @@ -903,6 +984,8 @@ export const RaceProvider = ({ children }) => { } } + resetAnticheatState(); + setRaceState({ code: null, type: null, @@ -1070,9 +1153,12 @@ export const RaceProvider = ({ children }) => { value={{ raceState, typingState, + anticheatState, inactivityState, setRaceState, setTypingState, + flagSuspicious, + markTrustedInteraction, setInactivityState, joinPracticeMode, joinPublicRace, diff --git a/client/src/context/SocketContext.jsx b/client/src/context/SocketContext.jsx index 3e026cc7..1df6259f 100644 --- a/client/src/context/SocketContext.jsx +++ b/client/src/context/SocketContext.jsx @@ -17,6 +17,14 @@ const socketOptions = { timeout: 20000 }; +const dispatchSocketEvent = (eventName, detail = {}) => { + try { + window.dispatchEvent(new CustomEvent(`tigertype:${eventName}`, { detail })); + } catch (err) { + console.error('Error dispatching socket event', eventName, err); + } +}; + export const SocketProvider = ({ children }) => { const { user, authenticated } = useAuth(); const raceContext = useContext(RaceContext); @@ -37,14 +45,12 @@ export const SocketProvider = ({ children }) => { // Initialize Socket.IO connection const socketInstance = io(socketOptions); - // Make socket accessible globally for components that need it directly - window.socket = socketInstance; - // Set up event listeners socketInstance.on('connect', () => { // console.log('Socket connected successfully with ID:', socketInstance.id); setConnected(true); setError(null); + dispatchSocketEvent('connect', { id: socketInstance.id }); // Keep window.user up-to-date w/ the latest user data from AuthContext if (user) { @@ -55,6 +61,7 @@ export const SocketProvider = ({ children }) => { socketInstance.on('connect_error', (err) => { console.error('Socket connection error:', err); setConnected(false); + dispatchSocketEvent('connect-error', { message: err?.message }); if (err && err.message && err.message.includes('Authentication')) { setError('Authentication required'); @@ -66,6 +73,7 @@ export const SocketProvider = ({ children }) => { socketInstance.on('disconnect', (reason) => { // console.log('Socket disconnected:', reason); setConnected(false); + dispatchSocketEvent('disconnect', { id: socketInstance.id, reason }); if (reason === 'io server disconnect') { // The server has disconnected us, attempt to reconnect @@ -86,6 +94,7 @@ export const SocketProvider = ({ children }) => { alert(reason); // Show alert popup setConnected(false); setError(reason); + dispatchSocketEvent('forced-disconnect', { id: socketInstance.id, reason }); // Reset race state if RaceContext is available if (raceContext && raceContext.resetRace) { @@ -106,10 +115,6 @@ export const SocketProvider = ({ children }) => { // Clean up on unmount return () => { - if (window.socket) { - window.socket = null; - } - socketInstance.off('connect'); socketInstance.off('disconnect'); socketInstance.off('connect_error'); @@ -154,4 +159,4 @@ export const useSocket = () => { return context; }; -export default SocketContext; \ No newline at end of file +export default SocketContext; diff --git a/server/controllers/socket-handlers.js b/server/controllers/socket-handlers.js index da7596dd..a5b0d9b3 100644 --- a/server/controllers/socket-handlers.js +++ b/server/controllers/socket-handlers.js @@ -29,6 +29,13 @@ const inactivityTimers = new Map(); // Store timers for inactivity warnings and // Store user avatar URLs for quicker lookup const playerAvatars = new Map(); // socketId -> avatar_url +// Anticheat thresholds and state +const suspiciousPlayers = new Map(); // socketId -> { reasons: [], locked: boolean } +const MAX_PROGRESS_STEP = 20; // max characters allowed per progress update +const MIN_PROGRESS_INTERVAL = 25; // min ms between progress packets +const MAX_ALLOWED_WPM = 320; // anything above is flagged +const MIN_COMPLETION_TIME_MS = 2500; // cannot finish faster than this + // Store host disconnect timers for private lobbies const HOST_RECONNECT_GRACE_PERIOD = 15000; // 15 seconds const hostDisconnectTimers = new Map(); // lobbyCode -> { timer: NodeJS.Timeout, userId: number } @@ -315,6 +322,46 @@ const initialize = (io) => { console.log(`Socket connected: ${netid} (${socket.id})`); + const registerSuspicion = (reason, details = {}) => { + if (!reason) return; + const existing = suspiciousPlayers.get(socket.id) || { reasons: [], locked: false }; + const already = existing.reasons.some(entry => entry.reason === reason); + if (!already) { + existing.reasons.push({ reason, details, at: Date.now() }); + } + existing.locked = true; + suspiciousPlayers.set(socket.id, existing); + + const progress = playerProgress.get(socket.id) || {}; + progress.suspicious = true; + progress.suspicionReasons = existing.reasons; + playerProgress.set(socket.id, progress); + + console.warn(`[ANTICHEAT] Locked socket ${socket.id} (${netid}) for ${reason}`, details); + socket.emit('anticheat:lock', { + reason, + details, + message: details?.message || 'Suspicious typing detected. Automation is not allowed.' + }); + }; + + const isSocketLocked = () => { + const entry = suspiciousPlayers.get(socket.id); + return entry?.locked; + }; + + socket.on('anticheat:flag', (payload = {}) => { + try { + const { reason, metadata } = payload || {}; + if (typeof reason !== 'string' || !reason) { + return; + } + registerSuspicion(`client-${reason}`, metadata || {}); + } catch (err) { + console.error('Error processing anticheat flag from client:', err); + } + }); + // Fetch user avatar when connecting if (userId) { fetchUserAvatar(userId, socket.id); @@ -1317,14 +1364,33 @@ const initialize = (io) => { }); // Handle progress updates - socket.on('race:progress', (data) => { + socket.on('race:progress', (data = {}) => { try { - // Client sends { position, total, isCompleted } - const { code, position, isCompleted, hasError = false } = data; + if (isSocketLocked()) { + return; + } + + const { + code, + position, + isCompleted, + accuracy, + errors, + correctChars, + wpm: clientReportedWpm + } = data; + + if (typeof code !== 'string') { + return; + } + + if (!Number.isFinite(position)) { + registerSuspicion('invalid-progress-payload', { position }); + return; + } - // Check if race exists and is active const race = activeRaces.get(code); - if (!race || race.status !== 'racing') { + if (!race || race.status !== 'racing' || !race.snippet || !race.snippet.text) { return; } @@ -1335,35 +1401,74 @@ const initialize = (io) => { } const playerIndex = players.findIndex(p => p.id === socket.id); - if (playerIndex === -1) { return; } - // Throttle progress updates const now = Date.now(); - const lastUpdate = lastProgressUpdate.get(socket.id) || 0; + const snippetLength = race.snippet.text.length; + const prevProgress = playerProgress.get(socket.id) || {}; + const prevPosition = prevProgress.position || 0; + const delta = position - prevPosition; - // Allow immediate update if player just completed the race - if (now - lastUpdate < PROGRESS_THROTTLE && !isCompleted) { + if (delta < 0) { + registerSuspicion('negative-progress', { prevPosition, position }); return; } - + + if (delta > MAX_PROGRESS_STEP && !isCompleted) { + registerSuspicion('progress-spike', { prevPosition, position, delta }); + return; + } + + const lastUpdateTs = lastProgressUpdate.get(socket.id) || 0; + const interval = now - lastUpdateTs; + + if (interval < MIN_PROGRESS_INTERVAL && !isCompleted) { + registerSuspicion('progress-interval', { interval }); + return; + } + + if (interval < PROGRESS_THROTTLE && !isCompleted) { + return; + } + lastProgressUpdate.set(socket.id, now); - - // Validate the progress (ensure position is not negative or excessively large) - const snippetLength = race.snippet.text.length; - if (position < 0 || position > snippetLength) { - console.warn(`Invalid position from ${netid}: ${position}, snippet length: ${snippetLength}`); + + const allowableOverflow = Math.max(10, Math.floor(snippetLength * 0.1)); + if (position < 0 || position > snippetLength + allowableOverflow) { + registerSuspicion('progress-out-of-range', { position, snippetLength }); return; } + + const raceStart = race.startTime || now; + const elapsedMs = now - raceStart; + if (elapsedMs > 0) { + const elapsedMinutes = elapsedMs / 60000; + const computedWpm = elapsedMinutes > 0 ? (position / 5) / elapsedMinutes : 0; + if (computedWpm > MAX_ALLOWED_WPM) { + registerSuspicion('wpm-threshold', { computedWpm, position, elapsedMs }); + return; + } + if (isCompleted && snippetLength > 40 && elapsedMs < MIN_COMPLETION_TIME_MS) { + registerSuspicion('completion-too-fast', { elapsedMs, snippetLength }); + return; + } + } + + const history = Array.isArray(prevProgress.history) ? prevProgress.history : []; + history.push({ position, timestamp: now }); + const trimmedHistory = history.slice(-180); - // Store player progress, using the client-provided completion status playerProgress.set(socket.id, { position, - completed: isCompleted, // Use the client-provided completion status - hasError: !!hasError, - timestamp: now + completed: isCompleted, + timestamp: now, + accuracy: Number.isFinite(accuracy) ? accuracy : prevProgress.accuracy, + errors: Number.isFinite(errors) ? errors : prevProgress.errors, + correctChars: Number.isFinite(correctChars) ? correctChars : prevProgress.correctChars, + wpm: Number.isFinite(clientReportedWpm) ? clientReportedWpm : prevProgress.wpm, + history: trimmedHistory }); // Calculate completion percentage @@ -1374,17 +1479,15 @@ const initialize = (io) => { netid, position, percentage, - completed: isCompleted, - hasError: !!hasError + completed: isCompleted }); // Handle race completion for this player if they just completed if (isCompleted) { console.log(`User ${netid} has completed the race in lobby ${code} based on progress update`); - // Ensure finish handler isn't called multiple times if progress updates arrive late const progressData = playerProgress.get(socket.id); if (progressData && !progressData.finishHandled) { - progressData.finishHandled = true; // Mark finish as handled + progressData.finishHandled = true; playerProgress.set(socket.id, progressData); handlePlayerFinish(io, code, socket.id, progressData).catch(err => { console.error('Error handling player finish:', err); @@ -1397,87 +1500,127 @@ const initialize = (io) => { }); // Handle race result submission - socket.on('race:result', async (data) => { + socket.on('race:result', async (data = {}) => { try { const { code, lobbyId, snippetId, wpm, accuracy, completion_time } = data; const { user: netid, userId } = socket.userInfo; - // --- BEGIN DEBUG LOGGING --- - // console.log(`[DEBUG race:result] Received data:`, JSON.stringify(data)); - // console.log(`[DEBUG race:result] User info: netid=${netid}, userId=${userId}`); - // --- END DEBUG LOGGING --- - if (!userId) { console.error(`[ERROR race:result] Cannot record result: No userId for socket ${socket.id} (netid: ${netid})`); return; } + if (isSocketLocked()) { + console.warn(`[ANTICHEAT] Result rejected for locked socket ${socket.id} (${netid})`); + return; + } + console.log(`Received result from ${netid}: WPM ${wpm}, Acc ${accuracy}, Time ${completion_time}`); - // Retrieve race and player info const players = racePlayers.get(code); const player = players?.find(p => p.id === socket.id); const race = activeRaces.get(code); - // Skip base stat updates for private lobbies const isPrivate = race?.type === 'private'; - - // --- BEGIN DEBUG LOGGING --- - // console.log(`[DEBUG race:result] Found player: ${!!player}, Found race: ${!!race}`); - if (race) { - // console.log(`[DEBUG race:result] Race snippet info: is_timed=${race.snippet?.is_timed_test}, duration=${race.snippet?.duration}`); - } - // --- END DEBUG LOGGING --- if (!player || !race) { console.warn(`[WARN race:result] Received result for race ${code}, but player ${netid} or race not found`); return; } - // Check if the result is for a timed test + const progressRecord = playerProgress.get(socket.id) || {}; + if (progressRecord.suspicious) { + registerSuspicion('suspicious-progress-result', { reasons: progressRecord.suspicionReasons }); + return; + } + + const finishTimestamp = progressRecord.timestamp || Date.now(); + const raceStart = race.startTime || finishTimestamp; + + let computedWpm = Number.isFinite(progressRecord.wpm) ? progressRecord.wpm : (Number.isFinite(wpm) ? Number(wpm) : 0); + let computedAccuracy = Number.isFinite(progressRecord.accuracy) ? progressRecord.accuracy : (Number.isFinite(accuracy) ? Number(accuracy) : 0); + let computedCompletion = Number.isFinite(completion_time) ? Number(completion_time) : null; + + if (!race.snippet?.is_timed_test) { + const chars = Number.isFinite(progressRecord.position) ? progressRecord.position : 0; + const elapsedMinutes = (finishTimestamp - raceStart) / 60000; + if (elapsedMinutes > 0 && chars >= 0) { + computedWpm = (chars / 5) / elapsedMinutes; + } + if (!Number.isFinite(computedCompletion)) { + computedCompletion = Math.max(0, (finishTimestamp - raceStart) / 1000); + } + } else { + if (!Number.isFinite(computedCompletion) && Number.isFinite(race.snippet?.duration)) { + computedCompletion = Number(race.snippet.duration); + } + if (!Number.isFinite(computedWpm) && Number.isFinite(wpm)) { + computedWpm = Number(wpm); + } + if (!Number.isFinite(computedAccuracy) && Number.isFinite(accuracy)) { + computedAccuracy = Number(accuracy); + } + } + + computedWpm = Number.isFinite(computedWpm) ? Math.max(0, Math.round(computedWpm * 100) / 100) : 0; + computedAccuracy = Number.isFinite(computedAccuracy) + ? Math.max(0, Math.min(100, Math.round(computedAccuracy * 100) / 100)) + : 0; + if (!Number.isFinite(computedCompletion)) { + computedCompletion = Math.max(0, (finishTimestamp - raceStart) / 1000); + } + + if (Number.isFinite(wpm) && Math.abs(Number(wpm) - computedWpm) > 25) { + registerSuspicion('wpm-mismatch', { reported: wpm, computed: computedWpm }); + return; + } + + if (Number.isFinite(accuracy) && Math.abs(Number(accuracy) - computedAccuracy) > 25) { + registerSuspicion('accuracy-mismatch', { reported: accuracy, computed: computedAccuracy }); + return; + } + + if (computedWpm > MAX_ALLOWED_WPM) { + registerSuspicion('wpm-threshold', { computedWpm }); + return; + } + + playerProgress.set(socket.id, { + ...progressRecord, + wpm: computedWpm, + accuracy: computedAccuracy, + completion_time: computedCompletion + }); + if (race.snippet?.is_timed_test && race.snippet?.duration) { const duration = race.snippet.duration; - // --- BEGIN DEBUG LOGGING --- - // console.log(`[DEBUG race:result] Processing as TIMED test. Duration: ${duration}`); - // console.log(`[DEBUG race:result] Calling insertTimedResult with: userId=${userId}, duration=${duration}, wpm=${wpm}, accuracy=${accuracy}`); - // --- END DEBUG LOGGING --- try { - await insertTimedResult(userId, duration, wpm, accuracy); + await insertTimedResult(userId, duration, computedWpm, computedAccuracy); console.log(`[SUCCESS race:result] Saved timed test result for ${netid} (duration: ${duration})`); } catch (dbError) { console.error(`[ERROR race:result] Failed to insert timed result for user ${userId}:`, dbError); - // Optionally emit an error back to client if needed } - // Update base stats only for non-private lobbies try { if (!isPrivate) { - await UserModel.updateStats(userId, wpm, accuracy, true); - await UserModel.updateFastestWpm(userId, wpm); - // console.log(`[DEBUG race:result] Updated user stats for ${netid}`); + await UserModel.updateStats(userId, computedWpm, computedAccuracy, true); + await UserModel.updateFastestWpm(userId, computedWpm); } } catch (statsError) { console.error(`[ERROR race:result] Failed to update user stats for ${userId} after timed result:`, statsError); } } else if (snippetId) { - // --- BEGIN DEBUG LOGGING --- - // console.log(`[DEBUG race:result] Processing as REGULAR race. Snippet ID: ${snippetId}`); - // console.log(`[DEBUG race:result] Calling RaceModel.recordResult with: userId=${userId}, lobbyId=${lobbyId}, snippetId=${snippetId}, wpm=${wpm}, accuracy=${accuracy}, completion_time=${completion_time}`); - // --- END DEBUG LOGGING --- - // Regular race result, save to race_results table try { - await RaceModel.recordResult(userId, lobbyId, snippetId, wpm, accuracy, completion_time); + await RaceModel.recordResult(userId, lobbyId, snippetId, computedWpm, computedAccuracy, computedCompletion); console.log(`[SUCCESS race:result] Saved regular race result for ${netid} (lobby: ${lobbyId}, snippet: ${snippetId})`); } catch (dbError) { console.error(`[ERROR race:result] Failed to insert regular race result for user ${userId}:`, dbError); } - // Update base stats only for non-private lobbies try { if (!isPrivate) { - await UserModel.updateStats(userId, wpm, accuracy, false); - await UserModel.updateFastestWpm(userId, wpm); - // console.log(`[DEBUG race:result] Updated user stats for ${netid}`); + await UserModel.updateStats(userId, computedWpm, computedAccuracy, false); + await UserModel.updateFastestWpm(userId, computedWpm); } } catch (statsError) { console.error(`[ERROR race:result] Failed to update user stats for ${userId} after regular result:`, statsError); @@ -1486,19 +1629,18 @@ const initialize = (io) => { console.warn(`[WARN race:result] Result from ${netid} for race ${code} has no snippetId and is not a timed test.`); } - // Handle player finish logic (updates progress, checks if race ends) - // Wrap in try/catch as well try { - await handlePlayerFinish(io, code, socket.id, { wpm, accuracy, completion_time }); - // console.log(`[DEBUG race:result] Successfully handled player finish logic for ${netid}`); + await handlePlayerFinish(io, code, socket.id, { + wpm: computedWpm, + accuracy: computedAccuracy, + completion_time: computedCompletion + }); } catch (finishError) { console.error(`[ERROR race:result] Error in handlePlayerFinish for ${netid}:`, finishError); } } catch (err) { console.error('[ERROR race:result] General error in handler:', err); - // Avoid emitting generic error to prevent potential info leak - // socket.emit('error', { message: 'Failed to save race result' }); } }); @@ -1725,6 +1867,7 @@ const initialize = (io) => { // Clean up any stored progress playerProgress.delete(socket.id); lastProgressUpdate.delete(socket.id); + suspiciousPlayers.delete(socket.id); // Clean up stored avatar playerAvatars.delete(socket.id); // Clean up any inactivity timers associated with this specific socket ID across all lobbies From 2f0bdefe80ffe1fb6839343115dc0b237dee5123 Mon Sep 17 00:00:00 2001 From: Ammaar Alam Date: Fri, 5 Dec 2025 18:06:59 -0500 Subject: [PATCH 2/4] restoring mistake indicators in live races --- server/controllers/socket-handlers.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/controllers/socket-handlers.js b/server/controllers/socket-handlers.js index a5b0d9b3..7f832561 100644 --- a/server/controllers/socket-handlers.js +++ b/server/controllers/socket-handlers.js @@ -1377,6 +1377,7 @@ const initialize = (io) => { accuracy, errors, correctChars, + hasError, wpm: clientReportedWpm } = data; @@ -1409,6 +1410,7 @@ const initialize = (io) => { const snippetLength = race.snippet.text.length; const prevProgress = playerProgress.get(socket.id) || {}; const prevPosition = prevProgress.position || 0; + const currentHasError = typeof hasError === 'boolean' ? hasError : prevProgress.hasError === true; const delta = position - prevPosition; if (delta < 0) { @@ -1467,6 +1469,7 @@ const initialize = (io) => { accuracy: Number.isFinite(accuracy) ? accuracy : prevProgress.accuracy, errors: Number.isFinite(errors) ? errors : prevProgress.errors, correctChars: Number.isFinite(correctChars) ? correctChars : prevProgress.correctChars, + hasError: currentHasError, wpm: Number.isFinite(clientReportedWpm) ? clientReportedWpm : prevProgress.wpm, history: trimmedHistory }); @@ -1479,7 +1482,8 @@ const initialize = (io) => { netid, position, percentage, - completed: isCompleted + completed: isCompleted, + hasError: currentHasError }); // Handle race completion for this player if they just completed From a83d398314002bf292427fd019653b9bfb8e1421 Mon Sep 17 00:00:00 2001 From: Ammaar Alam Date: Fri, 5 Dec 2025 18:18:23 -0500 Subject: [PATCH 3/4] ensuring if a locked client sends progress updates, server re-emits anticheat:lock event --- server/controllers/socket-handlers.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/server/controllers/socket-handlers.js b/server/controllers/socket-handlers.js index 7f832561..265fcb55 100644 --- a/server/controllers/socket-handlers.js +++ b/server/controllers/socket-handlers.js @@ -1367,6 +1367,15 @@ const initialize = (io) => { socket.on('race:progress', (data = {}) => { try { if (isSocketLocked()) { + const entry = suspiciousPlayers.get(socket.id); + if (entry && entry.reasons.length > 0) { + const lastReason = entry.reasons[entry.reasons.length - 1]; + socket.emit('anticheat:lock', { + reason: lastReason.reason, + details: lastReason.details, + message: lastReason.details?.message || 'Suspicious typing detected. Automation is not allowed.' + }); + } return; } From f2bd0450255f6463327d012afe99bbe7b14c5598 Mon Sep 17 00:00:00 2001 From: Ammaar Alam Date: Fri, 5 Dec 2025 18:24:39 -0500 Subject: [PATCH 4/4] enforcing max progress step check even when race completed is true --- server/controllers/socket-handlers.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/controllers/socket-handlers.js b/server/controllers/socket-handlers.js index 265fcb55..7ab906c3 100644 --- a/server/controllers/socket-handlers.js +++ b/server/controllers/socket-handlers.js @@ -1427,7 +1427,7 @@ const initialize = (io) => { return; } - if (delta > MAX_PROGRESS_STEP && !isCompleted) { + if (delta > MAX_PROGRESS_STEP) { registerSuspicion('progress-spike', { prevPosition, position, delta }); return; }