diff --git a/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java b/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java index e548d54c3..d202b9b13 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java @@ -21,6 +21,7 @@ import com.velocitypowered.proxy.command.CommandManager; import com.velocitypowered.proxy.protocol.util.FaviconSerializer; import com.velocitypowered.proxy.util.AddressUtil; import com.velocitypowered.proxy.util.EncryptionUtils; +import com.velocitypowered.proxy.util.Ratelimiter; import com.velocitypowered.proxy.util.ServerMap; import io.netty.bootstrap.Bootstrap; import net.kyori.text.Component; @@ -71,6 +72,7 @@ public class VelocityServer implements ProxyServer { return true; } }; + private final Ratelimiter ipAttemptLimiter = new Ratelimiter(3000); // TODO: Configurable. private VelocityServer() { commandManager.registerCommand("velocity", new VelocityCommand()); @@ -162,6 +164,10 @@ public class VelocityServer implements ProxyServer { return httpClient; } + public Ratelimiter getIpAttemptLimiter() { + return ipAttemptLimiter; + } + public boolean registerConnection(ConnectedPlayer connection) { String lowerName = connection.getUsername().toLowerCase(Locale.US); if (connectionsByName.putIfAbsent(lowerName, connection) != null) { diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/HandshakeSessionHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/HandshakeSessionHandler.java index 700709867..a603e7c8a 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/HandshakeSessionHandler.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/HandshakeSessionHandler.java @@ -15,6 +15,7 @@ import net.kyori.text.TextComponent; import net.kyori.text.TranslatableComponent; import net.kyori.text.format.TextColor; +import java.net.InetAddress; import java.net.InetSocketAddress; public class HandshakeSessionHandler implements MinecraftSessionHandler { @@ -50,6 +51,11 @@ public class HandshakeSessionHandler implements MinecraftSessionHandler { connection.closeWith(Disconnect.create(TranslatableComponent.of("multiplayer.disconnect.outdated_client"))); return; } else { + InetAddress address = ((InetSocketAddress) connection.getChannel().remoteAddress()).getAddress(); + if (!VelocityServer.getServer().getIpAttemptLimiter().attempt(address)) { + connection.closeWith(Disconnect.create(TextComponent.of("You are logging in too fast, try again later."))); + return; + } connection.setSessionHandler(new LoginSessionHandler(connection)); } break; diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/LoginSessionHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/LoginSessionHandler.java index cc28c91fa..d08351d14 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/LoginSessionHandler.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/LoginSessionHandler.java @@ -91,6 +91,11 @@ public class LoginSessionHandler implements MinecraftSessionHandler { VelocityServer.getServer().getHttpClient() .get(new URL(String.format(MOJANG_SERVER_AUTH_URL, login.getUsername(), serverId, playerIp))) .thenAcceptAsync(profileResponse -> { + if (inbound.isClosed()) { + // The player disconnected after we authenticated them. + return; + } + try { inbound.enableEncryption(decryptedSharedSecret); } catch (GeneralSecurityException e) { diff --git a/proxy/src/main/java/com/velocitypowered/proxy/util/Ratelimiter.java b/proxy/src/main/java/com/velocitypowered/proxy/util/Ratelimiter.java new file mode 100644 index 000000000..2f49c6e6b --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/util/Ratelimiter.java @@ -0,0 +1,41 @@ +package com.velocitypowered.proxy.util; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Ticker; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; + +import java.net.InetAddress; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +public class Ratelimiter { + private final Cache expiringCache; + private final long timeoutNanos; + + public Ratelimiter(long timeoutMs) { + this(timeoutMs, Ticker.systemTicker()); + } + + @VisibleForTesting + Ratelimiter(long timeoutMs, Ticker ticker) { + this.timeoutNanos = TimeUnit.MILLISECONDS.toNanos(timeoutMs); + this.expiringCache = CacheBuilder.newBuilder() + .ticker(ticker) + .concurrencyLevel(Runtime.getRuntime().availableProcessors()) + .expireAfterWrite(timeoutMs, TimeUnit.MILLISECONDS) + .build(); + } + + public boolean attempt(InetAddress address) { + long expectedNewValue = System.nanoTime() + timeoutNanos; + long last; + try { + last = expiringCache.get(address, () -> expectedNewValue); + } catch (ExecutionException e) { + // It should be impossible for this to fail. + throw new AssertionError(e); + } + return expectedNewValue == last; + } +} diff --git a/proxy/src/test/java/com/velocitypowered/proxy/util/RatelimiterTest.java b/proxy/src/test/java/com/velocitypowered/proxy/util/RatelimiterTest.java new file mode 100644 index 000000000..7df52cb96 --- /dev/null +++ b/proxy/src/test/java/com/velocitypowered/proxy/util/RatelimiterTest.java @@ -0,0 +1,30 @@ +package com.velocitypowered.proxy.util; + +import com.google.common.base.Ticker; +import org.junit.jupiter.api.Test; + +import java.net.InetAddress; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.jupiter.api.Assertions.*; + +class RatelimiterTest { + + @Test + void attempt() { + long base = System.nanoTime(); + AtomicLong extra = new AtomicLong(); + Ticker testTicker = new Ticker() { + @Override + public long read() { + return base + extra.get(); + } + }; + Ratelimiter ratelimiter = new Ratelimiter(1000, testTicker); + assertTrue(ratelimiter.attempt(InetAddress.getLoopbackAddress())); + assertFalse(ratelimiter.attempt(InetAddress.getLoopbackAddress())); + extra.addAndGet(TimeUnit.SECONDS.toNanos(2)); + assertTrue(ratelimiter.attempt(InetAddress.getLoopbackAddress())); + } +} \ No newline at end of file diff --git a/proxy/src/test/java/com/velocitypowered/proxy/util/ServerMapTest.java b/proxy/src/test/java/com/velocitypowered/proxy/util/ServerMapTest.java new file mode 100644 index 000000000..30d2c617d --- /dev/null +++ b/proxy/src/test/java/com/velocitypowered/proxy/util/ServerMapTest.java @@ -0,0 +1,35 @@ +package com.velocitypowered.proxy.util; + +import com.velocitypowered.api.server.ServerInfo; +import org.junit.jupiter.api.Test; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +class ServerMapTest { + private static final InetSocketAddress TEST_ADDRESS = new InetSocketAddress(InetAddress.getLoopbackAddress(), 25565); + + @Test + void respectsCaseInsensitivity() { + ServerMap map = new ServerMap(); + ServerInfo info = new ServerInfo("TestServer", TEST_ADDRESS); + map.register(info); + + assertEquals(Optional.of(info), map.getServer("TestServer")); + assertEquals(Optional.of(info), map.getServer("testserver")); + assertEquals(Optional.of(info), map.getServer("TESTSERVER")); + } + + @Test + void rejectsRepeatedRegisterAttempts() { + ServerMap map = new ServerMap(); + ServerInfo info = new ServerInfo("TestServer", TEST_ADDRESS); + map.register(info); + + ServerInfo willReject = new ServerInfo("TESTSERVER", TEST_ADDRESS); + assertThrows(IllegalArgumentException.class, () -> map.register(willReject)); + } +} \ No newline at end of file