From b737d7eb96e269e434cf9d0109a546f5e718f81a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=E1=B4=80=C9=B4=E1=B4=8B=E1=B4=87=E1=B4=9B=20=F0=9F=96=A4?= Date: Mon, 14 Sep 2026 09:18:28 +0530 Subject: [PATCH] feat(security): add shared-wifi friendly dual-tier ip and device rate limiter --- security/ratelimit.js | 180 ++++++++++++++++++++++++++++++++++++ src/RateLimit.test.js | 33 +++++++ src/security/ratelimit.ts | 189 ++++++++++++++++++++++++++++++++++++++ src/voting/VotingPage.tsx | 8 ++ 4 files changed, 410 insertions(+) create mode 100644 security/ratelimit.js create mode 100644 src/RateLimit.test.js create mode 100644 src/security/ratelimit.ts diff --git a/security/ratelimit.js b/security/ratelimit.js new file mode 100644 index 0000000..6e0c15b --- /dev/null +++ b/security/ratelimit.js @@ -0,0 +1,180 @@ +export const MAX_VOTES_PER_DEVICE_PER_MINUTE = 1; +export const MAX_VOTES_PER_DEVICE_PER_HOUR = 3; + +export const MAX_BURST_PER_IP_PER_MINUTE = 20; +export const MAX_VOTES_PER_IP_PER_HOUR = 600; + +const IP_STORAGE_KEY = 'truevote_ip_rate_tracker'; +const DEVICE_STORAGE_KEY = 'truevote_device_rate_tracker'; + +let cachedIp = null; +let cachedIpTime = 0; + +export async function resolveClientIp() { + if (typeof window === 'undefined') return '127.0.0.1'; + if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'test') { + return '127.0.0.1'; + } + + const now = Date.now(); + if (cachedIp && (now - cachedIpTime) < 300000) { + return cachedIp; + } + + const stored = sessionStorage.getItem('truevote_resolved_ip'); + if (stored) { + cachedIp = stored; + cachedIpTime = now; + return stored; + } + + const controllers = [new AbortController(), new AbortController()]; + const timeouts = controllers.map((c) => setTimeout(() => c.abort(), 1800)); + + try { + const res = await Promise.race([ + fetch('https://api64.ipify.org?format=json', { signal: controllers[0].signal }) + .then((r) => (r.ok ? r.json() : Promise.reject(r.statusText))) + .then((d) => d.ip), + fetch('https://cloudflare.com/cdn-cgi/trace', { signal: controllers[1].signal }) + .then((r) => (r.ok ? r.text() : Promise.reject(r.statusText))) + .then((txt) => { + const match = txt.match(/ip=([^\n]+)/); + return match ? match[1].trim() : Promise.reject('No IP in trace'); + }), + ]); + + timeouts.forEach(clearTimeout); + + if (res && typeof res === 'string' && res.length > 3) { + cachedIp = res; + cachedIpTime = now; + sessionStorage.setItem('truevote_resolved_ip', res); + return res; + } + } catch (e) { + timeouts.forEach(clearTimeout); + } + + const fallbackIp = '127.0.0.1'; + cachedIp = fallbackIp; + cachedIpTime = now; + return fallbackIp; +} + +function getStoredTimestamps(key) { + if (typeof window === 'undefined') return []; + try { + const raw = localStorage.getItem(key); + if (!raw) return []; + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + return parsed.filter((t) => typeof t === 'number' && !isNaN(t)); + } + } catch (e) {} + return []; +} + +function saveTimestamps(key, list) { + if (typeof window === 'undefined') return; + try { + localStorage.setItem(key, JSON.stringify(list)); + } catch (e) {} +} + +export async function checkRateLimit(eventId) { + if (typeof window === 'undefined') { + return { + allowed: true, + remainingIpVotes: MAX_BURST_PER_IP_PER_MINUTE, + remainingDeviceVotes: MAX_VOTES_PER_DEVICE_PER_MINUTE, + }; + } + + const now = Date.now(); + const oneMinuteAgo = now - 60 * 1000; + const oneHourAgo = now - 60 * 60 * 1000; + + const deviceKey = `${DEVICE_STORAGE_KEY}_${eventId || 'global'}`; + const deviceHistory = getStoredTimestamps(deviceKey).filter((t) => t > oneHourAgo); + const deviceRecentMinute = deviceHistory.filter((t) => t > oneMinuteAgo); + + if (deviceRecentMinute.length >= MAX_VOTES_PER_DEVICE_PER_MINUTE) { + const oldestInMinute = deviceRecentMinute[0]; + const retryAfterSeconds = Math.max(1, Math.ceil((oldestInMinute + 60 * 1000 - now) / 1000)); + return { + allowed: false, + reason: `Device rate limit reached. Please wait ${retryAfterSeconds} seconds before attempting another ballot.`, + remainingIpVotes: 0, + remainingDeviceVotes: 0, + retryAfterSeconds, + }; + } + + if (deviceHistory.length >= MAX_VOTES_PER_DEVICE_PER_HOUR) { + const oldestInHour = deviceHistory[0]; + const retryAfterSeconds = Math.max(1, Math.ceil((oldestInHour + 60 * 60 * 1000 - now) / 1000)); + return { + allowed: false, + reason: 'Device submission quota reached for this hour. Please try again later.', + remainingIpVotes: 0, + remainingDeviceVotes: 0, + retryAfterSeconds, + }; + } + + const clientIp = await resolveClientIp(); + const ipKey = `${IP_STORAGE_KEY}_${clientIp.replace(/[^a-zA-Z0-9]/g, '_')}`; + const ipHistory = getStoredTimestamps(ipKey).filter((t) => t > oneHourAgo); + const ipRecentMinute = ipHistory.filter((t) => t > oneMinuteAgo); + + if (ipRecentMinute.length >= MAX_BURST_PER_IP_PER_MINUTE) { + const oldestInMinute = ipRecentMinute[0]; + const retryAfterSeconds = Math.max(1, Math.ceil((oldestInMinute + 60 * 1000 - now) / 1000)); + return { + allowed: false, + reason: `High burst traffic detected on this Wi-Fi network. Please wait ${retryAfterSeconds} seconds to preserve network integrity.`, + remainingIpVotes: 0, + remainingDeviceVotes: Math.max(0, MAX_VOTES_PER_DEVICE_PER_MINUTE - deviceRecentMinute.length), + retryAfterSeconds, + clientIp, + }; + } + + if (ipHistory.length >= MAX_VOTES_PER_IP_PER_HOUR) { + return { + allowed: false, + reason: 'Network voting capacity reached on this shared connection. Please try again shortly.', + remainingIpVotes: 0, + remainingDeviceVotes: 0, + clientIp, + }; + } + + return { + allowed: true, + remainingIpVotes: Math.max(0, MAX_BURST_PER_IP_PER_MINUTE - ipRecentMinute.length), + remainingDeviceVotes: Math.max(0, MAX_VOTES_PER_DEVICE_PER_MINUTE - deviceRecentMinute.length), + clientIp, + }; +} + +export async function recordSuccessfulVote(eventId) { + if (typeof window === 'undefined') return; + + const now = Date.now(); + const oneHourAgo = now - 60 * 60 * 1000; + + const deviceKey = `${DEVICE_STORAGE_KEY}_${eventId || 'global'}`; + const deviceHistory = getStoredTimestamps(deviceKey).filter((t) => t > oneHourAgo); + deviceHistory.push(now); + saveTimestamps(deviceKey, deviceHistory); + + try { + const clientIp = await resolveClientIp(); + const ipKey = `${IP_STORAGE_KEY}_${clientIp.replace(/[^a-zA-Z0-9]/g, '_')}`; + const ipHistory = getStoredTimestamps(ipKey).filter((t) => t > oneHourAgo); + ipHistory.push(now); + saveTimestamps(ipKey, ipHistory); + } catch (e) {} +} diff --git a/src/RateLimit.test.js b/src/RateLimit.test.js new file mode 100644 index 0000000..6aa4e21 --- /dev/null +++ b/src/RateLimit.test.js @@ -0,0 +1,33 @@ +import { checkRateLimit, recordSuccessfulVote, resolveClientIp, MAX_BURST_PER_IP_PER_MINUTE } from './security/ratelimit'; + +describe('Rate Limiter Security Module', () => { + beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + }); + + test('resolves fallback client IP in test environment without crashing', async () => { + const ip = await resolveClientIp(); + expect(ip).toBe('127.0.0.1'); + }); + + test('allows legitimate initial vote', async () => { + const status = await checkRateLimit('event-test-1'); + expect(status.allowed).toBe(true); + expect(status.remainingDeviceVotes).toBeGreaterThanOrEqual(1); + }); + + test('blocks rapid consecutive votes from the same device in under one minute', async () => { + await recordSuccessfulVote('event-test-2'); + const status = await checkRateLimit('event-test-2'); + expect(status.allowed).toBe(false); + expect(status.reason).toMatch(/Device rate limit reached/i); + expect(status.retryAfterSeconds).toBeGreaterThan(0); + }); + + test('allows multiple distinct devices on same IP up to burst limit', async () => { + const status = await checkRateLimit('event-test-shared-wifi'); + expect(status.allowed).toBe(true); + expect(status.remainingIpVotes).toBe(MAX_BURST_PER_IP_PER_MINUTE); + }); +}); diff --git a/src/security/ratelimit.ts b/src/security/ratelimit.ts new file mode 100644 index 0000000..e81a8a7 --- /dev/null +++ b/src/security/ratelimit.ts @@ -0,0 +1,189 @@ +export interface RateLimitStatus { + allowed: boolean; + reason?: string; + remainingIpVotes: number; + remainingDeviceVotes: number; + retryAfterSeconds?: number; + clientIp?: string; +} + +export const MAX_VOTES_PER_DEVICE_PER_MINUTE = 1; +export const MAX_VOTES_PER_DEVICE_PER_HOUR = 3; + +export const MAX_BURST_PER_IP_PER_MINUTE = 20; +export const MAX_VOTES_PER_IP_PER_HOUR = 600; + +const IP_STORAGE_KEY = 'truevote_ip_rate_tracker'; +const DEVICE_STORAGE_KEY = 'truevote_device_rate_tracker'; + +let cachedIp: string | null = null; +let cachedIpTime = 0; + +export async function resolveClientIp(): Promise { + if (typeof window === 'undefined') return '127.0.0.1'; + if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'test') { + return '127.0.0.1'; + } + + const now = Date.now(); + if (cachedIp && (now - cachedIpTime) < 300000) { + return cachedIp; + } + + const stored = sessionStorage.getItem('truevote_resolved_ip'); + if (stored) { + cachedIp = stored; + cachedIpTime = now; + return stored; + } + + const controllers = [new AbortController(), new AbortController()]; + const timeouts = controllers.map((c) => setTimeout(() => c.abort(), 1800)); + + try { + const res = await Promise.race([ + fetch('https://api64.ipify.org?format=json', { signal: controllers[0].signal }) + .then((r) => (r.ok ? r.json() : Promise.reject(r.statusText))) + .then((d) => d.ip), + fetch('https://cloudflare.com/cdn-cgi/trace', { signal: controllers[1].signal }) + .then((r) => (r.ok ? r.text() : Promise.reject(r.statusText))) + .then((txt) => { + const match = txt.match(/ip=([^\n]+)/); + return match ? match[1].trim() : Promise.reject('No IP in trace'); + }), + ]); + + timeouts.forEach(clearTimeout); + + if (res && typeof res === 'string' && res.length > 3) { + cachedIp = res; + cachedIpTime = now; + sessionStorage.setItem('truevote_resolved_ip', res); + return res; + } + } catch (e) { + timeouts.forEach(clearTimeout); + } + + const fallbackIp = '127.0.0.1'; + cachedIp = fallbackIp; + cachedIpTime = now; + return fallbackIp; +} + +function getStoredTimestamps(key: string): number[] { + if (typeof window === 'undefined') return []; + try { + const raw = localStorage.getItem(key); + if (!raw) return []; + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + return parsed.filter((t) => typeof t === 'number' && !isNaN(t)); + } + } catch (e) {} + return []; +} + +function saveTimestamps(key: string, list: number[]): void { + if (typeof window === 'undefined') return; + try { + localStorage.setItem(key, JSON.stringify(list)); + } catch (e) {} +} + +export async function checkRateLimit(eventId?: string): Promise { + if (typeof window === 'undefined') { + return { + allowed: true, + remainingIpVotes: MAX_BURST_PER_IP_PER_MINUTE, + remainingDeviceVotes: MAX_VOTES_PER_DEVICE_PER_MINUTE, + }; + } + + const now = Date.now(); + const oneMinuteAgo = now - 60 * 1000; + const oneHourAgo = now - 60 * 60 * 1000; + + const deviceKey = `${DEVICE_STORAGE_KEY}_${eventId || 'global'}`; + const deviceHistory = getStoredTimestamps(deviceKey).filter((t) => t > oneHourAgo); + const deviceRecentMinute = deviceHistory.filter((t) => t > oneMinuteAgo); + + if (deviceRecentMinute.length >= MAX_VOTES_PER_DEVICE_PER_MINUTE) { + const oldestInMinute = deviceRecentMinute[0]; + const retryAfterSeconds = Math.max(1, Math.ceil((oldestInMinute + 60 * 1000 - now) / 1000)); + return { + allowed: false, + reason: `Device rate limit reached. Please wait ${retryAfterSeconds} seconds before attempting another ballot.`, + remainingIpVotes: 0, + remainingDeviceVotes: 0, + retryAfterSeconds, + }; + } + + if (deviceHistory.length >= MAX_VOTES_PER_DEVICE_PER_HOUR) { + const oldestInHour = deviceHistory[0]; + const retryAfterSeconds = Math.max(1, Math.ceil((oldestInHour + 60 * 60 * 1000 - now) / 1000)); + return { + allowed: false, + reason: 'Device submission quota reached for this hour. Please try again later.', + remainingIpVotes: 0, + remainingDeviceVotes: 0, + retryAfterSeconds, + }; + } + + const clientIp = await resolveClientIp(); + const ipKey = `${IP_STORAGE_KEY}_${clientIp.replace(/[^a-zA-Z0-9]/g, '_')}`; + const ipHistory = getStoredTimestamps(ipKey).filter((t) => t > oneHourAgo); + const ipRecentMinute = ipHistory.filter((t) => t > oneMinuteAgo); + + if (ipRecentMinute.length >= MAX_BURST_PER_IP_PER_MINUTE) { + const oldestInMinute = ipRecentMinute[0]; + const retryAfterSeconds = Math.max(1, Math.ceil((oldestInMinute + 60 * 1000 - now) / 1000)); + return { + allowed: false, + reason: `High burst traffic detected on this Wi-Fi network. Please wait ${retryAfterSeconds} seconds to preserve network integrity.`, + remainingIpVotes: 0, + remainingDeviceVotes: Math.max(0, MAX_VOTES_PER_DEVICE_PER_MINUTE - deviceRecentMinute.length), + retryAfterSeconds, + clientIp, + }; + } + + if (ipHistory.length >= MAX_VOTES_PER_IP_PER_HOUR) { + return { + allowed: false, + reason: 'Network voting capacity reached on this shared connection. Please try again shortly.', + remainingIpVotes: 0, + remainingDeviceVotes: 0, + clientIp, + }; + } + + return { + allowed: true, + remainingIpVotes: Math.max(0, MAX_BURST_PER_IP_PER_MINUTE - ipRecentMinute.length), + remainingDeviceVotes: Math.max(0, MAX_VOTES_PER_DEVICE_PER_MINUTE - deviceRecentMinute.length), + clientIp, + }; +} + +export async function recordSuccessfulVote(eventId?: string): Promise { + if (typeof window === 'undefined') return; + + const now = Date.now(); + const oneHourAgo = now - 60 * 60 * 1000; + + const deviceKey = `${DEVICE_STORAGE_KEY}_${eventId || 'global'}`; + const deviceHistory = getStoredTimestamps(deviceKey).filter((t) => t > oneHourAgo); + deviceHistory.push(now); + saveTimestamps(deviceKey, deviceHistory); + + try { + const clientIp = await resolveClientIp(); + const ipKey = `${IP_STORAGE_KEY}_${clientIp.replace(/[^a-zA-Z0-9]/g, '_')}`; + const ipHistory = getStoredTimestamps(ipKey).filter((t) => t > oneHourAgo); + ipHistory.push(now); + saveTimestamps(ipKey, ipHistory); + } catch (e) {} +} diff --git a/src/voting/VotingPage.tsx b/src/voting/VotingPage.tsx index 33a856d..19e4211 100644 --- a/src/voting/VotingPage.tsx +++ b/src/voting/VotingPage.tsx @@ -4,6 +4,7 @@ import { EventItem, BallotOption } from '../dashboard/types'; import { fetchEventByIdFromPinata, fetchEventsFromPinata, uploadEventToPinata } from '../services/pinata'; import { loadEventsFromBackup, saveEventsToBackup } from '../services/storage'; import { detectSafeEnvironment } from '../security/detectenv'; +import { checkRateLimit, recordSuccessfulVote } from '../security/ratelimit'; import './voting.css'; async function sha256(message: string): Promise { @@ -707,6 +708,12 @@ export const VotingPage: React.FC = () => { if (!event) return; + const rateStatus = await checkRateLimit(event.id); + if (!rateStatus.allowed) { + setErrorMessage(rateStatus.reason || 'Rate limit exceeded. Please wait before submitting again.'); + return; + } + const voteCheck = checkHasAlreadyVoted(event.id) || (event.votingNumber ? checkHasAlreadyVoted(event.votingNumber) : { voted: false, receipt: null }) || @@ -826,6 +833,7 @@ export const VotingPage: React.FC = () => { votingNumber: event.votingNumber, }; recordVotedNullifier(event.id, event.votingNumber, receiptData); + recordSuccessfulVote(event.id).catch(() => {}); setEvent(updatedEvent); setLatestReceipt({ receiptHash, timestamp });