-
Notifications
You must be signed in to change notification settings - Fork 0
Adding anti-cheat safeguards to block scripted race input #10
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
bb8fbac
2f0bdef
a83d398
f2bd045
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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; | ||||||
|
Comment on lines
+633
to
+636
|
||||||
| } | ||||||
| markTrustedInteraction(); | ||||||
| }; | ||||||
|
|
||||||
| const handleKeyDownGuard = (e) => { | ||||||
| if (anticheatState.locked) { | ||||||
| e.preventDefault(); | ||||||
| return; | ||||||
| } | ||||||
| const nativeEvent = e.nativeEvent || e; | ||||||
| if (nativeEvent && nativeEvent.isTrusted === false) { | ||||||
|
||||||
| if (nativeEvent && nativeEvent.isTrusted === false) { | |
| if (nativeEvent && nativeEvent.isTrusted !== true) { |
Copilot
AI
Dec 5, 2025
There was a problem hiding this comment.
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
AI
Dec 5, 2025
There was a problem hiding this comment.
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.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
|
||
|
|
||
| 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 | ||
|
|
||
|
|
||
| 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'; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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(); | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+144
to
+156
|
||||||||||||||||||||||||||||||||||||||||||
| 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
AI
Dec 5, 2025
There was a problem hiding this comment.
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.
| 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
AI
Dec 5, 2025
There was a problem hiding this comment.
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
AI
Dec 5, 2025
There was a problem hiding this comment.
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
AI
Dec 5, 2025
There was a problem hiding this comment.
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).
| const handleAnticheatReset = () => { | |
| resetAnticheatState(); | |
| }; |
Copilot
AI
Dec 5, 2025
There was a problem hiding this comment.
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.
| }, [socket, connected, raceState.type, raceState.manuallyStarted, raceState.snippet?.id, resetAnticheatState]); | |
| }, [socket, connected, raceState.type, raceState.manuallyStarted, raceState.snippet?.id]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unused variable updateProgress.