Files
wownerogue/src/network/rateLimiter.js
T
jwinterm dacbf356ed fix: close pre-launch review findings and light the premium renderers
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.
2026-07-26 07:29:47 -04:00

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;