mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-20 23:58:33 +02:00
Adversarial multi-agent review of the launch candidate produced 22 confirmed
findings (12 more were refuted). This closes the critical, high, and the
mediums/lows that are cheap and real, then does the iso/3D/Tavern art pass.
CRITICAL — unauthenticated remote kill switch
Socket.IO does not wrap listener invocation, and this process treats
uncaughtException/unhandledRejection as fatal, so ONE unguarded throw in ANY
listener exits the server. `socket.emit('debug_ping')` with no argument did
exactly that, and systemd restarts straight back into it. Every registration
in handleConnection now goes through a safe-dispatch wrapper that contains
sync throws and rejected promises to the offending socket; handleDebugPing
also stops trusting its payload.
HIGH
- NIP-98 login now binds the signed event to this deployment's host. The path
check alone let an event signed for any other origin authenticate here, and
linking ADOPTS the account owning that pubkey — full account takeover.
- Fog of war is enforced server-side. getState() shipped absolute
entrance/exit/treasure coordinates and the monster's exact position in every
frame, with concealment implemented only in the browser. Discovery is sticky;
the monster is reported only while in view.
- NODE_ENV must now be explicit and recognized. Unset, every production gate
read false while DebugManager reported production and silenced logging.
- Tavern chat applies the lobby's ban list, rate limiter and user_id
attribution before entering global chat.
- userRegistry is deleted by socket.id (it was keyed by socket.id and deleted
by socket.client.id, so nothing was ever freed).
MEDIUM / LOW
- Payouts: a legitimately split transfer_split kept its transaction hashes
instead of quarantining already-broadcast funds with tx_hash NULL, and
provably pre-broadcast failures return to 'pending' rather than needs_review.
- RateLimiter key parsing fixed — cleanup() freed nothing and expired windows
never reset, because ids contain colons.
- Enter no longer submits a paid entry from behind a modal.
- Lobby chat no longer double-escapes server-escaped text.
- Rate limits for identity:get / address:prompt / early_entry.
- Spectators released when a game ends by disconnect, not just settlement.
- movementManager / addressManager per-socket maps cleaned on disconnect.
Renderers
Iso: opaque shading instead of translucent fog, additive torch/lantern/beacon
light pools, crevice AO, embers, cold memory wash, and it stops reallocating
the whole canvas every animation frame.
3D: instanced level geometry (3 draw calls), procedural stone/flagstone
materials, torch lights + shadows, real meshes for stairs/chest/monster
(they were humanoid capsules), dust motes, wall cutaway, and lighting
rebalanced now that fog is measured from the camera.
Tavern
Block height and hash fragments drift around the stage edge and ripple when a
block lands, fed by a cosmetic chain tip added to the blockheight broadcast.
Speech bubbles work again (they keyed off a socketId the server stopped
sending).
126 suites / 1005 tests pass.
308 lines
11 KiB
JavaScript
308 lines
11 KiB
JavaScript
/**
|
|
* Rate Limiter Module
|
|
* Provides rate limiting functionality for various user actions
|
|
*/
|
|
|
|
class RateLimiter {
|
|
constructor(options = {}) {
|
|
// Default rate limits. SocketHandlers passes a production `limits` override that
|
|
// takes precedence (merged below); these defaults are kept ALIGNED with it so a
|
|
// RateLimiter constructed without options (e.g. in tests) behaves the same.
|
|
this.limits = {
|
|
'payment:create': { window: 60000, max: 3 }, // 3 payments per minute
|
|
'game:start': { window: 60000, max: 15 }, // 15 game starts per minute
|
|
'game:queue': { window: 30000, max: 5 }, // 5 queue attempts per 30s
|
|
'chat:message': { window: 10000, max: 12 }, // 12 messages per 10s
|
|
'address:set': { window: 300000, max: 3 }, // 3 address changes per 5min
|
|
'move:player': { window: 1000, max: 50 }, // 50 moves per second (already handled separately)
|
|
'connection:new': { window: 60000, max: 10 }, // 10 connections per minute per IP
|
|
// Cheap-looking socket events that each perform a users SELECT + UPDATE (last_active)
|
|
// and an entitlements SELECT. Unlimited, they can be pipelined at packet rate and turn
|
|
// one socket into a database load generator.
|
|
'identity:read': { window: 10000, max: 20 }, // 20 identity/address reads per 10s
|
|
...options.limits
|
|
};
|
|
|
|
// Storage for rate limit data - in production, use Redis
|
|
this.storage = new Map(); // key: userId:action, value: { count, firstAttempt }
|
|
this.ipStorage = new Map(); // key: ip:action, value: { count, firstAttempt }
|
|
|
|
// Cleanup interval to prevent memory leaks
|
|
this.cleanupInterval = setInterval(() => this.cleanup(), 60000); // cleanup every minute
|
|
|
|
this.debugMode = options.debugMode || false;
|
|
}
|
|
|
|
/**
|
|
* Check if an action is rate limited for a user
|
|
* @param {string} userId - User identifier (socket ID)
|
|
* @param {string} action - Action type (e.g., 'payment:create')
|
|
* @param {string} ip - Optional IP address for IP-based limits
|
|
* @returns {Object} { allowed: boolean, retryAfter: number, remaining: number }
|
|
*/
|
|
async checkLimit(userId, action, ip = null) {
|
|
const limit = this.limits[action];
|
|
if (!limit) {
|
|
// No limit configured for this action
|
|
return { allowed: true, remaining: Infinity, retryAfter: 0 };
|
|
}
|
|
|
|
const now = Date.now();
|
|
const key = `${userId}:${action}`;
|
|
|
|
// Check user-based limits
|
|
const userResult = this._checkSingleLimit(key, limit, now);
|
|
|
|
// Check IP-based limits if IP provided
|
|
// Apply to: connection actions, payment actions, and game actions
|
|
let ipResult = { allowed: true, remaining: Infinity, retryAfter: 0 };
|
|
if (ip && this._shouldApplyIpLimit(action)) {
|
|
const ipKey = `${ip}:${action}`;
|
|
ipResult = this._checkSingleLimit(ipKey, limit, now, this.ipStorage);
|
|
}
|
|
|
|
// Return most restrictive result
|
|
const result = {
|
|
allowed: userResult.allowed && ipResult.allowed,
|
|
remaining: Math.min(userResult.remaining, ipResult.remaining),
|
|
retryAfter: Math.max(userResult.retryAfter, ipResult.retryAfter)
|
|
};
|
|
|
|
if (this.debugMode && !result.allowed) {
|
|
console.log(`🚫 Rate limit exceeded for ${userId}:${action}. Remaining: ${result.remaining}, RetryAfter: ${result.retryAfter}ms`);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Determine if an action should have IP-based rate limiting applied
|
|
* @param {string} action - Action type
|
|
* @returns {boolean}
|
|
*/
|
|
_shouldApplyIpLimit(action) {
|
|
// Actions that should be rate limited by IP (in addition to socket ID)
|
|
// This prevents abuse when users reconnect to bypass socket-based limits
|
|
const ipLimitedPrefixes = [
|
|
'connection:', // Connection attempts
|
|
'payment:', // Payment creation/operations
|
|
'game:', // Game start/queue operations
|
|
'address:', // Address changes
|
|
'identity:', // Identity/entitlement reads (each one hits the database)
|
|
'chat:' // Chat messages (reconnect-proof spam protection)
|
|
];
|
|
return ipLimitedPrefixes.some(prefix => action.startsWith(prefix));
|
|
}
|
|
|
|
/**
|
|
* Record an action attempt (increment counter)
|
|
* @param {string} userId - User identifier
|
|
* @param {string} action - Action type
|
|
* @param {string} ip - Optional IP address
|
|
*/
|
|
async recordAttempt(userId, action, ip = null) {
|
|
const now = Date.now();
|
|
const key = `${userId}:${action}`;
|
|
|
|
// Record user attempt
|
|
this._recordSingleAttempt(key, now);
|
|
|
|
// Record IP attempt for actions that should have IP-based limiting
|
|
if (ip && this._shouldApplyIpLimit(action)) {
|
|
const ipKey = `${ip}:${action}`;
|
|
this._recordSingleAttempt(ipKey, now, this.ipStorage);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get current usage stats for a user/action
|
|
* @param {string} userId
|
|
* @param {string} action
|
|
* @returns {Object} { count: number, remaining: number, resetTime: number }
|
|
*/
|
|
getUsageStats(userId, action) {
|
|
const limit = this.limits[action];
|
|
if (!limit) return { count: 0, remaining: Infinity, resetTime: 0 };
|
|
|
|
const key = `${userId}:${action}`;
|
|
const data = this.storage.get(key);
|
|
|
|
if (!data) {
|
|
return { count: 0, remaining: limit.max, resetTime: 0 };
|
|
}
|
|
|
|
const now = Date.now();
|
|
const resetTime = data.firstAttempt + limit.window;
|
|
|
|
if (now > resetTime) {
|
|
return { count: 0, remaining: limit.max, resetTime: 0 };
|
|
}
|
|
|
|
return {
|
|
count: data.count,
|
|
remaining: Math.max(0, limit.max - data.count),
|
|
resetTime: resetTime
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Clear rate limit data for a user (useful for admin overrides)
|
|
* @param {string} userId
|
|
* @param {string} action - Optional, if not provided clears all actions for user
|
|
*/
|
|
clearLimits(userId, action = null) {
|
|
if (action) {
|
|
const key = `${userId}:${action}`;
|
|
this.storage.delete(key);
|
|
} else {
|
|
// Clear all limits for this user
|
|
for (const key of this.storage.keys()) {
|
|
if (key.startsWith(`${userId}:`)) {
|
|
this.storage.delete(key);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Internal method to check a single limit
|
|
*/
|
|
_checkSingleLimit(key, limit, now, storage = this.storage) {
|
|
const data = storage.get(key);
|
|
|
|
if (!data) {
|
|
// First attempt
|
|
return { allowed: true, remaining: limit.max - 1, retryAfter: 0 };
|
|
}
|
|
|
|
// Check if window has expired
|
|
if (now - data.firstAttempt > limit.window) {
|
|
// Window expired, reset
|
|
storage.delete(key);
|
|
return { allowed: true, remaining: limit.max - 1, retryAfter: 0 };
|
|
}
|
|
|
|
// Within window, check if limit exceeded
|
|
if (data.count >= limit.max) {
|
|
const retryAfter = (data.firstAttempt + limit.window) - now;
|
|
return { allowed: false, remaining: 0, retryAfter: Math.max(0, retryAfter) };
|
|
}
|
|
|
|
return { allowed: true, remaining: limit.max - data.count - 1, retryAfter: 0 };
|
|
}
|
|
|
|
/**
|
|
* Internal method to record a single attempt
|
|
*/
|
|
_recordSingleAttempt(key, now, storage = this.storage) {
|
|
const data = storage.get(key);
|
|
|
|
if (!data) {
|
|
storage.set(key, { count: 1, firstAttempt: now });
|
|
} else {
|
|
// Check if window has expired
|
|
const limit = this._getLimitFromKey(key);
|
|
if (limit && now - data.firstAttempt > limit.window) {
|
|
// Reset window
|
|
storage.set(key, { count: 1, firstAttempt: now });
|
|
} else {
|
|
// Increment within window
|
|
data.count++;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get limit config from storage key.
|
|
*
|
|
* Storage keys are `${id}:${action}`, but the id itself contains colons — `u:123` / `s:<socketId>`
|
|
* from rateLimitContext.stableId, and raw IPv6 addresses in ipStorage. Stripping only the FIRST
|
|
* segment therefore produced `123:chat:message`, which matches no configured limit, so every
|
|
* lookup returned undefined. Two things silently broke: `cleanup()` skipped every entry (the only
|
|
* registered memory reclamation freed nothing, and both Maps grew for the process lifetime), and
|
|
* `_recordAttempt` never reset an expired window, so counts accumulated forever. Match the known
|
|
* action names as a suffix instead — correct regardless of how many colons the id contains.
|
|
*/
|
|
_getLimitFromKey(key) {
|
|
for (const action of Object.keys(this.limits)) {
|
|
if (key === action || key.endsWith(`:${action}`)) return this.limits[action];
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Cleanup expired entries to prevent memory leaks
|
|
*/
|
|
cleanup() {
|
|
const now = Date.now();
|
|
let cleanedCount = 0;
|
|
|
|
// Clean user storage
|
|
for (const [key, data] of this.storage.entries()) {
|
|
const limit = this._getLimitFromKey(key);
|
|
if (limit && now - data.firstAttempt > limit.window) {
|
|
this.storage.delete(key);
|
|
cleanedCount++;
|
|
}
|
|
}
|
|
|
|
// Clean IP storage
|
|
for (const [key, data] of this.ipStorage.entries()) {
|
|
const limit = this._getLimitFromKey(key);
|
|
if (limit && now - data.firstAttempt > limit.window) {
|
|
this.ipStorage.delete(key);
|
|
cleanedCount++;
|
|
}
|
|
}
|
|
|
|
if (this.debugMode && cleanedCount > 0) {
|
|
console.log(`🧹 RateLimiter cleanup: removed ${cleanedCount} expired entries`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get current statistics about rate limiter state
|
|
*/
|
|
getStats() {
|
|
return {
|
|
totalKeys: this.storage.size + this.ipStorage.size,
|
|
userKeys: this.storage.size,
|
|
ipKeys: this.ipStorage.size,
|
|
limits: Object.keys(this.limits).length
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get count of tracked entries (for dashboard)
|
|
*/
|
|
getTrackedCount() {
|
|
return this.storage.size + this.ipStorage.size;
|
|
}
|
|
|
|
/**
|
|
* Get count of currently blocked users (rough estimate)
|
|
*/
|
|
getBlockedCount() {
|
|
const now = Date.now();
|
|
let blocked = 0;
|
|
for (const [key, data] of this.storage.entries()) {
|
|
const limit = this._getLimitFromKey(key);
|
|
if (limit && data.count >= limit.max && (now - data.firstAttempt) < limit.window) {
|
|
blocked++;
|
|
}
|
|
}
|
|
return blocked;
|
|
}
|
|
|
|
/**
|
|
* Shutdown the rate limiter (cleanup interval)
|
|
*/
|
|
shutdown() {
|
|
if (this.cleanupInterval) {
|
|
clearInterval(this.cleanupInterval);
|
|
this.cleanupInterval = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = RateLimiter; |