Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions client/src/components/Typing.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
57 changes: 56 additions & 1 deletion client/src/components/Typing.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused variable updateProgress.

Suggested change
const { raceState, setRaceState, typingState, setTypingState, updateProgress, handleInput: raceHandleInput, loadNewSnippet, anticheatState, flagSuspicious, markTrustedInteraction } = useRace();
const { raceState, setRaceState, typingState, setTypingState, handleInput: raceHandleInput, loadNewSnippet, anticheatState, flagSuspicious, markTrustedInteraction } = useRace();

Copilot uses AI. Check for mistakes.
const { socket } = useSocket();
const { user } = useAuth();
const [input, setInput] = useState('');
Expand Down Expand Up @@ -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;
Comment on lines +633 to +636
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The isTrusted property check uses strict inequality (=== false) which only catches explicitly false values. If isTrusted is undefined or missing (which could happen in older browsers or edge cases), the check will pass and allow potentially synthetic events. Consider using !== true instead to be more defensive, or add an explicit check for the property's existence.

Copilot uses AI. Check for mistakes.
}
markTrustedInteraction();
};

const handleKeyDownGuard = (e) => {
if (anticheatState.locked) {
e.preventDefault();
return;
}
const nativeEvent = e.nativeEvent || e;
if (nativeEvent && nativeEvent.isTrusted === false) {
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the onBeforeInput handler, the isTrusted check uses strict inequality (=== false) which only catches explicitly false values. If isTrusted is undefined or missing, the check will pass. Consider using !== true instead for more robust detection of synthetic events.

Suggested change
if (nativeEvent && nativeEvent.isTrusted === false) {
if (nativeEvent && nativeEvent.isTrusted !== true) {

Copilot uses AI. Check for mistakes.
e.preventDefault();
flagSuspicious('synthetic-keydown', { key: e.key });
return;
Comment on lines +646 to +650
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The isTrusted property check might not be reliable across all browsers or could be spoofed in certain scenarios. While this provides a good first line of defense against basic automation, consider logging or tracking patterns of suspicious activity (e.g., perfectly consistent timing between keystrokes) as an additional layer of protection beyond just checking isTrusted.

Copilot uses AI. Check for mistakes.
}
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;
Comment on lines +662 to +665
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue with the isTrusted check - using strict inequality (=== false) only catches explicitly false values. If isTrusted is undefined or missing, the check will pass. Consider using !== true for more robust detection of synthetic events.

Copilot uses AI. Check for mistakes.
}
markTrustedInteraction();
const newInput = e.target.value;
const text = raceState.snippet?.text || '';
const isTimedMode = !!(raceState.snippet?.is_timed_test);
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -1163,11 +1207,15 @@ function Typing({
<input
ref={inputRef}
value={input}
onBeforeInput={handleBeforeInputGuard}
onKeyDown={handleKeyDownGuard}
onChange={handleComponentInput}
onPaste={handlePaste}
onFocus={markTrustedInteraction}
className={isShaking ? 'shake' : ''}
disabled={
raceState.completed ||
anticheatState.locked ||
(raceState.type !== 'practice' && !raceState.inProgress && raceState.countdown === null)
}
autoComplete="off"
Expand All @@ -1179,6 +1227,13 @@ function Typing({
</div>
)}

{anticheatState.locked && (
<div className="anticheat-block" role="alert">
<strong>automated input blocked</strong>
<span>{anticheatState.message || 'suspicious typing blocked to protect races'}</span>
</div>
)}

{renderPracticeTooltip()}
</>
);
Expand Down
11 changes: 3 additions & 8 deletions client/src/context/AuthContext.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,22 +90,17 @@ 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();
}
isInitialConnection = false;
};
Comment on lines +93 to 99
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The event handler is renamed from handleSocketConnect to handleSocketReconnect, which better reflects its purpose (it skips the initial connection). However, the event being listened to is still tigertype:connect which fires on every connect including the initial one. This creates a subtle mismatch between the event name and handler name that could confuse future maintainers.

Consider either renaming the handler back to handleSocketConnect (since it handles all connects, not just reconnects), or adjusting the logic/comments to clarify the relationship.

Copilot uses AI. Check for mistakes.

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

Expand Down
94 changes: 90 additions & 4 deletions client/src/context/RaceContext.jsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Comment on lines +144 to +156
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lastTrustedInteractionRef is updated whenever a trusted interaction occurs, but it's never actually used for any validation logic. The ref is set in markTrustedInteraction and reset in resetAnticheatState, but there's no code that reads lastTrustedInteractionRef.current to validate time-based patterns or distinguish between legitimate bursts and automation.

If this ref isn't going to be used for validation, consider removing it to reduce complexity. Otherwise, implement the intended validation logic that checks the time since last trusted interaction.

Suggested change
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 resetAnticheatState = useCallback(() => {
setAnticheatState(() => ({
locked: false,
reasons: [],
message: null
}));

Copilot uses AI. Check for mistakes.
}, []);

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
Comment on lines +178 to +182
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The flagSuspicious function depends on raceState.code and raceState.lobbyId from the dependency array. However, when anti-cheat is triggered during a race transition or when these values are null/undefined, the server will receive incomplete context. Consider checking if these values exist before emitting the event, or default to empty/null values with a comment explaining the situation.

Suggested change
socket.emit('anticheat:flag', {
reason,
metadata,
code: raceState.code,
lobbyId: raceState.lobbyId
// During race transitions, raceState.code and raceState.lobbyId may be null/undefined.
// We default to null to ensure the server receives explicit context.
socket.emit('anticheat:flag', {
reason,
metadata,
code: raceState.code ?? null,
lobbyId: raceState.lobbyId ?? null

Copilot uses AI. Check for mistakes.
});
}
Comment on lines +159 to +184
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The flagSuspicious function uses a local variable shouldNotifyServer that's set inside the state updater but used outside it. Since setState is asynchronous and the updater can be called multiple times, there's a potential race condition where shouldNotifyServer might be based on stale state if multiple flags occur in quick succession.

Consider moving the server emission logic inside a useEffect that watches for changes to anticheatState.reasons, or restructure to ensure the server is notified atomically when a new reason is added.

Copilot uses AI. Check for mistakes.
}, [socket, connected, raceState.code, raceState.lobbyId]);
Comment on lines +159 to +185
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The flagSuspicious callback depends on raceState.code and raceState.lobbyId, but these are not stable references - they're properties of the raceState object which changes frequently. This could cause the callback to be recreated unnecessarily on every raceState update, potentially leading to stale closures or performance issues.

