From 25fbd833cd847a76f1e46ce15b70e077e41b72e2 Mon Sep 17 00:00:00 2001 From: Wouter Gritter Date: Sun, 24 May 2026 17:59:34 +0200 Subject: [PATCH] Add decompressed-bytes-per-second rate limit, update packet limiter defaults (#1786) * Add decompressed-bytes-per-second packet limiter, update defaults * Revert "Add compression ratio limiter" --- .../proxy/config/VelocityConfiguration.java | 18 +++--- .../migration/ConfigurationMigration.java | 3 +- .../migration/PacketLimiterMigration.java | 62 +++++++++++++++++++ .../proxy/connection/MinecraftConnection.java | 10 +++ .../network/ServerChannelInitializer.java | 2 +- .../netty/MinecraftCompressDecoder.java | 23 +++++-- .../src/main/resources/default-velocity.toml | 12 +++- 7 files changed, 113 insertions(+), 17 deletions(-) create mode 100644 proxy/src/main/java/com/velocitypowered/proxy/config/migration/PacketLimiterMigration.java diff --git a/proxy/src/main/java/com/velocitypowered/proxy/config/VelocityConfiguration.java b/proxy/src/main/java/com/velocitypowered/proxy/config/VelocityConfiguration.java index e198a0716..2c7826e01 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/config/VelocityConfiguration.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/config/VelocityConfiguration.java @@ -31,6 +31,7 @@ import com.velocitypowered.proxy.config.migration.ForwardingMigration; import com.velocitypowered.proxy.config.migration.KeyAuthenticationMigration; import com.velocitypowered.proxy.config.migration.MiniMessageTranslationsMigration; import com.velocitypowered.proxy.config.migration.MotdMigration; +import com.velocitypowered.proxy.config.migration.PacketLimiterMigration; import com.velocitypowered.proxy.config.migration.TransferIntegrationMigration; import com.velocitypowered.proxy.util.AddressUtil; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; @@ -511,7 +512,8 @@ public class VelocityConfiguration implements ProxyConfig { new KeyAuthenticationMigration(), new MotdMigration(), new MiniMessageTranslationsMigration(), - new TransferIntegrationMigration() + new TransferIntegrationMigration(), + new PacketLimiterMigration() }; for (final ConfigurationMigration migration : migrations) { @@ -1004,12 +1006,13 @@ public class VelocityConfiguration implements ProxyConfig { /** * Configuration for packet limiting. * - * @param interval the interval in seconds to measure packets over - * @param pps the maximum number of packets per second allowed - * @param bytes the maximum number of bytes per second allowed + * @param interval the interval in seconds to measure packets over + * @param pps the maximum number of packets per second allowed + * @param bytes the maximum number of bytes per second allowed + * @param bytesAfterDecompression the maximum number of decompressed bytes per second allowed */ - public record PacketLimiterConfig(int interval, int pps, int bytes) { - public static PacketLimiterConfig DEFAULT = new PacketLimiterConfig(7, 500, -1); + public record PacketLimiterConfig(int interval, int pps, int bytes, int bytesAfterDecompression) { + public static PacketLimiterConfig DEFAULT = new PacketLimiterConfig(7, -1, -1, 5242880); /** * returns a PacketLimiterConfig from a config section, or the default if the section is null. @@ -1022,7 +1025,8 @@ public class VelocityConfiguration implements ProxyConfig { return new PacketLimiterConfig( config.getIntOrElse("interval", DEFAULT.interval()), config.getIntOrElse("packets-per-second", DEFAULT.pps()), - config.getIntOrElse("bytes-per-second", DEFAULT.bytes()) + config.getIntOrElse("bytes-per-second", DEFAULT.bytes()), + config.getIntOrElse("decompressed-bytes-per-second", DEFAULT.bytesAfterDecompression()) ); } else { return DEFAULT; diff --git a/proxy/src/main/java/com/velocitypowered/proxy/config/migration/ConfigurationMigration.java b/proxy/src/main/java/com/velocitypowered/proxy/config/migration/ConfigurationMigration.java index d28578a68..7c00b7bbb 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/config/migration/ConfigurationMigration.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/config/migration/ConfigurationMigration.java @@ -29,7 +29,8 @@ public sealed interface ConfigurationMigration KeyAuthenticationMigration, MotdMigration, MiniMessageTranslationsMigration, - TransferIntegrationMigration { + TransferIntegrationMigration, + PacketLimiterMigration { boolean shouldMigrate(CommentedFileConfig config); void migrate(CommentedFileConfig config, Logger logger) throws IOException; diff --git a/proxy/src/main/java/com/velocitypowered/proxy/config/migration/PacketLimiterMigration.java b/proxy/src/main/java/com/velocitypowered/proxy/config/migration/PacketLimiterMigration.java new file mode 100644 index 000000000..bbd3ac598 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/config/migration/PacketLimiterMigration.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2026 Velocity Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.velocitypowered.proxy.config.migration; + +import static com.velocitypowered.proxy.config.VelocityConfiguration.PacketLimiterConfig.DEFAULT; + +import com.electronwill.nightconfig.core.file.CommentedFileConfig; +import org.apache.logging.log4j.Logger; + +/** + * Configuration migration for the new [packet-limiter] section. + * Config version 2.7 may contain this section with only the `interval`, `packets-per-second` + * and `bytes-per-second` attributes. Config version 2.8 enforces these exist, adds the new + * `decompressed-bytes-per-second` attribute, adjusts the new default, and adds comments. + */ +public final class PacketLimiterMigration implements ConfigurationMigration { + + @Override + public boolean shouldMigrate(CommentedFileConfig config) { + return configVersion(config) < 2.8; + } + + @Override + public void migrate(CommentedFileConfig config, Logger logger) { + config.set("packet-limiter.interval", DEFAULT.interval()); + config.set("packet-limiter.packets-per-second", DEFAULT.pps()); + config.set("packet-limiter.bytes-per-second", DEFAULT.bytes()); + config.set("packet-limiter.decompressed-bytes-per-second", DEFAULT.bytesAfterDecompression()); + + config.setComment("packet-limiter.interval", """ + Size of the moving time window in seconds used to calculate average rates. + A larger window tolerates short bursts while still enforcing the configured limits over time."""); + + config.setComment("packet-limiter.packets-per-second", """ + Maximum average number of packets per second a client may send. -1 disables this check."""); + + config.setComment("packet-limiter.bytes-per-second", """ + Maximum average number of compressed (on-wire) bytes per second a client may send. -1 disables this check."""); + + config.setComment("packet-limiter.decompressed-bytes-per-second", """ + Maximum average number of decompressed bytes per second a client may send. + Protects against compression bomb attacks where small packets expand to excessive sizes after decompression. + -1 disables this check."""); + + config.set("config-version", "2.8"); + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftConnection.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftConnection.java index 0071716d9..0cb95752a 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftConnection.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftConnection.java @@ -38,7 +38,9 @@ import com.velocitypowered.proxy.connection.client.HandshakeSessionHandler; import com.velocitypowered.proxy.connection.client.InitialLoginSessionHandler; import com.velocitypowered.proxy.connection.client.StatusSessionHandler; import com.velocitypowered.proxy.network.Connections; +import com.velocitypowered.proxy.network.limiter.SimpleBytesPerSecondLimiter; import com.velocitypowered.proxy.protocol.MinecraftPacket; +import com.velocitypowered.proxy.protocol.ProtocolUtils; import com.velocitypowered.proxy.protocol.StateRegistry; import com.velocitypowered.proxy.protocol.VelocityConnectionEvent; import com.velocitypowered.proxy.protocol.netty.MinecraftCipherDecoder; @@ -571,6 +573,14 @@ public class MinecraftConnection extends ChannelInboundHandlerAdapter { channel.pipeline().addBefore(MINECRAFT_DECODER, COMPRESSION_DECODER, decoder); channel.pipeline().addBefore(MINECRAFT_ENCODER, COMPRESSION_ENCODER, encoder); + var packetLimiterConfig = server.getConfiguration().getPacketLimiterConfig(); + if (minecraftDecoder.getDirection() == ProtocolUtils.Direction.SERVERBOUND + && packetLimiterConfig.interval() > 0 + && packetLimiterConfig.bytesAfterDecompression() > 0) { + decoder.setPacketLimiter(new SimpleBytesPerSecondLimiter( + -1, packetLimiterConfig.bytesAfterDecompression(), packetLimiterConfig.interval())); + } + channel.pipeline().fireUserEventTriggered(VelocityConnectionEvent.COMPRESSION_ENABLED); } } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/network/ServerChannelInitializer.java b/proxy/src/main/java/com/velocitypowered/proxy/network/ServerChannelInitializer.java index fae9113f6..68c990ea6 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/network/ServerChannelInitializer.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/network/ServerChannelInitializer.java @@ -80,7 +80,7 @@ public class ServerChannelInitializer extends ChannelInitializer { int configuredPacketsPerSecond = packetLimiterConfig.pps(); int configuredBytes = packetLimiterConfig.bytes(); - if (configuredInterval > 0 && (configuredBytes > 0 || configuredPacketsPerSecond > 0)) { + if (configuredInterval > 0 && (configuredBytes > 0 || configuredPacketsPerSecond > 0)) { ch.pipeline().get(MinecraftVarintFrameDecoder.class).setPacketLimiter( new SimpleBytesPerSecondLimiter(configuredPacketsPerSecond, configuredBytes, configuredInterval) ); diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/MinecraftCompressDecoder.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/MinecraftCompressDecoder.java index 90b747b05..caf2dc774 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/MinecraftCompressDecoder.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/netty/MinecraftCompressDecoder.java @@ -22,11 +22,14 @@ import static com.velocitypowered.natives.util.MoreByteBufUtils.preferredBuffer; import static com.velocitypowered.proxy.protocol.util.NettyPreconditions.checkFrame; import com.velocitypowered.natives.compression.VelocityCompressor; +import com.velocitypowered.proxy.network.limiter.PacketLimiter; import com.velocitypowered.proxy.protocol.ProtocolUtils; +import com.velocitypowered.proxy.util.except.QuietDecoderException; import io.netty.buffer.ByteBuf; import io.netty.channel.ChannelHandlerContext; import io.netty.handler.codec.MessageToMessageDecoder; import java.util.List; +import org.jspecify.annotations.Nullable; /** * Decompresses a Minecraft packet. @@ -44,11 +47,12 @@ public class MinecraftCompressDecoder extends MessageToMessageDecoder { Boolean.getBoolean("velocity.increased-compression-cap") ? HARD_MAXIMUM_UNCOMPRESSED_SIZE : SERVERBOUND_MAXIMUM_UNCOMPRESSED_SIZE; private static final boolean SKIP_COMPRESSION_VALIDATION = Boolean.getBoolean("velocity.skip-uncompressed-packet-size-validation"); - private static final double MAX_COMPRESSION_RATIO = Double.parseDouble(System.getProperty("velocity.max-compression-ratio", "64")); private final ProtocolUtils.Direction direction; private int threshold; private final VelocityCompressor compressor; + @Nullable + private PacketLimiter packetLimiter; /** * Creates a new {@code MinecraftCompressDecoder} with the specified compression {@code threshold}. @@ -73,10 +77,13 @@ public class MinecraftCompressDecoder extends MessageToMessageDecoder { + " threshold %s", actualUncompressedSize, threshold); } // This message is not compressed. + if (packetLimiter != null && !packetLimiter.account(in.readableBytes())) { + throw new QuietDecoderException("Rate limit exceeded while processing packets for %s" + .formatted(ctx.channel().remoteAddress())); + } out.add(in.retain()); return; } - int length = in.readableBytes(); checkFrame(claimedUncompressedSize >= threshold, "Uncompressed size %s is less than" + " threshold %s", claimedUncompressedSize, threshold); @@ -88,10 +95,6 @@ public class MinecraftCompressDecoder extends MessageToMessageDecoder { checkFrame(claimedUncompressedSize <= SERVERBOUND_UNCOMPRESSED_CAP, "Uncompressed size %s exceeds hard threshold of %s", claimedUncompressedSize, SERVERBOUND_UNCOMPRESSED_CAP); - double maxCompressedAllowed = length * MAX_COMPRESSION_RATIO; - checkFrame(claimedUncompressedSize <= maxCompressedAllowed, - "Uncompressed size %s exceeds ratio threshold of %s for compressed sized %s", claimedUncompressedSize, - maxCompressedAllowed, length); } ByteBuf compatibleIn = ensureCompatible(ctx.alloc(), compressor, in); ByteBuf uncompressed = preferredBuffer(ctx.alloc(), compressor, claimedUncompressedSize); @@ -99,6 +102,10 @@ public class MinecraftCompressDecoder extends MessageToMessageDecoder { compressor.inflate(compatibleIn, uncompressed, claimedUncompressedSize); checkFrame(uncompressed.writerIndex() == claimedUncompressedSize, "Decompressed size %s does not match claimed uncompressed size %s", uncompressed.writerIndex(), claimedUncompressedSize); + if (packetLimiter != null && !packetLimiter.account(claimedUncompressedSize)) { + throw new QuietDecoderException("Rate limit exceeded while processing packets for %s" + .formatted(ctx.channel().remoteAddress())); + } out.add(uncompressed); } catch (Exception e) { uncompressed.release(); @@ -116,4 +123,8 @@ public class MinecraftCompressDecoder extends MessageToMessageDecoder { public void setThreshold(int threshold) { this.threshold = threshold; } + + public void setPacketLimiter(@Nullable PacketLimiter packetLimiter) { + this.packetLimiter = packetLimiter; + } } diff --git a/proxy/src/main/resources/default-velocity.toml b/proxy/src/main/resources/default-velocity.toml index 6aa5ceaa8..0eae2734a 100644 --- a/proxy/src/main/resources/default-velocity.toml +++ b/proxy/src/main/resources/default-velocity.toml @@ -1,5 +1,5 @@ # Config version. Do not change this -config-version = "2.7" +config-version = "2.8" # What port should the proxy be bound to? By default, we'll bind to all addresses on port 25565. bind = "0.0.0.0:25565" @@ -75,9 +75,17 @@ sample-players-in-ping = false enable-player-address-logging = true [packet-limiter] +# Size of the moving time window in seconds used to calculate average rates. +# A larger window tolerates short bursts while still enforcing the configured limits over time. interval = 7 -packets-per-second = 500 +# Maximum average number of packets per second a client may send. -1 disables this check. +packets-per-second = -1 +# Maximum average number of compressed (on-wire) bytes per second a client may send. -1 disables this check. bytes-per-second = -1 +# Maximum average number of decompressed bytes per second a client may send. +# Protects against compression bomb attacks where small packets expand to excessive sizes after decompression. +# -1 disables this check. +decompressed-bytes-per-second = 5242880 [servers] # Configure your servers here. Each key represents the server's name, and the value