fix: trusted-proxy gate for forwarded IP, wired-var cache + ghost-session cleanup

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.
This commit is contained in:
simoleo89
2026-06-09 11:20:12 +00:00
parent 01c17c0511
commit 373d0399c1
5 changed files with 64 additions and 4 deletions
@@ -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);
@@ -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;
@@ -1028,6 +1028,10 @@ public class Room implements Comparable<Room>, 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();
@@ -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 {
@@ -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());
}
}
}