Consider restructuring to only depend on stable values, or use a ref to access the latest race state values inside the callback without including them in the dependency array.

Copilot uses AI. Check for mistakes.

// Initialize inactivity state from session storage or default values
const savedInactivityState = loadInactivityState();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
};

Comment on lines +542 to +545
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function handleAnticheatReset is defined but the corresponding anticheat:reset event is never emitted by the server in this PR. This creates dead code on the client side that will never be triggered. Either remove this handler and its listener registration, or implement the server-side logic to emit anticheat:reset when appropriate (e.g., when a new race starts for a previously locked player).

Suggested change
const handleAnticheatReset = () => {
resetAnticheatState();
};

Copilot uses AI. Check for mistakes.
// Register event listeners
socket.on('race:joined', handleRaceJoined);
socket.on('race:playersUpdate', handlePlayersUpdate);
Expand All @@ -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 () => {
Expand All @@ -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]);
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dependency array includes resetAnticheatState which is a callback defined with useCallback. This creates an unnecessary re-registration of all socket event listeners whenever resetAnticheatState changes (which should be stable). Since resetAnticheatState has no dependencies itself, this is safe to omit from the dependency array, or you can wrap the handlers that use it in their own useCallback hooks.

Suggested change
}, [socket, connected, raceState.type, raceState.manuallyStarted, raceState.snippet?.id, resetAnticheatState]);
}, [socket, connected, raceState.type, raceState.manuallyStarted, raceState.snippet?.id]);

Copilot uses AI. Check for mistakes.

// Methods for race actions
const joinPracticeMode = () => {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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
});
}

Expand Down Expand Up @@ -903,6 +984,8 @@ export const RaceProvider = ({ children }) => {
}
}

resetAnticheatState();

setRaceState({
code: null,
type: null,
Expand Down Expand Up @@ -1070,9 +1153,12 @@ export const RaceProvider = ({ children }) => {
value={{
raceState,
typingState,
anticheatState,
inactivityState,
setRaceState,
setTypingState,
flagSuspicious,
markTrustedInteraction,
setInactivityState,
joinPracticeMode,
joinPublicRace,
Expand Down
Loading