From 373d0399c189ad626215a7099967ac7e8b126eb3 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 9 Jun 2026 11:20:12 +0000 Subject: [PATCH] fix: trusted-proxy gate for forwarded IP, wired-var cache + ghost-session cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security (S3): - AuthHttpUtil/WebSocketHttpHandler: only honour the configured ws.ip.header forwarded-IP header when the DIRECT peer is a trusted reverse proxy, instead of trusting it unconditionally. Loopback is always trusted; extra proxies can be allow-listed (exact IP or string prefix, comma-separated) via the new `ws.ip.header.trusted` config key — default-deny so the header can't be spoofed from the open internet to evade per-IP rate limiting and IP bans. Also take only the first comma token when setting the game-session WS_IP. Leak cleanup (C4): - WiredVariableReferenceSupport.invalidateRoom(): drop a room's shared wired-variable assignment caches; called from Room.dispose so the static USER/ROOM_ASSIGNMENT_CACHE maps don't retain entries for the JVM lifetime. - SessionResumeManager.parkHabbo: if the scheduler refuses the grace-expiry task (future == null), disconnect immediately instead of parking an un-reapable GhostSession that would pin the Habbo + room refs forever. Note: ws.ip.header.trusted defaults to loopback-only; deployments whose proxy is on another host must add its IP/prefix to that key or client IPs will collapse to the proxy address. --- .../gameclients/SessionResumeManager.java | 9 +++++ .../extra/WiredVariableReferenceSupport.java | 12 +++++++ .../com/eu/habbo/habbohotel/rooms/Room.java | 4 +++ .../gameserver/auth/AuthHttpUtil.java | 33 +++++++++++++++++-- .../handlers/WebSocketHttpHandler.java | 10 ++++-- 5 files changed, 64 insertions(+), 4 deletions(-) diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/gameclients/SessionResumeManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/gameclients/SessionResumeManager.java index e8099bbc..5052168f 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/gameclients/SessionResumeManager.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/gameclients/SessionResumeManager.java @@ -71,6 +71,15 @@ public class SessionResumeManager { } }, graceSeconds * 1000); + if (future == null) { + // The scheduler refused the grace-expiry task (pool saturated or + // shutting down). Parking now would leave a GhostSession that nothing + // can ever reap (the Habbo + room refs pinned for the JVM lifetime), + // so disconnect immediately instead. + performFullDisconnect(habbo); + return false; + } + ghostSessions.put(userId, new GhostSession(habbo, ssoTicket, future, previousEffectId, previousEffectEnd)); applyPausedEffect(habbo); diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/extra/WiredVariableReferenceSupport.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/extra/WiredVariableReferenceSupport.java index d8de5ab4..344524dc 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/extra/WiredVariableReferenceSupport.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/interactions/wired/extra/WiredVariableReferenceSupport.java @@ -227,6 +227,18 @@ public final class WiredVariableReferenceSupport { USER_ASSIGNMENT_CACHE.entrySet().removeIf(entry -> entry.getKey().startsWith(prefix)); } + /** + * Drops all cached shared-variable assignments belonging to a room. Both + * caches are keyed "roomId:itemId[:userId]", so the trailing colon makes the + * prefix match the exact room id. Called on room dispose so the static caches + * don't retain entries for the JVM lifetime. + */ + public static void invalidateRoom(int roomId) { + String prefix = roomId + ":"; + USER_ASSIGNMENT_CACHE.entrySet().removeIf(entry -> entry.getKey().startsWith(prefix)); + ROOM_ASSIGNMENT_CACHE.entrySet().removeIf(entry -> entry.getKey().startsWith(prefix)); + } + public static SharedRoomAssignment getSharedRoomAssignment(WiredExtraVariableReference reference) { if (reference == null || !reference.isRoomReference()) { return null; diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/Room.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/Room.java index b8b25ceb..1a69f83f 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/Room.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/Room.java @@ -1028,6 +1028,10 @@ public class Room implements Comparable, ISerialize, Runnable { com.eu.habbo.habbohotel.wired.core.WiredManager.getEngine().clearRoomDiagnostics(this.id); } + // Drop this room's shared wired-variable assignment caches (otherwise + // they accrue per (room, item, user) for the JVM lifetime). + com.eu.habbo.habbohotel.items.interactions.wired.extra.WiredVariableReferenceSupport.invalidateRoom(this.id); + this.itemManager.clear(); this.unitManager.clearQueue(); diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthHttpUtil.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthHttpUtil.java index 0d949ec8..acd03b82 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthHttpUtil.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/auth/AuthHttpUtil.java @@ -28,7 +28,7 @@ import java.sql.SQLException; import java.util.Base64; import java.util.regex.Pattern; -final class AuthHttpUtil { +public final class AuthHttpUtil { private static final Logger LOGGER = LoggerFactory.getLogger(AuthHttpUtil.class); @@ -132,7 +132,10 @@ final class AuthHttpUtil { String ipHeader = Emulator.getConfig() != null ? Emulator.getConfig().getValue("ws.ip.header", "") : ""; - if (!ipHeader.isEmpty() && req.headers().contains(ipHeader)) { + // Only trust a client-supplied forwarded-IP header when the DIRECT peer + // is a trusted reverse proxy; otherwise an attacker hitting the port + // directly could spoof it to evade per-IP rate limiting and IP bans. + if (!ipHeader.isEmpty() && req.headers().contains(ipHeader) && isTrustedProxy(ctx)) { String hv = req.headers().get(ipHeader); if (hv != null && !hv.isEmpty()) { int comma = hv.indexOf(','); @@ -148,6 +151,32 @@ final class AuthHttpUtil { return ""; } + /** + * Whether the channel's direct peer may set a forwarded-IP header. Loopback + * is always trusted; additional proxies can be allow-listed (exact IP or + * string prefix, comma-separated) via the {@code ws.ip.header.trusted} + * config key. Default-deny so the header can't be spoofed from the open net. + */ + public static boolean isTrustedProxy(ChannelHandlerContext ctx) { + String peerIp = (ctx.channel().remoteAddress() instanceof InetSocketAddress a) + ? a.getAddress().getHostAddress() : null; + if (peerIp == null || peerIp.isEmpty()) return false; + if (peerIp.equals("127.0.0.1") || peerIp.equals("::1") || peerIp.equals("0:0:0:0:0:0:0:1")) { + return true; + } + String trusted = Emulator.getConfig() != null + ? Emulator.getConfig().getValue("ws.ip.header.trusted", "") + : ""; + if (trusted.isEmpty()) return false; + for (String entry : trusted.split(",")) { + String prefix = entry.trim(); + if (!prefix.isEmpty() && (peerIp.equals(prefix) || peerIp.startsWith(prefix))) { + return true; + } + } + return false; + } + static boolean checkPassword(String plain, String stored) { String compatible = stored.startsWith("$2y$") ? "$2a$" + stored.substring(4) : stored; try { diff --git a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/handlers/WebSocketHttpHandler.java b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/handlers/WebSocketHttpHandler.java index 572e9488..d356c0e5 100644 --- a/Emulator/src/main/java/com/eu/habbo/networking/gameserver/handlers/WebSocketHttpHandler.java +++ b/Emulator/src/main/java/com/eu/habbo/networking/gameserver/handlers/WebSocketHttpHandler.java @@ -2,6 +2,7 @@ package com.eu.habbo.networking.gameserver.handlers; import com.eu.habbo.Emulator; import com.eu.habbo.networking.gameserver.GameServerAttributes; +import com.eu.habbo.networking.gameserver.auth.AuthHttpUtil; import io.netty.buffer.Unpooled; import io.netty.channel.ChannelFutureListener; import io.netty.channel.ChannelHandlerContext; @@ -65,9 +66,14 @@ public class WebSocketHttpHandler extends ChannelInboundHandlerAdapter { private static void captureForwardedIp(ChannelHandlerContext ctx, HttpMessage req) { String ipHeader = Emulator.getConfig().getValue("ws.ip.header", ""); - if (!ipHeader.isEmpty() && req.headers().contains(ipHeader)) { + // Only honour the forwarded-IP header from a trusted reverse proxy, + // otherwise the game-session IP (used for bans/rate-limits) is spoofable. + if (!ipHeader.isEmpty() && req.headers().contains(ipHeader) && AuthHttpUtil.isTrustedProxy(ctx)) { String ip = req.headers().get(ipHeader); - ctx.channel().attr(GameServerAttributes.WS_IP).set(ip); + if (ip != null && !ip.isEmpty()) { + int comma = ip.indexOf(','); + ctx.channel().attr(GameServerAttributes.WS_IP).set((comma > 0 ? ip.substring(0, comma) : ip).trim()); + } } }