diff --git a/Emulator/src/main/java/com/eu/habbo/Emulator.java b/Emulator/src/main/java/com/eu/habbo/Emulator.java index 63abc5f5..3c3d93b2 100644 --- a/Emulator/src/main/java/com/eu/habbo/Emulator.java +++ b/Emulator/src/main/java/com/eu/habbo/Emulator.java @@ -159,6 +159,9 @@ public final class Emulator { Emulator.config.register("rcon.rate_limit.timeout_ms", "0"); Emulator.config.register("rcon.mute.max_duration_seconds", "604800"); Emulator.config.register("rcon.achievement.max_progress", "10000"); + Emulator.config.register("rcon.execute_command.max_length", "256"); + Emulator.config.register("rcon.execute_command.denied_permissions", "cmd_shutdown;cmd_update_config;cmd_update_permissions;cmd_give_rank;cmd_badge;cmd_gift;cmd_credits;cmd_points;cmd_pixels;cmd_massbadge;cmd_masscredits;cmd_massgift;cmd_massduckets;cmd_masspoints;cmd_empty;cmd_empty_bots;cmd_empty_pets;cmd_unload;cmd_ban;cmd_superban;cmd_ip_ban;cmd_machine_ban;cmd_disconnect"); + Emulator.config.register("rcon.execute_command.allowed_permissions", ""); String hotelTimezoneId = Emulator.getConfig().getValue("hotel.timezone", java.time.ZoneId.systemDefault().getId()); System.out.println(); LOGGER.info("https://github.com/duckietm/Arcturus-Morningstar-Extended, "); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/rcon/ExecuteCommand.java b/Emulator/src/main/java/com/eu/habbo/messages/rcon/ExecuteCommand.java index fd16a35c..d728a86b 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/rcon/ExecuteCommand.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/rcon/ExecuteCommand.java @@ -1,14 +1,48 @@ package com.eu.habbo.messages.rcon; import com.eu.habbo.Emulator; +import com.eu.habbo.habbohotel.commands.Command; import com.eu.habbo.habbohotel.commands.CommandHandler; import com.eu.habbo.habbohotel.users.Habbo; import com.google.gson.Gson; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.Arrays; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; + public class ExecuteCommand extends RCONMessage { private static final Logger LOGGER = LoggerFactory.getLogger(ExecuteCommand.class); + static final int DEFAULT_MAX_COMMAND_LENGTH = 256; + private static final String DEFAULT_DENIED_PERMISSIONS = String.join(";", + "cmd_shutdown", + "cmd_update_config", + "cmd_update_permissions", + "cmd_give_rank", + "cmd_badge", + "cmd_gift", + "cmd_credits", + "cmd_points", + "cmd_pixels", + "cmd_massbadge", + "cmd_masscredits", + "cmd_massgift", + "cmd_massduckets", + "cmd_masspoints", + "cmd_empty", + "cmd_empty_bots", + "cmd_empty_pets", + "cmd_unload", + "cmd_ban", + "cmd_superban", + "cmd_ip_ban", + "cmd_machine_ban", + "cmd_disconnect"); public ExecuteCommand() { @@ -18,6 +52,33 @@ public class ExecuteCommand extends RCONMessage maxLength) { + this.status = STATUS_ERROR; + this.message = "invalid command"; + return; + } + + String commandKey = commandKey(commandLine); + if (commandKey.isEmpty()) { + this.status = STATUS_ERROR; + this.message = "invalid command"; + return; + } + + Command command = CommandHandler.getCommand(commandKey); + String commandPermission = command != null && command.permission != null ? command.permission : commandKey; + + if (!isAllowed(commandPermission, + Emulator.getConfig().getValue("rcon.execute_command.denied_permissions", DEFAULT_DENIED_PERMISSIONS), + Emulator.getConfig().getValue("rcon.execute_command.allowed_permissions", ""))) { + this.status = STATUS_ERROR; + this.message = "command not allowed"; + return; + } + Habbo habbo = Emulator.getGameServer().getGameClientManager().getHabbo(json.user_id); if (habbo == null) { @@ -26,18 +87,78 @@ public class ExecuteCommand extends RCONMessage allowed = permissionSet(allowedPermissions); + if (!allowed.isEmpty()) { + return allowed.contains(normalized); + } + + return !permissionSet(deniedPermissions).contains(normalized); + } + + static String commandKey(String commandLine) { + if (commandLine == null) { + return ""; + } + + String trimmed = commandLine.trim(); + if (!trimmed.startsWith(":")) { + return ""; + } + + String withoutPrefix = trimmed.substring(1).trim(); + if (withoutPrefix.isEmpty()) { + return ""; + } + + return withoutPrefix.split("\\s+", 2)[0].toLowerCase(Locale.ROOT); + } + + static int parseMaxCommandLength(String configured) { + try { + int parsed = Integer.parseInt(configured); + if (parsed > 0) { + return parsed; + } + } catch (NumberFormatException ignored) { + } + + return DEFAULT_MAX_COMMAND_LENGTH; + } + + private static Set permissionSet(String permissions) { + if (permissions == null || permissions.isBlank()) { + return Set.of(); + } + + return Arrays.stream(permissions.split("[;,]")) + .map(ExecuteCommand::normalize) + .filter(value -> !value.isEmpty()) + .collect(Collectors.toUnmodifiableSet()); + } + + private static String normalize(String permission) { + return permission == null ? "" : permission.trim().toLowerCase(Locale.ROOT); + } + static class JSONExecuteCommand { + @Positive(message = "invalid user") public int user_id; - + @NotBlank(message = "invalid command") + @Size(max = 512, message = "invalid command") public String command; } -} \ No newline at end of file +} diff --git a/Emulator/src/test/java/com/eu/habbo/messages/rcon/ExecuteCommandGuardTest.java b/Emulator/src/test/java/com/eu/habbo/messages/rcon/ExecuteCommandGuardTest.java new file mode 100644 index 00000000..f46863f0 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/rcon/ExecuteCommandGuardTest.java @@ -0,0 +1,53 @@ +package com.eu.habbo.messages.rcon; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ExecuteCommandGuardTest { + @Test + void extractsCommandKeyOnlyFromColonCommands() { + assertEquals("dance", ExecuteCommand.commandKey(":dance")); + assertEquals("dance", ExecuteCommand.commandKey(" :DaNcE 1 2 ")); + assertEquals("", ExecuteCommand.commandKey("dance")); + assertEquals("", ExecuteCommand.commandKey(": ")); + } + + @Test + void deniedPermissionsBlockDangerousDefaultsUnlessExplicitlyAllowed() { + assertFalse(ExecuteCommand.isAllowed("cmd_shutdown", "cmd_shutdown;cmd_give_rank", "")); + assertFalse(ExecuteCommand.isAllowed("CMD_GIVE_RANK", "cmd_shutdown,cmd_give_rank", "")); + assertTrue(ExecuteCommand.isAllowed("cmd_dance", "cmd_shutdown;cmd_give_rank", "")); + assertTrue(ExecuteCommand.isAllowed("cmd_shutdown", "cmd_shutdown", "cmd_shutdown")); + assertFalse(ExecuteCommand.isAllowed("cmd_dance", "cmd_shutdown", "cmd_about")); + } + + @Test + void parsesInvalidCommandLengthAsDefault() { + assertEquals(ExecuteCommand.DEFAULT_MAX_COMMAND_LENGTH, ExecuteCommand.parseMaxCommandLength(null)); + assertEquals(ExecuteCommand.DEFAULT_MAX_COMMAND_LENGTH, ExecuteCommand.parseMaxCommandLength("0")); + assertEquals(64, ExecuteCommand.parseMaxCommandLength("64")); + } + + @Test + void executeCommandHasConfigurableGuardRails() throws Exception { + String source = Files.readString(Path.of("src/main/java/com/eu/habbo/messages/rcon/ExecuteCommand.java")); + String emulator = Files.readString(Path.of("src/main/java/com/eu/habbo/Emulator.java")); + + assertTrue(source.contains("CommandHandler.getCommand(commandKey)"), + "RCON executecommand must resolve aliases to the registered command permission"); + assertTrue(source.contains("rcon.execute_command.denied_permissions"), + "RCON executecommand must support a configurable denied-permission list"); + assertTrue(source.contains("rcon.execute_command.allowed_permissions"), + "RCON executecommand must support a stricter configurable allowlist"); + assertTrue(source.contains("!commandLine.startsWith(\":\") || commandLine.length() > maxLength"), + "RCON executecommand must reject non-command payloads and oversized command lines"); + assertTrue(emulator.contains("rcon.execute_command.denied_permissions"), + "RCON executecommand guard defaults must be registered before the RCON server starts"); + } +}