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