From 6166110262af91b929ec10209f7b7f2d9b3fbfca Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 17 Aug 2026 19:01:07 -0600 Subject: [PATCH 01/25] Bound vote listener executor queues --- .../votifier/net/VoteReceiver.java | 160 +++++++++++++----- 1 file changed, 113 insertions(+), 47 deletions(-) diff --git a/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteReceiver.java b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteReceiver.java index b4aee65..b9230c8 100644 --- a/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteReceiver.java +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteReceiver.java @@ -60,6 +60,7 @@ */ package com.vexsoftware.votifier.net; +import java.io.IOException; import java.net.InetSocketAddress; import java.net.ServerSocket; import java.net.Socket; @@ -72,9 +73,11 @@ import java.util.Base64; import java.util.Map; import java.util.Set; +import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import javax.crypto.Cipher; @@ -138,7 +141,7 @@ public void shutdown() { shutdownExecutor(connectionExecutor, "connection"); shutdownExecutor(forwardExecutor, "forward"); } - + private void shutdownExecutor(ExecutorService executor, String name) { if (executor == null) { return; @@ -162,70 +165,116 @@ public int getConnectionWorkerCount() { return 4; } + public int getConnectionQueueCapacity() { + return 64; + } + + public long getConnectionQueueTimeoutMillis() { + return 5000L; + } + public int getForwardWorkerCount() { return 1; } + public int getForwardQueueCapacity() { + return 256; + } + @Override public void run() { throttleService = new VoteThrottleService(getThrottleConfig()); voteForwarder = new VoteForwarder(this); - connectionExecutor = Executors.newFixedThreadPool(getConnectionWorkerCount(), new ThreadFactory() { - private int id = 1; + if (isUseTokens() && !isDisableV1()) { + logWarning("TokenSupport is enabled, but legacy Votifier V1 votes are still accepted. " + + "Set DisableV1: true to require token-authenticated V2 votes."); + } - @Override - public Thread newThread(Runnable r) { - Thread thread = new Thread(r, "Votifier-Connection-" + id++); - thread.setDaemon(true); - return thread; - } - }); - - forwardExecutor = Executors.newFixedThreadPool(getForwardWorkerCount(), new ThreadFactory() { - @Override - public Thread newThread(Runnable r) { - Thread thread = new Thread(r, "Votifier-Forwarder"); - thread.setDaemon(true); - return thread; - } - }); + int connectionWorkerCount = getConnectionWorkerCount(); + connectionExecutor = new ThreadPoolExecutor(connectionWorkerCount, connectionWorkerCount, 0L, + TimeUnit.MILLISECONDS, new ArrayBlockingQueue(getConnectionQueueCapacity()), + new ThreadFactory() { + private int id = 1; + + @Override + public Thread newThread(Runnable r) { + Thread thread = new Thread(r, "Votifier-Connection-" + id++); + thread.setDaemon(true); + return thread; + } + }, new ThreadPoolExecutor.AbortPolicy()); + + int forwardWorkerCount = getForwardWorkerCount(); + forwardExecutor = new ThreadPoolExecutor(forwardWorkerCount, forwardWorkerCount, 0L, TimeUnit.MILLISECONDS, + new ArrayBlockingQueue(getForwardQueueCapacity()), new ThreadFactory() { + private int id = 1; + + @Override + public Thread newThread(Runnable r) { + Thread thread = new Thread(r, "Votifier-Forwarder-" + id++); + thread.setDaemon(true); + return thread; + } + }, new ThreadPoolExecutor.CallerRunsPolicy()); final VoteConnectionHandler handler = new VoteConnectionHandler(this, throttleService); while (running) { try { final Socket socket = server.accept(); + final long acceptedAtNanos = System.nanoTime(); - connectionExecutor.submit(new Runnable() { - @Override - public void run() { - try { - Vote vote = handler.handle(socket); - if (vote != null) { - callEvent(vote); - - final Vote forwardVote = vote; - forwardExecutor.submit(new Runnable() { - @Override - public void run() { - try { - voteForwarder.forwardVote(forwardVote); - } catch (Exception ex) { - logWarning("Error forwarding vote: " - + (ex.getLocalizedMessage() == null ? ex.getClass().getSimpleName() - : ex.getLocalizedMessage())); + try { + socket.setSoTimeout(5000); + } catch (SocketException ex) { + closeConnection(socket); + throw ex; + } + + try { + connectionExecutor.execute(new Runnable() { + @Override + public void run() { + long queueTimeoutMillis = getConnectionQueueTimeoutMillis(); + if (queueTimeoutMillis > 0 && System.nanoTime() - acceptedAtNanos > TimeUnit.MILLISECONDS + .toNanos(queueTimeoutMillis)) { + closeConnection(socket); + debug("Closed stale vote connection after it exceeded the connection queue deadline."); + return; + } + + try { + Vote vote = handler.handle(socket); + if (vote != null) { + callEvent(vote); + + final Vote forwardVote = vote; + forwardExecutor.execute(new Runnable() { + @Override + public void run() { + try { + voteForwarder.forwardVote(forwardVote); + } catch (Exception ex) { + logWarning("Error forwarding vote: " + + (ex.getLocalizedMessage() == null + ? ex.getClass().getSimpleName() + : ex.getLocalizedMessage())); + } } - } - }); + }); + } + } catch (Exception ex) { + logWarning("Error processing vote connection: " + + (ex.getLocalizedMessage() == null ? ex.getClass().getSimpleName() + : ex.getLocalizedMessage())); } - } catch (Exception ex) { - logWarning("Error processing vote connection: " - + (ex.getLocalizedMessage() == null ? ex.getClass().getSimpleName() - : ex.getLocalizedMessage())); } - } - }); + }); + } catch (RejectedExecutionException ex) { + closeConnection(socket); + debug("Rejected vote connection because the connection queue is full."); + } } catch (SocketException ex) { if (running) { logWarning("Connection error while accepting vote socket: " + ex.getLocalizedMessage()); @@ -240,8 +289,25 @@ public void run() { } } + private void closeConnection(Socket socket) { + try { + socket.close(); + } catch (IOException ex) { + debug(ex); + } + } + public abstract boolean isUseTokens(); + /** + * Returns whether legacy Votifier V1 packets must be rejected. + * + * @return true when only V2 votes should be accepted + */ + public boolean isDisableV1() { + return false; + } + public abstract ThrottleConfig getThrottleConfig(); public abstract void logWarning(String warn); @@ -281,4 +347,4 @@ public PublicKey getPublicKey(ForwardServer forwardServer) throws Exception { public String getChallenge() { return com.vexsoftware.votifier.crypto.TokenUtil.newToken(); } -} \ No newline at end of file +} From ba94b1d191587968b9c1428b082bf7cd75ea3cc9 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 17 Aug 2026 19:01:47 -0600 Subject: [PATCH 02/25] Add optional V1 rejection mode --- .../votifier/net/VoteConnectionHandler.java | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteConnectionHandler.java b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteConnectionHandler.java index 118cb41..c9b6b94 100644 --- a/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteConnectionHandler.java +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteConnectionHandler.java @@ -68,7 +68,8 @@ public Vote handle(Socket socket) { } String realIp = null; - ProxyHeaderProcessor.ProxyHeaderResult proxyResult = proxyHeaderProcessor.process(in, writer, receiver); + ProxyHeaderProcessor.ProxyHeaderResult proxyResult = proxyHeaderProcessor.process(in, writer, receiver, + accepted); realIp = proxyResult.getRealIp(); realIpKnown = realIp != null && !realIp.isEmpty(); @@ -85,6 +86,10 @@ public Vote handle(Socket socket) { VoteProtocolVersion version = voteParser.detectVersion(in); receiver.debug("Detected vote protocol version: " + version); + if (receiver.isDisableV1() && version == VoteProtocolVersion.V1) { + throw new VoteAuthenticationException("Votifier V1 votes are disabled by configuration"); + } + if (version == VoteProtocolVersion.V1 && in.available() < 256) { throttleService.fail(throttleKey, tunnelMode, realIpKnown); throttleService.logWarning(receiver, "shortv1|" + throttleKey, @@ -148,7 +153,7 @@ public Vote handle(Socket socket) { "Decryption failed: Invalid V1 vote block / public key mismatch from " + remoteIp); } catch (SocketTimeoutException ex) { throttleService.logWarning(receiver, "timeout|" + remoteIp, - "Connection timeout while waiting for vote payload from " + remoteIp + " - " + ex.getMessage()); + "Connection timeout while reading vote data from " + remoteIp + " - " + ex.getMessage()); } catch (SocketException ex) { throttleService.logWarning(receiver, "socket|" + remoteIp, "Connection error: Protocol error from " + remoteIp + " - " + ex.getLocalizedMessage()); @@ -162,13 +167,11 @@ public Vote handle(Socket socket) { private void sendHandshakeIfNeeded(PushbackInputStream in, BufferedWriter writer, String challenge) throws Exception { - String message = receiver.isUseTokens() ? "VOTIFIER 2" : "VOTIFIER 1"; - if (receiver.isUseTokens()) { - message += " " + challenge; - } + boolean useV2Handshake = receiver.isUseTokens() || receiver.isDisableV1(); + String message = useV2Handshake ? "VOTIFIER 2 " + challenge : "VOTIFIER 1"; int available = in.available(); - if (available >= 256) { + if (available >= 256 && !receiver.isDisableV1()) { receiver.debug("Detected V1 vote payload before handshake (available bytes: " + available + "), skipping handshake."); return; From 33bdd197074fd9047423ef798ba00a7f453e425d Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 17 Aug 2026 19:02:50 -0600 Subject: [PATCH 03/25] Bound PROXY and CONNECT header parsing --- .../votifier/net/ProxyHeaderProcessor.java | 258 +++++++++++++----- 1 file changed, 190 insertions(+), 68 deletions(-) diff --git a/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/ProxyHeaderProcessor.java b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/ProxyHeaderProcessor.java index 23cf855..6beed38 100644 --- a/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/ProxyHeaderProcessor.java +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/ProxyHeaderProcessor.java @@ -6,16 +6,29 @@ */ package com.vexsoftware.votifier.net; -import java.io.ByteArrayOutputStream; import java.io.BufferedWriter; +import java.io.ByteArrayOutputStream; import java.io.PushbackInputStream; +import java.net.Socket; +import java.net.SocketException; +import java.net.SocketTimeoutException; import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; import lombok.Getter; import lombok.Setter; public class ProxyHeaderProcessor { + private static final int MAX_PROXY_V1_HEADER_BYTES = 107; + private static final int MAX_CONNECT_LINE_BYTES = 8192; + private static final int MAX_CONNECT_HEADERS = 100; + private static final int MAX_CONNECT_HEADER_BYTES = 32768; + private static final int HEADER_READ_TIMEOUT_MILLIS = 5000; + private static final int DISCARD_BUFFER_BYTES = 1024; + + private static final byte[] PROXY_V1_PREFIX = "PROXY".getBytes(StandardCharsets.US_ASCII); + private static final byte[] CONNECT_PREFIX = "CONNECT".getBytes(StandardCharsets.US_ASCII); private static final byte[] PROXY_V2_SIGNATURE = new byte[] { 0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A }; @@ -27,109 +40,218 @@ public static class ProxyHeaderResult { public ProxyHeaderResult process(PushbackInputStream in, BufferedWriter writer, VoteReceiver receiver) throws Exception { - ProxyHeaderResult result = new ProxyHeaderResult(); + return process(in, writer, receiver, null); + } - byte[] headerPeek = new byte[32]; - int bytesRead = in.read(headerPeek); - if (bytesRead <= 0) { - return result; - } + public ProxyHeaderResult process(PushbackInputStream in, BufferedWriter writer, VoteReceiver receiver, Socket socket) + throws Exception { + int previousTimeout = socket == null ? 0 : socket.getSoTimeout(); + long deadlineNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(HEADER_READ_TIMEOUT_MILLIS); - String headerString = new String(headerPeek, 0, bytesRead, StandardCharsets.US_ASCII); + try { + return processWithDeadline(in, writer, receiver, socket, deadlineNanos); + } finally { + if (socket != null && !socket.isClosed()) { + try { + socket.setSoTimeout(previousTimeout); + } catch (SocketException ex) { + receiver.debug(ex); + } + } + } + } - if (headerString.startsWith("PROXY") && !headerString.contains("CONNECT")) { - in.unread(headerPeek, 0, bytesRead); + private ProxyHeaderResult processWithDeadline(PushbackInputStream in, BufferedWriter writer, VoteReceiver receiver, + Socket socket, long deadlineNanos) throws Exception { + ProxyHeaderResult result = new ProxyHeaderResult(); + byte[] prefix = new byte[16]; + int bytesRead = readPrefix(in, prefix, 1, socket, deadlineNanos); + if (bytesRead == 0) { + return result; + } - String proxyHeader = readLine(in); - receiver.debug("Discarded PROXY (v1) header: " + proxyHeader); + if (prefix[0] == PROXY_V1_PREFIX[0]) { + bytesRead = readPrefix(in, prefix, PROXY_V1_PREFIX.length, bytesRead, socket, deadlineNanos); + if (bytesRead == PROXY_V1_PREFIX.length && startsWith(prefix, bytesRead, PROXY_V1_PREFIX)) { + in.unread(prefix, 0, bytesRead); + String proxyHeader = readLine(in, socket, deadlineNanos, MAX_PROXY_V1_HEADER_BYTES, null, + "PROXY protocol v1 header exceeds " + MAX_PROXY_V1_HEADER_BYTES + " bytes"); + receiver.debug("Discarded PROXY (v1) header: " + proxyHeader); - String[] parts = proxyHeader.split("\\s+"); - if (parts.length >= 3) { - String srcIp = parts[2].trim(); - if (!srcIp.isEmpty()) { - result.setRealIp(srcIp); + String[] parts = proxyHeader.split("\\s+"); + if (parts.length >= 3) { + String srcIp = parts[2].trim(); + if (!srcIp.isEmpty()) { + result.setRealIp(srcIp); + } } + return result; } - return result; } - if (bytesRead >= 16 && isProxyV2(headerPeek)) { - int addrLength = ((headerPeek[14] & 0xFF) << 8) | (headerPeek[15] & 0xFF); - int totalLength = 16 + addrLength; - int remaining = totalLength - bytesRead; - - if (remaining > 0) { - byte[] discard = new byte[remaining]; - int read = 0; - while (read < remaining) { - int r = in.read(discard, read, remaining - read); - if (r == -1) { + if (prefix[0] == CONNECT_PREFIX[0]) { + bytesRead = readPrefix(in, prefix, CONNECT_PREFIX.length, bytesRead, socket, deadlineNanos); + if (bytesRead == CONNECT_PREFIX.length && startsWith(prefix, bytesRead, CONNECT_PREFIX)) { + in.unread(prefix, 0, bytesRead); + int[] totalHeaderBytes = new int[1]; + String connectLine = readLine(in, socket, deadlineNanos, MAX_CONNECT_LINE_BYTES, totalHeaderBytes, + "HTTP CONNECT header line exceeds " + MAX_CONNECT_LINE_BYTES + " bytes"); + receiver.debug("Received CONNECT request: " + connectLine); + + int headerCount = 0; + while (true) { + String line = readLine(in, socket, deadlineNanos, MAX_CONNECT_LINE_BYTES, totalHeaderBytes, + "HTTP CONNECT header line exceeds " + MAX_CONNECT_LINE_BYTES + " bytes"); + if (line.isEmpty()) { break; } - read += r; + if (++headerCount > MAX_CONNECT_HEADERS) { + throw new InvalidVoteException("Too many HTTP CONNECT headers"); + } + receiver.debug("Discarding header: " + line); } - if (read != remaining) { - throw new Exception("Incomplete PROXY protocol v2 header"); - } + writer.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + writer.flush(); + return result; } + } - receiver.debug("Discarded PROXY protocol v2 header (" + totalLength + " bytes)"); - return result; + if ((prefix[0] & 0xFF) == (PROXY_V2_SIGNATURE[0] & 0xFF)) { + bytesRead = readPrefix(in, prefix, 16, bytesRead, socket, deadlineNanos); + if (bytesRead < 16 && matchesPrefix(prefix, bytesRead, PROXY_V2_SIGNATURE)) { + throw new InvalidVoteException("Incomplete PROXY protocol v2 header"); + } + if (bytesRead == 16 && startsWith(prefix, bytesRead, PROXY_V2_SIGNATURE)) { + int addressLength = ((prefix[14] & 0xFF) << 8) | (prefix[15] & 0xFF); + discardFully(in, addressLength, socket, deadlineNanos); + receiver.debug("Discarded PROXY protocol v2 header (" + (16 + addressLength) + " bytes)"); + return result; + } } - if (headerString.startsWith("CONNECT")) { - in.unread(headerPeek, 0, bytesRead); + in.unread(prefix, 0, bytesRead); + return result; + } - String connectLine = readLine(in); - receiver.debug("Received CONNECT request: " + connectLine); + private int readPrefix(PushbackInputStream in, byte[] prefix, int targetLength, Socket socket, long deadlineNanos) + throws Exception { + return readPrefix(in, prefix, targetLength, 0, socket, deadlineNanos); + } - String line; - while (!(line = readLine(in)).isEmpty()) { - receiver.debug("Discarding header: " + line); + private int readPrefix(PushbackInputStream in, byte[] prefix, int targetLength, int offset, Socket socket, + long deadlineNanos) throws Exception { + int read = offset; + while (read < targetLength) { + int value = readByteWithDeadline(in, socket, deadlineNanos); + if (value == -1) { + break; } - - writer.write("HTTP/1.1 200 Connection Established\r\n\r\n"); - writer.flush(); - return result; + prefix[read++] = (byte) value; } - - in.unread(headerPeek, 0, bytesRead); - return result; + return read; } - private boolean isProxyV2(byte[] header) { - for (int i = 0; i < PROXY_V2_SIGNATURE.length; i++) { - if (header[i] != PROXY_V2_SIGNATURE[i]) { - return false; + private void discardFully(PushbackInputStream in, int length, Socket socket, long deadlineNanos) throws Exception { + byte[] discard = new byte[Math.min(DISCARD_BUFFER_BYTES, Math.max(1, length))]; + int remaining = length; + while (remaining > 0) { + int read = readWithDeadline(in, discard, 0, Math.min(discard.length, remaining), socket, deadlineNanos); + if (read == -1) { + throw new InvalidVoteException("Incomplete PROXY protocol v2 header"); } + remaining -= read; } - return true; } - private String readLine(PushbackInputStream in) throws Exception { - ByteArrayOutputStream lineBuffer = new ByteArrayOutputStream(); - int b; - boolean seenCR = false; + private String readLine(PushbackInputStream in, Socket socket, long deadlineNanos, int maxLineBytes, + int[] totalBytes, String overflowMessage) throws Exception { + ByteArrayOutputStream lineBuffer = new ByteArrayOutputStream(Math.min(128, maxLineBytes)); + int lineBytes = 0; + + while (true) { + int value = readByteWithDeadline(in, socket, deadlineNanos); + if (value == -1) { + throw new InvalidVoteException("Unexpected end of stream while reading proxy/tunnel headers"); + } - while ((b = in.read()) != -1) { - if (b == '\r') { - seenCR = true; - continue; + lineBytes++; + if (lineBytes > maxLineBytes) { + throw new InvalidVoteException(overflowMessage); } + incrementTotalBytes(totalBytes); - if (b == '\n') { + if (value == '\n') { break; } - if (seenCR) { - in.unread(b); + if (value == '\r') { + int next = readByteWithDeadline(in, socket, deadlineNanos); + if (next == -1) { + throw new InvalidVoteException("Unexpected end of stream while reading proxy/tunnel headers"); + } + + lineBytes++; + if (lineBytes > maxLineBytes) { + throw new InvalidVoteException(overflowMessage); + } + incrementTotalBytes(totalBytes); + + if (next != '\n') { + throw new InvalidVoteException("Invalid line ending in proxy/tunnel headers"); + } break; } - lineBuffer.write(b); + lineBuffer.write(value); + } + + return lineBuffer.toString(StandardCharsets.US_ASCII.name()).trim(); + } + + private void incrementTotalBytes(int[] totalBytes) throws InvalidVoteException { + if (totalBytes != null && ++totalBytes[0] > MAX_CONNECT_HEADER_BYTES) { + throw new InvalidVoteException("HTTP CONNECT headers exceed " + MAX_CONNECT_HEADER_BYTES + " bytes"); } + } - return lineBuffer.toString("ASCII").trim(); + private int readByteWithDeadline(PushbackInputStream in, Socket socket, long deadlineNanos) throws Exception { + setRemainingTimeout(socket, deadlineNanos); + return in.read(); + } + + private int readWithDeadline(PushbackInputStream in, byte[] buffer, int offset, int length, Socket socket, + long deadlineNanos) throws Exception { + setRemainingTimeout(socket, deadlineNanos); + return in.read(buffer, offset, length); + } + + private void setRemainingTimeout(Socket socket, long deadlineNanos) throws Exception { + long remainingNanos = deadlineNanos - System.nanoTime(); + if (remainingNanos <= 0) { + throw new SocketTimeoutException("Timed out reading proxy/tunnel headers"); + } + + if (socket != null) { + long timeoutMillis = TimeUnit.NANOSECONDS.toMillis(remainingNanos); + if (TimeUnit.MILLISECONDS.toNanos(timeoutMillis) < remainingNanos) { + timeoutMillis++; + } + socket.setSoTimeout((int) Math.max(1L, Math.min(Integer.MAX_VALUE, timeoutMillis))); + } + } + + private boolean startsWith(byte[] data, int dataLength, byte[] expected) { + return dataLength >= expected.length && matchesPrefix(data, expected.length, expected); + } + + private boolean matchesPrefix(byte[] data, int dataLength, byte[] expected) { + int length = Math.min(dataLength, expected.length); + for (int i = 0; i < length; i++) { + if (data[i] != expected[i]) { + return false; + } + } + return true; } -} \ No newline at end of file +} From b42b81cd288fb5c427cd4d44787362ac6c292b36 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 17 Aug 2026 19:03:11 -0600 Subject: [PATCH 04/25] Add Bukkit DisableV1 setting --- .../vexsoftware/votifier/config/Config.java | 179 +++++++++--------- 1 file changed, 92 insertions(+), 87 deletions(-) diff --git a/VotifierPlus/src/main/java/com/vexsoftware/votifier/config/Config.java b/VotifierPlus/src/main/java/com/vexsoftware/votifier/config/Config.java index d34c329..d8fc70b 100644 --- a/VotifierPlus/src/main/java/com/vexsoftware/votifier/config/Config.java +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/config/Config.java @@ -1,87 +1,92 @@ -package com.vexsoftware.votifier.config; - -import java.io.File; -import java.util.HashSet; -import java.util.Set; - -import org.bukkit.configuration.ConfigurationSection; - -import com.bencodez.simpleapi.debug.DebugLevel; -import com.bencodez.simpleapi.file.YMLFile; -import com.bencodez.simpleapi.file.annotation.AnnotationHandler; -import com.bencodez.simpleapi.file.annotation.ConfigDataBoolean; -import com.bencodez.simpleapi.file.annotation.ConfigDataInt; -import com.bencodez.simpleapi.file.annotation.ConfigDataKeys; -import com.bencodez.simpleapi.file.annotation.ConfigDataString; -import com.vexsoftware.votifier.VotifierPlus; - -import lombok.Getter; -import lombok.Setter; - -public class Config extends YMLFile { - - public Config(VotifierPlus plugin) { - super(plugin, new File(VotifierPlus.getInstance().getDataFolder(), "config.yml")); - } - - public void loadValues() { - new AnnotationHandler().load(getData(), this); - debug = DebugLevel.getDebug(debugLevelStr); - } - - @Override - public void onFileCreation() { - VotifierPlus.getInstance().saveResource("config.yml", true); - } - - @ConfigDataString(path = "host") - @Getter - @Setter - private String host = "0.0.0.0"; - - @ConfigDataInt(path = "port") - @Getter - @Setter - private int port = 8192; - - @ConfigDataString(path = "DebugLevel", options = { "NONE", "INFO", "EXTRA", "DEV" }) - private String debugLevelStr = "NONE"; - - @Getter - @Setter - private DebugLevel debug = DebugLevel.NONE; - - @ConfigDataKeys(path = "Forwarding") - @Getter - @Setter - private Set servers = new HashSet(); - - @Getter - @Setter - @ConfigDataString(path = "Format.NoPerms") - private String formatNoPerms = "&cYou do not have enough permission!"; - - @Getter - @Setter - @ConfigDataString(path = "Format.NotNumber") - private String formatNotNumber = "&cError on &6%arg%&c, number expected!"; - - @Getter - @Setter - @ConfigDataString(path = "Format.HelpLine") - private String helpLine = "&3&l%Command% - &3%HelpMessage%"; - - @ConfigDataBoolean(path = "DisableUpdateChecking") - @Getter - private boolean disableUpdateChecking = false; - - @ConfigDataBoolean(path = "TokenSupport") - @Getter - @Setter - private boolean tokenSupport = false; - - public ConfigurationSection getForwardingConfiguration(String s) { - return getData().getConfigurationSection("Forwarding." + s); - } - -} +package com.vexsoftware.votifier.config; + +import java.io.File; +import java.util.HashSet; +import java.util.Set; + +import org.bukkit.configuration.ConfigurationSection; + +import com.bencodez.simpleapi.debug.DebugLevel; +import com.bencodez.simpleapi.file.YMLFile; +import com.bencodez.simpleapi.file.annotation.AnnotationHandler; +import com.bencodez.simpleapi.file.annotation.ConfigDataBoolean; +import com.bencodez.simpleapi.file.annotation.ConfigDataInt; +import com.bencodez.simpleapi.file.annotation.ConfigDataKeys; +import com.bencodez.simpleapi.file.annotation.ConfigDataString; +import com.vexsoftware.votifier.VotifierPlus; + +import lombok.Getter; +import lombok.Setter; + +public class Config extends YMLFile { + + public Config(VotifierPlus plugin) { + super(plugin, new File(VotifierPlus.getInstance().getDataFolder(), "config.yml")); + } + + public void loadValues() { + new AnnotationHandler().load(getData(), this); + debug = DebugLevel.getDebug(debugLevelStr); + } + + @Override + public void onFileCreation() { + VotifierPlus.getInstance().saveResource("config.yml", true); + } + + @ConfigDataString(path = "host") + @Getter + @Setter + private String host = "0.0.0.0"; + + @ConfigDataInt(path = "port") + @Getter + @Setter + private int port = 8192; + + @ConfigDataString(path = "DebugLevel", options = { "NONE", "INFO", "EXTRA", "DEV" }) + private String debugLevelStr = "NONE"; + + @Getter + @Setter + private DebugLevel debug = DebugLevel.NONE; + + @ConfigDataKeys(path = "Forwarding") + @Getter + @Setter + private Set servers = new HashSet(); + + @Getter + @Setter + @ConfigDataString(path = "Format.NoPerms") + private String formatNoPerms = "&cYou do not have enough permission!"; + + @Getter + @Setter + @ConfigDataString(path = "Format.NotNumber") + private String formatNotNumber = "&cError on &6%arg%&c, number expected!"; + + @Getter + @Setter + @ConfigDataString(path = "Format.HelpLine") + private String helpLine = "&3&l%Command% - &3%HelpMessage%"; + + @ConfigDataBoolean(path = "DisableUpdateChecking") + @Getter + private boolean disableUpdateChecking = false; + + @ConfigDataBoolean(path = "TokenSupport") + @Getter + @Setter + private boolean tokenSupport = false; + + @ConfigDataBoolean(path = "DisableV1") + @Getter + @Setter + private boolean disableV1 = false; + + public ConfigurationSection getForwardingConfiguration(String s) { + return getData().getConfigurationSection("Forwarding." + s); + } + +} From 20b92b613442de03c61c3fbddcf30e17d4c11c98 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 17 Aug 2026 19:03:27 -0600 Subject: [PATCH 05/25] Add proxy DisableV1 setting --- .../vexsoftware/votifier/bungee/Config.java | 192 +++++++++--------- 1 file changed, 98 insertions(+), 94 deletions(-) diff --git a/VotifierPlus/src/main/java/com/vexsoftware/votifier/bungee/Config.java b/VotifierPlus/src/main/java/com/vexsoftware/votifier/bungee/Config.java index 4531f53..4ffbca6 100644 --- a/VotifierPlus/src/main/java/com/vexsoftware/votifier/bungee/Config.java +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/bungee/Config.java @@ -1,94 +1,98 @@ -package com.vexsoftware.votifier.bungee; - -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.nio.file.Files; -import java.util.Set; - -import lombok.Getter; -import net.md_5.bungee.config.Configuration; -import net.md_5.bungee.config.ConfigurationProvider; -import net.md_5.bungee.config.YamlConfiguration; - -public class Config { - private VotifierPlusBungee bungee; - @Getter - private Configuration data; - - public Config(VotifierPlusBungee bungee) { - this.bungee = bungee; - } - - public void load() { - if (!bungee.getDataFolder().exists()) - bungee.getDataFolder().mkdir(); - - File file = new File(bungee.getDataFolder(), "bungeeconfig.yml"); - - if (!file.exists()) { - try (InputStream in = bungee.getResourceAsStream("bungeeconfig.yml")) { - Files.copy(in, file.toPath()); - } catch (IOException e) { - e.printStackTrace(); - } - } - try { - data = ConfigurationProvider.getProvider(YamlConfiguration.class) - .load(new File(bungee.getDataFolder(), "bungeeconfig.yml")); - } catch (IOException e) { - e.printStackTrace(); - } - } - - public void save() { - try { - ConfigurationProvider.getProvider(YamlConfiguration.class).save(data, - new File(bungee.getDataFolder(), "bungeeconfig.yml")); - } catch (IOException e) { - e.printStackTrace(); - } - } - - public String getHost() { - return getData().getString("host", ""); - } - - public int getPort() { - return getData().getInt("port"); - } - - public boolean getDebug() { - return getData().getBoolean("Debug", false); - } - - public Set getServers() { - return (Set) getData().getSection("Forwarding").getKeys(); - } - - public Configuration getServerData(String s) { - return getData().getSection("Forwarding." + s); - } - - public Set getTokens() { - return (Set) getData().getSection("tokens").getKeys(); - } - - public boolean getTokenSupport() { - return getData().getBoolean("TokenSupport", false); - } - - public String getToken(String key) { - return getData().getString("tokens." + key, null); - } - - public boolean containsTokens() { - return getData().contains("tokens"); - } - - public void setToken(String key, String token) { - getData().set("tokens." + key, token); - save(); - } - -} +package com.vexsoftware.votifier.bungee; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.util.Set; + +import lombok.Getter; +import net.md_5.bungee.config.Configuration; +import net.md_5.bungee.config.ConfigurationProvider; +import net.md_5.bungee.config.YamlConfiguration; + +public class Config { + private VotifierPlusBungee bungee; + @Getter + private Configuration data; + + public Config(VotifierPlusBungee bungee) { + this.bungee = bungee; + } + + public void load() { + if (!bungee.getDataFolder().exists()) + bungee.getDataFolder().mkdir(); + + File file = new File(bungee.getDataFolder(), "bungeeconfig.yml"); + + if (!file.exists()) { + try (InputStream in = bungee.getResourceAsStream("bungeeconfig.yml")) { + Files.copy(in, file.toPath()); + } catch (IOException e) { + e.printStackTrace(); + } + } + try { + data = ConfigurationProvider.getProvider(YamlConfiguration.class) + .load(new File(bungee.getDataFolder(), "bungeeconfig.yml")); + } catch (IOException e) { + e.printStackTrace(); + } + } + + public void save() { + try { + ConfigurationProvider.getProvider(YamlConfiguration.class).save(data, + new File(bungee.getDataFolder(), "bungeeconfig.yml")); + } catch (IOException e) { + e.printStackTrace(); + } + } + + public String getHost() { + return getData().getString("host", ""); + } + + public int getPort() { + return getData().getInt("port"); + } + + public boolean getDebug() { + return getData().getBoolean("Debug", false); + } + + public Set getServers() { + return (Set) getData().getSection("Forwarding").getKeys(); + } + + public Configuration getServerData(String s) { + return getData().getSection("Forwarding." + s); + } + + public Set getTokens() { + return (Set) getData().getSection("tokens").getKeys(); + } + + public boolean getTokenSupport() { + return getData().getBoolean("TokenSupport", false); + } + + public boolean getDisableV1() { + return getData().getBoolean("DisableV1", false); + } + + public String getToken(String key) { + return getData().getString("tokens." + key, null); + } + + public boolean containsTokens() { + return getData().contains("tokens"); + } + + public void setToken(String key, String token) { + getData().set("tokens." + key, token); + save(); + } + +} From 319eb7b4e55aad072c1770bd8f59de213a6b75b9 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 17 Aug 2026 19:03:39 -0600 Subject: [PATCH 06/25] Add Velocity DisableV1 setting --- .../vexsoftware/votifier/velocity/Config.java | 128 +++++++++--------- 1 file changed, 66 insertions(+), 62 deletions(-) diff --git a/VotifierPlus/src/main/java/com/vexsoftware/votifier/velocity/Config.java b/VotifierPlus/src/main/java/com/vexsoftware/votifier/velocity/Config.java index f361713..cb8d7be 100644 --- a/VotifierPlus/src/main/java/com/vexsoftware/votifier/velocity/Config.java +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/velocity/Config.java @@ -1,62 +1,66 @@ -package com.vexsoftware.votifier.velocity; - -import java.io.File; -import java.util.Collection; - -import org.checkerframework.checker.nullness.qual.NonNull; -import org.spongepowered.configurate.ConfigurationNode; -import org.spongepowered.configurate.serialize.SerializationException; - -import com.bencodez.simpleapi.file.velocity.VelocityYMLFile; - -public class Config extends VelocityYMLFile { - - public Config(File file) { - super(file); - } - - public String getHost() { - return getString(getNode("host"), ""); - } - - public int getPort() { - return getInt(getNode("port"), 0); - } - - public boolean getDebug() { - return getBoolean(getNode("Debug"), false); - } - - public @NonNull Collection getServers() { - return getNode("Forwarding").childrenMap().values(); - } - - public ConfigurationNode getServersData(String s) { - return getNode("Forwarding", s); - } - - public @NonNull Collection getTokens() { - return getNode("tokens").childrenMap().values(); - } - - public String getToken(String key) { - return getString(getNode("tokens", key), ""); - } - - public boolean containsTokens() { - return !getNode("tokens").virtual(); - } - - public void setToken(String key, String token) { - try { - getNode("tokens", key).set(token); - } catch (SerializationException e) { - e.printStackTrace(); - } - save(); - } - - public boolean getTokenSupport() { - return getBoolean(getNode("TokenSupport"), false); - } -} +package com.vexsoftware.votifier.velocity; + +import java.io.File; +import java.util.Collection; + +import org.checkerframework.checker.nullness.qual.NonNull; +import org.spongepowered.configurate.ConfigurationNode; +import org.spongepowered.configurate.serialize.SerializationException; + +import com.bencodez.simpleapi.file.velocity.VelocityYMLFile; + +public class Config extends VelocityYMLFile { + + public Config(File file) { + super(file); + } + + public String getHost() { + return getString(getNode("host"), ""); + } + + public int getPort() { + return getInt(getNode("port"), 0); + } + + public boolean getDebug() { + return getBoolean(getNode("Debug"), false); + } + + public @NonNull Collection getServers() { + return getNode("Forwarding").childrenMap().values(); + } + + public ConfigurationNode getServersData(String s) { + return getNode("Forwarding", s); + } + + public @NonNull Collection getTokens() { + return getNode("tokens").childrenMap().values(); + } + + public String getToken(String key) { + return getString(getNode("tokens", key), ""); + } + + public boolean containsTokens() { + return !getNode("tokens").virtual(); + } + + public void setToken(String key, String token) { + try { + getNode("tokens", key).set(token); + } catch (SerializationException e) { + e.printStackTrace(); + } + save(); + } + + public boolean getTokenSupport() { + return getBoolean(getNode("TokenSupport"), false); + } + + public boolean getDisableV1() { + return getBoolean(getNode("DisableV1"), false); + } +} From 95df993d99801cdbdd08bf11431e16a82efb56db Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 17 Aug 2026 19:04:17 -0600 Subject: [PATCH 07/25] Document V1 compatibility control --- VotifierPlus/src/main/resources/config.yml | 255 +++++++++++---------- 1 file changed, 129 insertions(+), 126 deletions(-) diff --git a/VotifierPlus/src/main/resources/config.yml b/VotifierPlus/src/main/resources/config.yml index 7db19fe..f726bc5 100644 --- a/VotifierPlus/src/main/resources/config.yml +++ b/VotifierPlus/src/main/resources/config.yml @@ -1,126 +1,129 @@ -# Debug levels: -# NONE -# INFO -# EXTRA -DebugLevel: NONE -# The host VotifierPlus will listen on -host: 0.0.0.0 -# The port VotifierPlus will listen on -port: 8192 -# This is still new to VotifierPlus, so it's disabled by default. -TokenSupport: false - -# ----------------------------------------------------------------------------- -# Connection throttling & spam reduction for Votifier -# -# Purpose: -# - Reduce console spam from random scanners / port probes -# - Prevent CPU waste from repeated invalid votes (bad padding, short payloads) -# - Work safely behind tunnels (playit.gg) without blocking legit votes -# -# Notes: -# - All time values use ParsedDuration (examples: 30s, 2m, 10m, 1h) -# - Per-client bans ONLY apply when a real client IP is known -# (e.g. via PROXY protocol v1). If behind playit without PROXY, -# only tunnel-level throttling is used. -# ----------------------------------------------------------------------------- -ConnectionThrottle: - - # Master switch - Enabled: false - - # --------------------------------------------------------------------------- - # Tunnel detection - # - # If the remote socket IP matches one of these, the connection is treated as - # "tunnel mode" (e.g. playit.gg). - # - # In tunnel mode: - # - Lower failure thresholds are used - # - Longer throttle durations are applied - # - # IMPORTANT: - # - Do NOT add your own backend/proxy IPs here - # - Only add tunnel / egress IPs (playit, cloudflared, etc.) - # --------------------------------------------------------------------------- - TunnelRemoteIps: - - "127.0.0.1" # playit.gg egress (example) - # - "x.x.x.x" # add more if needed - - # --------------------------------------------------------------------------- - # Sliding failure window - # - # If this many failures occur within the window, hard throttling starts. - # - # Failures counted include: - # - Invalid V1 block size - # - RSA bad padding / key mismatch - # - Malformed JSON (V2) - # - Invalid token / signature - # --------------------------------------------------------------------------- - - # How long to track failures before resetting the counter - Window: "2m" - - # Failures within the window before throttling (normal / non-tunnel) - Failures: 20 - - # How long to block further connections once throttled - ThrottleFor: "5m" - - # --------------------------------------------------------------------------- - # Tunnel-mode overrides (playit, etc.) - # - # These are intentionally more aggressive because scanners all share - # the same tunnel IP. - # --------------------------------------------------------------------------- - - # Failures before throttling when in tunnel mode - TunnelFailures: 8 - - # Throttle duration when in tunnel mode - TunnelThrottleFor: "10m" - - # --------------------------------------------------------------------------- - # Per-client bans (ONLY when real client IP is known) - # - # Requires: - # - PROXY protocol v1 providing the real source IP - # - # If enabled and the same real IP repeatedly fails validation, - # that IP will be temporarily banned. - # - # If real IP is NOT known (typical playit setup), - # this section is ignored automatically. - # --------------------------------------------------------------------------- - PerClientBan: - - # Enable per-client banning - Enabled: true - - # Failures within the window before banning a real client IP - Failures: 6 - - # How long the real client IP is banned - BanFor: "15m" - - # --------------------------------------------------------------------------- - # Log rate limiting - # - # Prevents console spam by allowing only ONE warning per key - # (IP + error type) per window. - # - # Additional messages are suppressed and summarized. - # --------------------------------------------------------------------------- - LogWindow: "60s" - -# If your using VotingPlugin you don't need this -# Doesn't support tokens yet -Forwarding: - server1: - Enabled: false - Host: '' - Port: 8193 - Key: '' - # If token is set a token will be used instead of the key - Token: '' \ No newline at end of file +# Debug levels: +# NONE +# INFO +# EXTRA +DebugLevel: NONE +# The host VotifierPlus will listen on +host: 0.0.0.0 +# The port VotifierPlus will listen on +port: 8192 +# Enables Votifier v2 token/HMAC support while retaining V1 compatibility. +TokenSupport: false +# Rejects all legacy Votifier v1 RSA packets and forces a V2 handshake. +# Keep this false if any configured voting site only supports V1. +DisableV1: false + +# ----------------------------------------------------------------------------- +# Connection throttling & spam reduction for Votifier +# +# Purpose: +# - Reduce console spam from random scanners / port probes +# - Prevent CPU waste from repeated invalid votes (bad padding, short payloads) +# - Work safely behind tunnels (playit.gg) without blocking legit votes +# +# Notes: +# - All time values use ParsedDuration (examples: 30s, 2m, 10m, 1h) +# - Per-client bans ONLY apply when a real client IP is known +# (e.g. via PROXY protocol v1). If behind playit without PROXY, +# only tunnel-level throttling is used. +# ----------------------------------------------------------------------------- +ConnectionThrottle: + + # Master switch + Enabled: false + + # --------------------------------------------------------------------------- + # Tunnel detection + # + # If the remote socket IP matches one of these, the connection is treated as + # "tunnel mode" (e.g. playit.gg). + # + # In tunnel mode: + # - Lower failure thresholds are used + # - Longer throttle durations are applied + # + # IMPORTANT: + # - Do NOT add your own backend/proxy IPs here + # - Only add tunnel / egress IPs (playit, cloudflared, etc.) + # --------------------------------------------------------------------------- + TunnelRemoteIps: + - "127.0.0.1" # playit.gg egress (example) + # - "x.x.x.x" # add more if needed + + # --------------------------------------------------------------------------- + # Sliding failure window + # + # If this many failures occur within the window, hard throttling starts. + # + # Failures counted include: + # - Invalid V1 block size + # - RSA bad padding / key mismatch + # - Malformed JSON (V2) + # - Invalid token / signature + # --------------------------------------------------------------------------- + + # How long to track failures before resetting the counter + Window: "2m" + + # Failures within the window before throttling (normal / non-tunnel) + Failures: 20 + + # How long to block further connections once throttled + ThrottleFor: "5m" + + # --------------------------------------------------------------------------- + # Tunnel-mode overrides (playit, etc.) + # + # These are intentionally more aggressive because scanners all share + # the same tunnel IP. + # --------------------------------------------------------------------------- + + # Failures before throttling when in tunnel mode + TunnelFailures: 8 + + # Throttle duration when in tunnel mode + TunnelThrottleFor: "10m" + + # --------------------------------------------------------------------------- + # Per-client bans (ONLY when real client IP is known) + # + # Requires: + # - PROXY protocol v1 providing the real source IP + # + # If enabled and the same real IP repeatedly fails validation, + # that IP will be temporarily banned. + # + # If real IP is NOT known (typical playit setup), + # this section is ignored automatically. + # --------------------------------------------------------------------------- + PerClientBan: + + # Enable per-client banning + Enabled: true + + # Failures within the window before banning a real client IP + Failures: 6 + + # How long the real client IP is banned + BanFor: "15m" + + # --------------------------------------------------------------------------- + # Log rate limiting + # + # Prevents console spam by allowing only ONE warning per key + # (IP + error type) per window. + # + # Additional messages are suppressed and summarized. + # --------------------------------------------------------------------------- + LogWindow: "60s" + +# If your using VotingPlugin you don't need this +# Doesn't support tokens yet +Forwarding: + server1: + Enabled: false + Host: '' + Port: 8193 + Key: '' + # If token is set a token will be used instead of the key + Token: '' From f4d31dad8c75a8b61e0fe3b9c753dde116b3f96d Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 17 Aug 2026 19:04:39 -0600 Subject: [PATCH 08/25] Document proxy V1 compatibility control --- .../src/main/resources/bungeeconfig.yml | 249 +++++++++--------- 1 file changed, 126 insertions(+), 123 deletions(-) diff --git a/VotifierPlus/src/main/resources/bungeeconfig.yml b/VotifierPlus/src/main/resources/bungeeconfig.yml index c15a2fc..468c091 100644 --- a/VotifierPlus/src/main/resources/bungeeconfig.yml +++ b/VotifierPlus/src/main/resources/bungeeconfig.yml @@ -1,123 +1,126 @@ -Debug: false -# The host VotifierPlus will listen on -host: 0.0.0.0 -# The port VotifierPlus will listen on -port: 8192 -# This is still new to VotifierPlus, so it's disabled by default. -TokenSupport: false - -# ----------------------------------------------------------------------------- -# Connection throttling & spam reduction for Votifier -# -# Purpose: -# - Reduce console spam from random scanners / port probes -# - Prevent CPU waste from repeated invalid votes (bad padding, short payloads) -# - Work safely behind tunnels (playit.gg) without blocking legit votes -# -# Notes: -# - All time values use ParsedDuration (examples: 30s, 2m, 10m, 1h) -# - Per-client bans ONLY apply when a real client IP is known -# (e.g. via PROXY protocol v1). If behind playit without PROXY, -# only tunnel-level throttling is used. -# ----------------------------------------------------------------------------- -ConnectionThrottle: - - # Master switch - Enabled: false - - # --------------------------------------------------------------------------- - # Tunnel detection - # - # If the remote socket IP matches one of these, the connection is treated as - # "tunnel mode" (e.g. playit.gg). - # - # In tunnel mode: - # - Lower failure thresholds are used - # - Longer throttle durations are applied - # - # IMPORTANT: - # - Do NOT add your own backend/proxy IPs here - # - Only add tunnel / egress IPs (playit, cloudflared, etc.) - # --------------------------------------------------------------------------- - TunnelRemoteIps: - - "127.0.0.1" # playit.gg egress (example) - # - "x.x.x.x" # add more if needed - - # --------------------------------------------------------------------------- - # Sliding failure window - # - # If this many failures occur within the window, hard throttling starts. - # - # Failures counted include: - # - Invalid V1 block size - # - RSA bad padding / key mismatch - # - Malformed JSON (V2) - # - Invalid token / signature - # --------------------------------------------------------------------------- - - # How long to track failures before resetting the counter - Window: "2m" - - # Failures within the window before throttling (normal / non-tunnel) - Failures: 20 - - # How long to block further connections once throttled - ThrottleFor: "5m" - - # --------------------------------------------------------------------------- - # Tunnel-mode overrides (playit, etc.) - # - # These are intentionally more aggressive because scanners all share - # the same tunnel IP. - # --------------------------------------------------------------------------- - - # Failures before throttling when in tunnel mode - TunnelFailures: 8 - - # Throttle duration when in tunnel mode - TunnelThrottleFor: "10m" - - # --------------------------------------------------------------------------- - # Per-client bans (ONLY when real client IP is known) - # - # Requires: - # - PROXY protocol v1 providing the real source IP - # - # If enabled and the same real IP repeatedly fails validation, - # that IP will be temporarily banned. - # - # If real IP is NOT known (typical playit setup), - # this section is ignored automatically. - # --------------------------------------------------------------------------- - PerClientBan: - - # Enable per-client banning - Enabled: true - - # Failures within the window before banning a real client IP - Failures: 6 - - # How long the real client IP is banned - BanFor: "15m" - - # --------------------------------------------------------------------------- - # Log rate limiting - # - # Prevents console spam by allowing only ONE warning per key - # (IP + error type) per window. - # - # Additional messages are suppressed and summarized. - # --------------------------------------------------------------------------- - LogWindow: "60s" - - -# If your using VotingPlugin you don't need this -# Doesn't support tokens yet -Forwarding: - server1: - Enabled: false - Host: '' - Port: '' - Key: '' - # If token is set a token will be used instead of the key - Token: '' \ No newline at end of file +Debug: false +# The host VotifierPlus will listen on +host: 0.0.0.0 +# The port VotifierPlus will listen on +port: 8192 +# Enables Votifier v2 token/HMAC support while retaining V1 compatibility. +TokenSupport: false +# Rejects all legacy Votifier v1 RSA packets and forces a V2 handshake. +# Keep this false if any configured voting site only supports V1. +DisableV1: false + +# ----------------------------------------------------------------------------- +# Connection throttling & spam reduction for Votifier +# +# Purpose: +# - Reduce console spam from random scanners / port probes +# - Prevent CPU waste from repeated invalid votes (bad padding, short payloads) +# - Work safely behind tunnels (playit.gg) without blocking legit votes +# +# Notes: +# - All time values use ParsedDuration (examples: 30s, 2m, 10m, 1h) +# - Per-client bans ONLY apply when a real client IP is known +# (e.g. via PROXY protocol v1). If behind playit without PROXY, +# only tunnel-level throttling is used. +# ----------------------------------------------------------------------------- +ConnectionThrottle: + + # Master switch + Enabled: false + + # --------------------------------------------------------------------------- + # Tunnel detection + # + # If the remote socket IP matches one of these, the connection is treated as + # "tunnel mode" (e.g. playit.gg). + # + # In tunnel mode: + # - Lower failure thresholds are used + # - Longer throttle durations are applied + # + # IMPORTANT: + # - Do NOT add your own backend/proxy IPs here + # - Only add tunnel / egress IPs (playit, cloudflared, etc.) + # --------------------------------------------------------------------------- + TunnelRemoteIps: + - "127.0.0.1" # playit.gg egress (example) + # - "x.x.x.x" # add more if needed + + # --------------------------------------------------------------------------- + # Sliding failure window + # + # If this many failures occur within the window, hard throttling starts. + # + # Failures counted include: + # - Invalid V1 block size + # - RSA bad padding / key mismatch + # - Malformed JSON (V2) + # - Invalid token / signature + # --------------------------------------------------------------------------- + + # How long to track failures before resetting the counter + Window: "2m" + + # Failures within the window before throttling (normal / non-tunnel) + Failures: 20 + + # How long to block further connections once throttled + ThrottleFor: "5m" + + # --------------------------------------------------------------------------- + # Tunnel-mode overrides (playit, etc.) + # + # These are intentionally more aggressive because scanners all share + # the same tunnel IP. + # --------------------------------------------------------------------------- + + # Failures before throttling when in tunnel mode + TunnelFailures: 8 + + # Throttle duration when in tunnel mode + TunnelThrottleFor: "10m" + + # --------------------------------------------------------------------------- + # Per-client bans (ONLY when real client IP is known) + # + # Requires: + # - PROXY protocol v1 providing the real source IP + # + # If enabled and the same real IP repeatedly fails validation, + # that IP will be temporarily banned. + # + # If real IP is NOT known (typical playit setup), + # this section is ignored automatically. + # --------------------------------------------------------------------------- + PerClientBan: + + # Enable per-client banning + Enabled: true + + # Failures within the window before banning a real client IP + Failures: 6 + + # How long the real client IP is banned + BanFor: "15m" + + # --------------------------------------------------------------------------- + # Log rate limiting + # + # Prevents console spam by allowing only ONE warning per key + # (IP + error type) per window. + # + # Additional messages are suppressed and summarized. + # --------------------------------------------------------------------------- + LogWindow: "60s" + + +# If your using VotingPlugin you don't need this +# Doesn't support tokens yet +Forwarding: + server1: + Enabled: false + Host: '' + Port: '' + Key: '' + # If token is set a token will be used instead of the key + Token: '' From a5bf3b1eaa4214e9d6dc939c04608c47b487bcb2 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 17 Aug 2026 19:05:42 -0600 Subject: [PATCH 09/25] Add shared vote protocol policy --- .../votifier/net/VoteProtocolPolicy.java | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteProtocolPolicy.java diff --git a/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteProtocolPolicy.java b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteProtocolPolicy.java new file mode 100644 index 0000000..ce65a22 --- /dev/null +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteProtocolPolicy.java @@ -0,0 +1,21 @@ +package com.vexsoftware.votifier.net; + +/** + * Process-wide protocol policy for the single VotifierPlus listener owned by a + * plugin class loader. + */ +public final class VoteProtocolPolicy { + + private static volatile boolean disableV1; + + private VoteProtocolPolicy() { + } + + public static boolean isDisableV1() { + return disableV1; + } + + public static void setDisableV1(boolean disableV1) { + VoteProtocolPolicy.disableV1 = disableV1; + } +} From 97a233a8dc094b5f1d08ebe68f2c5952b2db25a1 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 17 Aug 2026 19:06:12 -0600 Subject: [PATCH 10/25] Apply Bukkit protocol policy --- .../src/main/java/com/vexsoftware/votifier/config/Config.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/VotifierPlus/src/main/java/com/vexsoftware/votifier/config/Config.java b/VotifierPlus/src/main/java/com/vexsoftware/votifier/config/Config.java index d8fc70b..c5a2cf6 100644 --- a/VotifierPlus/src/main/java/com/vexsoftware/votifier/config/Config.java +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/config/Config.java @@ -14,6 +14,7 @@ import com.bencodez.simpleapi.file.annotation.ConfigDataKeys; import com.bencodez.simpleapi.file.annotation.ConfigDataString; import com.vexsoftware.votifier.VotifierPlus; +import com.vexsoftware.votifier.net.VoteProtocolPolicy; import lombok.Getter; import lombok.Setter; @@ -27,6 +28,7 @@ public Config(VotifierPlus plugin) { public void loadValues() { new AnnotationHandler().load(getData(), this); debug = DebugLevel.getDebug(debugLevelStr); + VoteProtocolPolicy.setDisableV1(disableV1); } @Override From a2248dd33f53c3efae246d3d3092be9e5d7f5963 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 17 Aug 2026 19:06:30 -0600 Subject: [PATCH 11/25] Apply proxy protocol policy --- .../src/main/java/com/vexsoftware/votifier/bungee/Config.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/VotifierPlus/src/main/java/com/vexsoftware/votifier/bungee/Config.java b/VotifierPlus/src/main/java/com/vexsoftware/votifier/bungee/Config.java index 4ffbca6..9594bcb 100644 --- a/VotifierPlus/src/main/java/com/vexsoftware/votifier/bungee/Config.java +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/bungee/Config.java @@ -6,6 +6,8 @@ import java.nio.file.Files; import java.util.Set; +import com.vexsoftware.votifier.net.VoteProtocolPolicy; + import lombok.Getter; import net.md_5.bungee.config.Configuration; import net.md_5.bungee.config.ConfigurationProvider; @@ -36,6 +38,7 @@ public void load() { try { data = ConfigurationProvider.getProvider(YamlConfiguration.class) .load(new File(bungee.getDataFolder(), "bungeeconfig.yml")); + VoteProtocolPolicy.setDisableV1(getDisableV1()); } catch (IOException e) { e.printStackTrace(); } From a0c10762f8740e1eec51e2b0e351dbc2d0e5dd2e Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 17 Aug 2026 19:06:42 -0600 Subject: [PATCH 12/25] Apply Velocity protocol policy --- .../main/java/com/vexsoftware/votifier/velocity/Config.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/VotifierPlus/src/main/java/com/vexsoftware/votifier/velocity/Config.java b/VotifierPlus/src/main/java/com/vexsoftware/votifier/velocity/Config.java index cb8d7be..ff16eda 100644 --- a/VotifierPlus/src/main/java/com/vexsoftware/votifier/velocity/Config.java +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/velocity/Config.java @@ -8,11 +8,13 @@ import org.spongepowered.configurate.serialize.SerializationException; import com.bencodez.simpleapi.file.velocity.VelocityYMLFile; +import com.vexsoftware.votifier.net.VoteProtocolPolicy; public class Config extends VelocityYMLFile { public Config(File file) { super(file); + VoteProtocolPolicy.setDisableV1(getDisableV1()); } public String getHost() { @@ -57,6 +59,7 @@ public void setToken(String key, String token) { } public boolean getTokenSupport() { + VoteProtocolPolicy.setDisableV1(getDisableV1()); return getBoolean(getNode("TokenSupport"), false); } From c42afd35411e54df216c2247bbfd5fd56379d238 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 17 Aug 2026 19:07:31 -0600 Subject: [PATCH 13/25] Wire listener to shared protocol policy --- .../main/java/com/vexsoftware/votifier/net/VoteReceiver.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteReceiver.java b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteReceiver.java index b9230c8..11d2ef3 100644 --- a/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteReceiver.java +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteReceiver.java @@ -305,7 +305,7 @@ private void closeConnection(Socket socket) { * @return true when only V2 votes should be accepted */ public boolean isDisableV1() { - return false; + return VoteProtocolPolicy.isDisableV1(); } public abstract ThrottleConfig getThrottleConfig(); From 8f53c0e7bee8d97eac1c456b449263ef15afef76 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 17 Aug 2026 19:09:20 -0600 Subject: [PATCH 14/25] Test V1 policy and bounded listener queue --- .../tests/VoteProtocolSecurityTest.java | 346 ++++++++++++++++++ 1 file changed, 346 insertions(+) create mode 100644 VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteProtocolSecurityTest.java diff --git a/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteProtocolSecurityTest.java b/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteProtocolSecurityTest.java new file mode 100644 index 0000000..f335e49 --- /dev/null +++ b/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteProtocolSecurityTest.java @@ -0,0 +1,346 @@ +package com.bencodez.votifierplus.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketException; +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.util.Base64; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import javax.crypto.Cipher; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.google.gson.JsonObject; +import com.vexsoftware.votifier.ForwardServer; +import com.vexsoftware.votifier.model.Vote; +import com.vexsoftware.votifier.net.ThrottleConfig; +import com.vexsoftware.votifier.net.VoteConnectionHandler; +import com.vexsoftware.votifier.net.VoteProtocolPolicy; +import com.vexsoftware.votifier.net.VoteReceiver; +import com.vexsoftware.votifier.net.VoteThrottleService; + +/** + * Regression tests for the optional V1 security boundary and bounded listener + * queue. + */ +public class VoteProtocolSecurityTest { + + private static KeyPair testKeyPair; + private static Key tokenKey; + + private TestVoteReceiver receiver; + private ExecutorService executor; + + @BeforeAll + public static void setupClass() throws Exception { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + testKeyPair = keyPairGenerator.generateKeyPair(); + tokenKey = new SecretKeySpec("securityTestToken123".getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + } + + @BeforeEach + public void setup() throws Exception { + VoteProtocolPolicy.setDisableV1(false); + receiver = new TestVoteReceiver("127.0.0.1", 0); + executor = Executors.newCachedThreadPool(); + } + + @AfterEach + public void tearDown() { + VoteProtocolPolicy.setDisableV1(false); + if (executor != null) { + executor.shutdownNow(); + } + if (receiver != null) { + receiver.shutdown(); + } + } + + @Test + public void testTokenCompatibilityModeStillAcceptsPresentV1Packet() throws Exception { + receiver.setUseTokens(true); + VoteProtocolPolicy.setDisableV1(false); + VoteConnectionHandler handler = new VoteConnectionHandler(receiver, new VoteThrottleService(null)); + + try (ServerSocket serverSocket = new ServerSocket(0); + Socket client = new Socket("127.0.0.1", serverSocket.getLocalPort()); + Socket accepted = serverSocket.accept()) { + client.getOutputStream().write(createV1Packet("compatibilityUser")); + client.getOutputStream().flush(); + + Future future = executor.submit(() -> handler.handle(accepted)); + Vote vote = future.get(2, TimeUnit.SECONDS); + + assertNotNull(vote); + assertEquals("compatibilityUser", vote.getUsername()); + } + } + + @Test + public void testDisableV1RejectsDelayedV1PacketInTokenMode() throws Exception { + receiver.setUseTokens(true); + VoteProtocolPolicy.setDisableV1(true); + VoteConnectionHandler handler = new VoteConnectionHandler(receiver, new VoteThrottleService(null)); + + try (ServerSocket serverSocket = new ServerSocket(0); + Socket client = new Socket("127.0.0.1", serverSocket.getLocalPort()); + Socket accepted = serverSocket.accept()) { + Future future = executor.submit(() -> handler.handle(accepted)); + BufferedReader reader = new BufferedReader( + new InputStreamReader(client.getInputStream(), StandardCharsets.UTF_8)); + + assertEquals("VOTIFIER 2 testChallenge", reader.readLine()); + client.getOutputStream().write(createV1Packet("rejectedDelayedUser")); + client.getOutputStream().flush(); + + assertNull(future.get(2, TimeUnit.SECONDS)); + assertTrue(receiver.getWarnings().stream() + .anyMatch(message -> message.contains("Votifier V1 votes are disabled by configuration"))); + } + } + + @Test + public void testDisableV1RejectsPresentV1PacketAndStillSendsV2Handshake() throws Exception { + receiver.setUseTokens(true); + VoteProtocolPolicy.setDisableV1(true); + VoteConnectionHandler handler = new VoteConnectionHandler(receiver, new VoteThrottleService(null)); + + try (ServerSocket serverSocket = new ServerSocket(0); + Socket client = new Socket("127.0.0.1", serverSocket.getLocalPort()); + Socket accepted = serverSocket.accept()) { + client.getOutputStream().write(createV1Packet("rejectedPresentUser")); + client.getOutputStream().flush(); + + Future future = executor.submit(() -> handler.handle(accepted)); + BufferedReader reader = new BufferedReader( + new InputStreamReader(client.getInputStream(), StandardCharsets.UTF_8)); + + assertEquals("VOTIFIER 2 testChallenge", reader.readLine()); + assertNull(future.get(2, TimeUnit.SECONDS)); + } + } + + @Test + public void testDisableV1ForcesV2HandshakeAndAcceptsValidV2() throws Exception { + receiver.setUseTokens(false); + VoteProtocolPolicy.setDisableV1(true); + VoteConnectionHandler handler = new VoteConnectionHandler(receiver, new VoteThrottleService(null)); + + try (ServerSocket serverSocket = new ServerSocket(0); + Socket client = new Socket("127.0.0.1", serverSocket.getLocalPort()); + Socket accepted = serverSocket.accept()) { + Future future = executor.submit(() -> handler.handle(accepted)); + BufferedReader reader = new BufferedReader( + new InputStreamReader(client.getInputStream(), StandardCharsets.UTF_8)); + OutputStream output = client.getOutputStream(); + + assertEquals("VOTIFIER 2 testChallenge", reader.readLine()); + output.write(createV2Packet("secureV2User")); + output.flush(); + + assertTrue(reader.readLine().contains("\"status\":\"ok\"")); + Vote vote = future.get(2, TimeUnit.SECONDS); + assertNotNull(vote); + assertEquals("secureV2User", vote.getUsername()); + } + } + + @Test + public void testFullConnectionQueueRejectsAndClosesNewSocket() throws Exception { + receiver.shutdown(); + SaturatedVoteReceiver saturated = new SaturatedVoteReceiver("127.0.0.1", 0); + receiver = saturated; + receiver.start(); + + try (Socket active = new Socket("127.0.0.1", receiver.getServer().getLocalPort())) { + BufferedReader activeReader = new BufferedReader( + new InputStreamReader(active.getInputStream(), StandardCharsets.UTF_8)); + assertEquals("VOTIFIER 1", activeReader.readLine()); + + try (Socket queued = new Socket("127.0.0.1", receiver.getServer().getLocalPort()); + Socket rejected = new Socket("127.0.0.1", receiver.getServer().getLocalPort())) { + assertTrue(saturated.awaitRejection()); + rejected.setSoTimeout(1000); + try { + assertEquals(-1, rejected.getInputStream().read()); + } catch (SocketException expectedReset) { + assertTrue(expectedReset.getMessage() != null || rejected.isClosed() || rejected.isConnected()); + } + } + } + } + + private byte[] createV1Packet(String username) throws Exception { + String voteMessage = "VOTE\nvotifier.bencodez.com\n" + username + "\n127.0.0.1\nNormalTimestamp\n"; + Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding"); + cipher.init(Cipher.ENCRYPT_MODE, testKeyPair.getPublic()); + return cipher.doFinal(voteMessage.getBytes(StandardCharsets.UTF_8)); + } + + private byte[] createV2Packet(String username) throws Exception { + JsonObject inner = new JsonObject(); + inner.addProperty("serviceName", "votifier.bencodez.com"); + inner.addProperty("username", username); + inner.addProperty("address", "127.0.0.1"); + inner.addProperty("timestamp", "NormalTimestampV2"); + inner.addProperty("challenge", "testChallenge"); + String payload = inner.toString(); + + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(tokenKey); + String signature = Base64.getEncoder() + .encodeToString(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8))); + + JsonObject outer = new JsonObject(); + outer.addProperty("payload", payload); + outer.addProperty("signature", signature); + return (outer.toString() + "\r\n").getBytes(StandardCharsets.UTF_8); + } + + private static class TestVoteReceiver extends VoteReceiver { + + private final List warnings = new CopyOnWriteArrayList<>(); + private volatile boolean useTokens; + + TestVoteReceiver(String host, int port) throws Exception { + super(host, port); + } + + void setUseTokens(boolean useTokens) { + this.useTokens = useTokens; + } + + List getWarnings() { + return warnings; + } + + @Override + public boolean isUseTokens() { + return useTokens; + } + + @Override + public void logWarning(String warning) { + if (warnings != null) { + warnings.add(warning); + } + } + + @Override + public void logSevere(String message) { + } + + @Override + public void log(String message) { + } + + @Override + public void debug(String message) { + } + + @Override + public void debug(Exception exception) { + } + + @Override + public String getVersion() { + return "Test"; + } + + @Override + public Set getServers() { + return Collections.emptySet(); + } + + @Override + public KeyPair getKeyPair() { + return testKeyPair; + } + + @Override + public Map getTokens() { + return Collections.singletonMap("votifier.bencodez.com", tokenKey); + } + + @Override + public ForwardServer getServerData(String server) { + return null; + } + + @Override + public void callEvent(Vote vote) { + } + + @Override + public String getChallenge() { + return "testChallenge"; + } + + @Override + public ThrottleConfig getThrottleConfig() { + return null; + } + } + + private static class SaturatedVoteReceiver extends TestVoteReceiver { + + private final CountDownLatch rejection = new CountDownLatch(1); + + SaturatedVoteReceiver(String host, int port) throws Exception { + super(host, port); + } + + @Override + public int getConnectionWorkerCount() { + return 1; + } + + @Override + public int getConnectionQueueCapacity() { + return 1; + } + + @Override + public long getConnectionQueueTimeoutMillis() { + return 10000L; + } + + @Override + public void debug(String message) { + if (rejection != null && message.startsWith("Rejected vote connection")) { + rejection.countDown(); + } + } + + boolean awaitRejection() throws InterruptedException { + return rejection.await(2, TimeUnit.SECONDS); + } + } +} From a219ca78191cc3e9117f18c4a8377fabbe7dd561 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 17 Aug 2026 19:10:20 -0600 Subject: [PATCH 15/25] Test bounded proxy and CONNECT parsing --- .../ProxyHeaderProcessorSecurityTest.java | 265 ++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/ProxyHeaderProcessorSecurityTest.java diff --git a/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/ProxyHeaderProcessorSecurityTest.java b/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/ProxyHeaderProcessorSecurityTest.java new file mode 100644 index 0000000..72a32f0 --- /dev/null +++ b/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/ProxyHeaderProcessorSecurityTest.java @@ -0,0 +1,265 @@ +package com.bencodez.votifierplus.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.BufferedWriter; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.OutputStreamWriter; +import java.io.PushbackInputStream; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.vexsoftware.votifier.ForwardServer; +import com.vexsoftware.votifier.model.Vote; +import com.vexsoftware.votifier.net.InvalidVoteException; +import com.vexsoftware.votifier.net.ProxyHeaderProcessor; +import com.vexsoftware.votifier.net.ThrottleConfig; +import com.vexsoftware.votifier.net.VoteReceiver; + +/** + * Regression tests for bounded PROXY and HTTP CONNECT parsing. + */ +public class ProxyHeaderProcessorSecurityTest { + + private static KeyPair testKeyPair; + + private ProxyHeaderProcessor processor; + private StubVoteReceiver receiver; + + @BeforeAll + public static void setupClass() throws Exception { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + testKeyPair = keyPairGenerator.generateKeyPair(); + } + + @BeforeEach + public void setup() throws Exception { + processor = new ProxyHeaderProcessor(); + receiver = new StubVoteReceiver("127.0.0.1", 0); + } + + @AfterEach + public void tearDown() { + if (receiver != null) { + receiver.shutdown(); + } + } + + @Test + public void testValidProxyV1HeaderPreservesVotePayload() throws Exception { + String header = "PROXY TCP4 192.0.2.10 192.0.2.20 1234 8192\r\n"; + String payload = "VOTE\nsite\nuser\n127.0.0.1\ntimestamp\n"; + PushbackInputStream input = input(header + payload); + + ProxyHeaderProcessor.ProxyHeaderResult result = processor.process(input, writer(), receiver); + + assertEquals("192.0.2.10", result.getRealIp()); + assertEquals(payload, readRemaining(input)); + } + + @Test + public void testProxyV1HeaderOver107BytesIsRejected() throws Exception { + String oversized = "PROXY " + "A".repeat(100) + "\r\n"; + InvalidVoteException exception = assertThrows(InvalidVoteException.class, + () -> processor.process(input(oversized), writer(), receiver)); + + assertTrue(exception.getMessage().contains("exceeds 107 bytes")); + } + + @Test + public void testValidConnectHeadersPreserveVotePayload() throws Exception { + String headers = "CONNECT vote.example:443 HTTP/1.1\r\nHost: vote.example:443\r\n\r\n"; + String payload = "VOTE\nsite\nuser\n127.0.0.1\ntimestamp\n"; + PushbackInputStream input = input(headers + payload); + ByteArrayOutputStream response = new ByteArrayOutputStream(); + BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(response, StandardCharsets.US_ASCII)); + + processor.process(input, writer, receiver); + writer.flush(); + + assertTrue(response.toString(StandardCharsets.US_ASCII).contains("200 Connection Established")); + assertEquals(payload, readRemaining(input)); + } + + @Test + public void testOversizedConnectRequestLineIsRejected() throws Exception { + String oversized = "CONNECT " + "a".repeat(8185) + "\r\n"; + InvalidVoteException exception = assertThrows(InvalidVoteException.class, + () -> processor.process(input(oversized), writer(), receiver)); + + assertTrue(exception.getMessage().contains("line exceeds 8192 bytes")); + } + + @Test + public void testMoreThan100ConnectHeadersAreRejected() throws Exception { + StringBuilder request = new StringBuilder("CONNECT vote.example:443 HTTP/1.1\r\n"); + for (int i = 0; i < 101; i++) { + request.append("X-Test-").append(i).append(": value\r\n"); + } + request.append("\r\n"); + + InvalidVoteException exception = assertThrows(InvalidVoteException.class, + () -> processor.process(input(request.toString()), writer(), receiver)); + + assertTrue(exception.getMessage().contains("Too many HTTP CONNECT headers")); + } + + @Test + public void testConnectHeadersOver32KiBAreRejected() throws Exception { + StringBuilder request = new StringBuilder("CONNECT vote.example:443 HTTP/1.1\r\n"); + for (int i = 0; i < 5; i++) { + request.append("X-Test: ").append("a".repeat(7000)).append("\r\n"); + } + request.append("\r\n"); + + InvalidVoteException exception = assertThrows(InvalidVoteException.class, + () -> processor.process(input(request.toString()), writer(), receiver)); + + assertTrue(exception.getMessage().contains("headers exceed 32768 bytes")); + } + + @Test + public void testProxyV2ReadsUseDecreasingCumulativeTimeout() throws Exception { + byte[] header = new byte[] { 0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A, + 0x21, 0x11, 0x00, 0x03, 0x01, 0x02, 0x03 }; + ByteArrayInputStream fragmented = new ByteArrayInputStream(header) { + @Override + public synchronized int read(byte[] bytes, int offset, int length) { + if (pos >= 16) { + try { + Thread.sleep(25); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + } + return super.read(bytes, offset, Math.min(length, pos < 16 ? 16 : 1)); + } + }; + PushbackInputStream input = new PushbackInputStream(fragmented, 512); + RecordingSocket socket = new RecordingSocket(); + + processor.process(input, writer(), receiver, socket); + + assertTrue(socket.getRecordedTimeouts().stream().anyMatch(timeout -> timeout < 5000)); + assertEquals(5000, socket.getRecordedTimeouts().get(socket.getRecordedTimeouts().size() - 1)); + } + + private PushbackInputStream input(String value) { + return new PushbackInputStream( + new ByteArrayInputStream(value.getBytes(StandardCharsets.US_ASCII)), 512); + } + + private BufferedWriter writer() { + return new BufferedWriter(new OutputStreamWriter(new ByteArrayOutputStream(), StandardCharsets.US_ASCII)); + } + + private String readRemaining(PushbackInputStream input) throws Exception { + ByteArrayOutputStream remaining = new ByteArrayOutputStream(); + input.transferTo(remaining); + return remaining.toString(StandardCharsets.US_ASCII); + } + + private static class RecordingSocket extends Socket { + + private final List recordedTimeouts = new ArrayList<>(); + private int timeout = 5000; + + @Override + public int getSoTimeout() { + return timeout; + } + + @Override + public void setSoTimeout(int timeout) { + this.timeout = timeout; + recordedTimeouts.add(timeout); + } + + List getRecordedTimeouts() { + return recordedTimeouts; + } + } + + private static class StubVoteReceiver extends VoteReceiver { + + StubVoteReceiver(String host, int port) throws Exception { + super(host, port); + } + + @Override + public boolean isUseTokens() { + return false; + } + + @Override + public void logWarning(String warning) { + } + + @Override + public void logSevere(String message) { + } + + @Override + public void log(String message) { + } + + @Override + public void debug(String message) { + } + + @Override + public void debug(Exception exception) { + } + + @Override + public String getVersion() { + return "Test"; + } + + @Override + public Set getServers() { + return Collections.emptySet(); + } + + @Override + public KeyPair getKeyPair() { + return testKeyPair; + } + + @Override + public Map getTokens() { + return Collections.emptyMap(); + } + + @Override + public ForwardServer getServerData(String server) { + return null; + } + + @Override + public void callEvent(Vote vote) { + } + + @Override + public ThrottleConfig getThrottleConfig() { + return null; + } + } +} From 9dfb9b3b8157bc0ed2b23579130b3c23810ac7a9 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 17 Aug 2026 19:11:40 -0600 Subject: [PATCH 16/25] Preserve Bukkit config source line endings --- .../vexsoftware/votifier/config/Config.java | 188 +++++++++--------- 1 file changed, 94 insertions(+), 94 deletions(-) diff --git a/VotifierPlus/src/main/java/com/vexsoftware/votifier/config/Config.java b/VotifierPlus/src/main/java/com/vexsoftware/votifier/config/Config.java index c5a2cf6..390b1f3 100644 --- a/VotifierPlus/src/main/java/com/vexsoftware/votifier/config/Config.java +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/config/Config.java @@ -1,94 +1,94 @@ -package com.vexsoftware.votifier.config; - -import java.io.File; -import java.util.HashSet; -import java.util.Set; - -import org.bukkit.configuration.ConfigurationSection; - -import com.bencodez.simpleapi.debug.DebugLevel; -import com.bencodez.simpleapi.file.YMLFile; -import com.bencodez.simpleapi.file.annotation.AnnotationHandler; -import com.bencodez.simpleapi.file.annotation.ConfigDataBoolean; -import com.bencodez.simpleapi.file.annotation.ConfigDataInt; -import com.bencodez.simpleapi.file.annotation.ConfigDataKeys; -import com.bencodez.simpleapi.file.annotation.ConfigDataString; -import com.vexsoftware.votifier.VotifierPlus; -import com.vexsoftware.votifier.net.VoteProtocolPolicy; - -import lombok.Getter; -import lombok.Setter; - -public class Config extends YMLFile { - - public Config(VotifierPlus plugin) { - super(plugin, new File(VotifierPlus.getInstance().getDataFolder(), "config.yml")); - } - - public void loadValues() { - new AnnotationHandler().load(getData(), this); - debug = DebugLevel.getDebug(debugLevelStr); - VoteProtocolPolicy.setDisableV1(disableV1); - } - - @Override - public void onFileCreation() { - VotifierPlus.getInstance().saveResource("config.yml", true); - } - - @ConfigDataString(path = "host") - @Getter - @Setter - private String host = "0.0.0.0"; - - @ConfigDataInt(path = "port") - @Getter - @Setter - private int port = 8192; - - @ConfigDataString(path = "DebugLevel", options = { "NONE", "INFO", "EXTRA", "DEV" }) - private String debugLevelStr = "NONE"; - - @Getter - @Setter - private DebugLevel debug = DebugLevel.NONE; - - @ConfigDataKeys(path = "Forwarding") - @Getter - @Setter - private Set servers = new HashSet(); - - @Getter - @Setter - @ConfigDataString(path = "Format.NoPerms") - private String formatNoPerms = "&cYou do not have enough permission!"; - - @Getter - @Setter - @ConfigDataString(path = "Format.NotNumber") - private String formatNotNumber = "&cError on &6%arg%&c, number expected!"; - - @Getter - @Setter - @ConfigDataString(path = "Format.HelpLine") - private String helpLine = "&3&l%Command% - &3%HelpMessage%"; - - @ConfigDataBoolean(path = "DisableUpdateChecking") - @Getter - private boolean disableUpdateChecking = false; - - @ConfigDataBoolean(path = "TokenSupport") - @Getter - @Setter - private boolean tokenSupport = false; - - @ConfigDataBoolean(path = "DisableV1") - @Getter - @Setter - private boolean disableV1 = false; - - public ConfigurationSection getForwardingConfiguration(String s) { - return getData().getConfigurationSection("Forwarding." + s); - } - -} +package com.vexsoftware.votifier.config; + +import java.io.File; +import java.util.HashSet; +import java.util.Set; + +import org.bukkit.configuration.ConfigurationSection; + +import com.bencodez.simpleapi.debug.DebugLevel; +import com.bencodez.simpleapi.file.YMLFile; +import com.bencodez.simpleapi.file.annotation.AnnotationHandler; +import com.bencodez.simpleapi.file.annotation.ConfigDataBoolean; +import com.bencodez.simpleapi.file.annotation.ConfigDataInt; +import com.bencodez.simpleapi.file.annotation.ConfigDataKeys; +import com.bencodez.simpleapi.file.annotation.ConfigDataString; +import com.vexsoftware.votifier.VotifierPlus; +import com.vexsoftware.votifier.net.VoteProtocolPolicy; + +import lombok.Getter; +import lombok.Setter; + +public class Config extends YMLFile { + + public Config(VotifierPlus plugin) { + super(plugin, new File(VotifierPlus.getInstance().getDataFolder(), "config.yml")); + } + + public void loadValues() { + new AnnotationHandler().load(getData(), this); + debug = DebugLevel.getDebug(debugLevelStr); + VoteProtocolPolicy.setDisableV1(disableV1); + } + + @Override + public void onFileCreation() { + VotifierPlus.getInstance().saveResource("config.yml", true); + } + + @ConfigDataString(path = "host") + @Getter + @Setter + private String host = "0.0.0.0"; + + @ConfigDataInt(path = "port") + @Getter + @Setter + private int port = 8192; + + @ConfigDataString(path = "DebugLevel", options = { "NONE", "INFO", "EXTRA", "DEV" }) + private String debugLevelStr = "NONE"; + + @Getter + @Setter + private DebugLevel debug = DebugLevel.NONE; + + @ConfigDataKeys(path = "Forwarding") + @Getter + @Setter + private Set servers = new HashSet(); + + @Getter + @Setter + @ConfigDataString(path = "Format.NoPerms") + private String formatNoPerms = "&cYou do not have enough permission!"; + + @Getter + @Setter + @ConfigDataString(path = "Format.NotNumber") + private String formatNotNumber = "&cError on &6%arg%&c, number expected!"; + + @Getter + @Setter + @ConfigDataString(path = "Format.HelpLine") + private String helpLine = "&3&l%Command% - &3%HelpMessage%"; + + @ConfigDataBoolean(path = "DisableUpdateChecking") + @Getter + private boolean disableUpdateChecking = false; + + @ConfigDataBoolean(path = "TokenSupport") + @Getter + @Setter + private boolean tokenSupport = false; + + @ConfigDataBoolean(path = "DisableV1") + @Getter + @Setter + private boolean disableV1 = false; + + public ConfigurationSection getForwardingConfiguration(String s) { + return getData().getConfigurationSection("Forwarding." + s); + } + +} From 63bfff618b50e08e441d61965885d274616753f9 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 17 Aug 2026 19:12:00 -0600 Subject: [PATCH 17/25] Preserve Bungee config source line endings --- .../vexsoftware/votifier/bungee/Config.java | 202 +++++++++--------- 1 file changed, 101 insertions(+), 101 deletions(-) diff --git a/VotifierPlus/src/main/java/com/vexsoftware/votifier/bungee/Config.java b/VotifierPlus/src/main/java/com/vexsoftware/votifier/bungee/Config.java index 9594bcb..f5c46ef 100644 --- a/VotifierPlus/src/main/java/com/vexsoftware/votifier/bungee/Config.java +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/bungee/Config.java @@ -1,101 +1,101 @@ -package com.vexsoftware.votifier.bungee; - -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.nio.file.Files; -import java.util.Set; - -import com.vexsoftware.votifier.net.VoteProtocolPolicy; - -import lombok.Getter; -import net.md_5.bungee.config.Configuration; -import net.md_5.bungee.config.ConfigurationProvider; -import net.md_5.bungee.config.YamlConfiguration; - -public class Config { - private VotifierPlusBungee bungee; - @Getter - private Configuration data; - - public Config(VotifierPlusBungee bungee) { - this.bungee = bungee; - } - - public void load() { - if (!bungee.getDataFolder().exists()) - bungee.getDataFolder().mkdir(); - - File file = new File(bungee.getDataFolder(), "bungeeconfig.yml"); - - if (!file.exists()) { - try (InputStream in = bungee.getResourceAsStream("bungeeconfig.yml")) { - Files.copy(in, file.toPath()); - } catch (IOException e) { - e.printStackTrace(); - } - } - try { - data = ConfigurationProvider.getProvider(YamlConfiguration.class) - .load(new File(bungee.getDataFolder(), "bungeeconfig.yml")); - VoteProtocolPolicy.setDisableV1(getDisableV1()); - } catch (IOException e) { - e.printStackTrace(); - } - } - - public void save() { - try { - ConfigurationProvider.getProvider(YamlConfiguration.class).save(data, - new File(bungee.getDataFolder(), "bungeeconfig.yml")); - } catch (IOException e) { - e.printStackTrace(); - } - } - - public String getHost() { - return getData().getString("host", ""); - } - - public int getPort() { - return getData().getInt("port"); - } - - public boolean getDebug() { - return getData().getBoolean("Debug", false); - } - - public Set getServers() { - return (Set) getData().getSection("Forwarding").getKeys(); - } - - public Configuration getServerData(String s) { - return getData().getSection("Forwarding." + s); - } - - public Set getTokens() { - return (Set) getData().getSection("tokens").getKeys(); - } - - public boolean getTokenSupport() { - return getData().getBoolean("TokenSupport", false); - } - - public boolean getDisableV1() { - return getData().getBoolean("DisableV1", false); - } - - public String getToken(String key) { - return getData().getString("tokens." + key, null); - } - - public boolean containsTokens() { - return getData().contains("tokens"); - } - - public void setToken(String key, String token) { - getData().set("tokens." + key, token); - save(); - } - -} +package com.vexsoftware.votifier.bungee; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.util.Set; + +import com.vexsoftware.votifier.net.VoteProtocolPolicy; + +import lombok.Getter; +import net.md_5.bungee.config.Configuration; +import net.md_5.bungee.config.ConfigurationProvider; +import net.md_5.bungee.config.YamlConfiguration; + +public class Config { + private VotifierPlusBungee bungee; + @Getter + private Configuration data; + + public Config(VotifierPlusBungee bungee) { + this.bungee = bungee; + } + + public void load() { + if (!bungee.getDataFolder().exists()) + bungee.getDataFolder().mkdir(); + + File file = new File(bungee.getDataFolder(), "bungeeconfig.yml"); + + if (!file.exists()) { + try (InputStream in = bungee.getResourceAsStream("bungeeconfig.yml")) { + Files.copy(in, file.toPath()); + } catch (IOException e) { + e.printStackTrace(); + } + } + try { + data = ConfigurationProvider.getProvider(YamlConfiguration.class) + .load(new File(bungee.getDataFolder(), "bungeeconfig.yml")); + VoteProtocolPolicy.setDisableV1(getDisableV1()); + } catch (IOException e) { + e.printStackTrace(); + } + } + + public void save() { + try { + ConfigurationProvider.getProvider(YamlConfiguration.class).save(data, + new File(bungee.getDataFolder(), "bungeeconfig.yml")); + } catch (IOException e) { + e.printStackTrace(); + } + } + + public String getHost() { + return getData().getString("host", ""); + } + + public int getPort() { + return getData().getInt("port"); + } + + public boolean getDebug() { + return getData().getBoolean("Debug", false); + } + + public Set getServers() { + return (Set) getData().getSection("Forwarding").getKeys(); + } + + public Configuration getServerData(String s) { + return getData().getSection("Forwarding." + s); + } + + public Set getTokens() { + return (Set) getData().getSection("tokens").getKeys(); + } + + public boolean getTokenSupport() { + return getData().getBoolean("TokenSupport", false); + } + + public boolean getDisableV1() { + return getData().getBoolean("DisableV1", false); + } + + public String getToken(String key) { + return getData().getString("tokens." + key, null); + } + + public boolean containsTokens() { + return getData().contains("tokens"); + } + + public void setToken(String key, String token) { + getData().set("tokens." + key, token); + save(); + } + +} From eb58436e5f3ebc825eb4c6f334b01a5cad9b0b4c Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 17 Aug 2026 19:12:15 -0600 Subject: [PATCH 18/25] Preserve Velocity config source line endings --- .../vexsoftware/votifier/velocity/Config.java | 138 +++++++++--------- 1 file changed, 69 insertions(+), 69 deletions(-) diff --git a/VotifierPlus/src/main/java/com/vexsoftware/votifier/velocity/Config.java b/VotifierPlus/src/main/java/com/vexsoftware/votifier/velocity/Config.java index ff16eda..83dd27f 100644 --- a/VotifierPlus/src/main/java/com/vexsoftware/votifier/velocity/Config.java +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/velocity/Config.java @@ -1,69 +1,69 @@ -package com.vexsoftware.votifier.velocity; - -import java.io.File; -import java.util.Collection; - -import org.checkerframework.checker.nullness.qual.NonNull; -import org.spongepowered.configurate.ConfigurationNode; -import org.spongepowered.configurate.serialize.SerializationException; - -import com.bencodez.simpleapi.file.velocity.VelocityYMLFile; -import com.vexsoftware.votifier.net.VoteProtocolPolicy; - -public class Config extends VelocityYMLFile { - - public Config(File file) { - super(file); - VoteProtocolPolicy.setDisableV1(getDisableV1()); - } - - public String getHost() { - return getString(getNode("host"), ""); - } - - public int getPort() { - return getInt(getNode("port"), 0); - } - - public boolean getDebug() { - return getBoolean(getNode("Debug"), false); - } - - public @NonNull Collection getServers() { - return getNode("Forwarding").childrenMap().values(); - } - - public ConfigurationNode getServersData(String s) { - return getNode("Forwarding", s); - } - - public @NonNull Collection getTokens() { - return getNode("tokens").childrenMap().values(); - } - - public String getToken(String key) { - return getString(getNode("tokens", key), ""); - } - - public boolean containsTokens() { - return !getNode("tokens").virtual(); - } - - public void setToken(String key, String token) { - try { - getNode("tokens", key).set(token); - } catch (SerializationException e) { - e.printStackTrace(); - } - save(); - } - - public boolean getTokenSupport() { - VoteProtocolPolicy.setDisableV1(getDisableV1()); - return getBoolean(getNode("TokenSupport"), false); - } - - public boolean getDisableV1() { - return getBoolean(getNode("DisableV1"), false); - } -} +package com.vexsoftware.votifier.velocity; + +import java.io.File; +import java.util.Collection; + +import org.checkerframework.checker.nullness.qual.NonNull; +import org.spongepowered.configurate.ConfigurationNode; +import org.spongepowered.configurate.serialize.SerializationException; + +import com.bencodez.simpleapi.file.velocity.VelocityYMLFile; +import com.vexsoftware.votifier.net.VoteProtocolPolicy; + +public class Config extends VelocityYMLFile { + + public Config(File file) { + super(file); + VoteProtocolPolicy.setDisableV1(getDisableV1()); + } + + public String getHost() { + return getString(getNode("host"), ""); + } + + public int getPort() { + return getInt(getNode("port"), 0); + } + + public boolean getDebug() { + return getBoolean(getNode("Debug"), false); + } + + public @NonNull Collection getServers() { + return getNode("Forwarding").childrenMap().values(); + } + + public ConfigurationNode getServersData(String s) { + return getNode("Forwarding", s); + } + + public @NonNull Collection getTokens() { + return getNode("tokens").childrenMap().values(); + } + + public String getToken(String key) { + return getString(getNode("tokens", key), ""); + } + + public boolean containsTokens() { + return !getNode("tokens").virtual(); + } + + public void setToken(String key, String token) { + try { + getNode("tokens", key).set(token); + } catch (SerializationException e) { + e.printStackTrace(); + } + save(); + } + + public boolean getTokenSupport() { + VoteProtocolPolicy.setDisableV1(getDisableV1()); + return getBoolean(getNode("TokenSupport"), false); + } + + public boolean getDisableV1() { + return getBoolean(getNode("DisableV1"), false); + } +} From 627b92e53eb69efd925701faa8611d9f73e29918 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 17 Aug 2026 19:12:44 -0600 Subject: [PATCH 19/25] Preserve Bukkit config resource line endings --- VotifierPlus/src/main/resources/config.yml | 258 ++++++++++----------- 1 file changed, 129 insertions(+), 129 deletions(-) diff --git a/VotifierPlus/src/main/resources/config.yml b/VotifierPlus/src/main/resources/config.yml index f726bc5..187cd3e 100644 --- a/VotifierPlus/src/main/resources/config.yml +++ b/VotifierPlus/src/main/resources/config.yml @@ -1,129 +1,129 @@ -# Debug levels: -# NONE -# INFO -# EXTRA -DebugLevel: NONE -# The host VotifierPlus will listen on -host: 0.0.0.0 -# The port VotifierPlus will listen on -port: 8192 -# Enables Votifier v2 token/HMAC support while retaining V1 compatibility. -TokenSupport: false -# Rejects all legacy Votifier v1 RSA packets and forces a V2 handshake. -# Keep this false if any configured voting site only supports V1. -DisableV1: false - -# ----------------------------------------------------------------------------- -# Connection throttling & spam reduction for Votifier -# -# Purpose: -# - Reduce console spam from random scanners / port probes -# - Prevent CPU waste from repeated invalid votes (bad padding, short payloads) -# - Work safely behind tunnels (playit.gg) without blocking legit votes -# -# Notes: -# - All time values use ParsedDuration (examples: 30s, 2m, 10m, 1h) -# - Per-client bans ONLY apply when a real client IP is known -# (e.g. via PROXY protocol v1). If behind playit without PROXY, -# only tunnel-level throttling is used. -# ----------------------------------------------------------------------------- -ConnectionThrottle: - - # Master switch - Enabled: false - - # --------------------------------------------------------------------------- - # Tunnel detection - # - # If the remote socket IP matches one of these, the connection is treated as - # "tunnel mode" (e.g. playit.gg). - # - # In tunnel mode: - # - Lower failure thresholds are used - # - Longer throttle durations are applied - # - # IMPORTANT: - # - Do NOT add your own backend/proxy IPs here - # - Only add tunnel / egress IPs (playit, cloudflared, etc.) - # --------------------------------------------------------------------------- - TunnelRemoteIps: - - "127.0.0.1" # playit.gg egress (example) - # - "x.x.x.x" # add more if needed - - # --------------------------------------------------------------------------- - # Sliding failure window - # - # If this many failures occur within the window, hard throttling starts. - # - # Failures counted include: - # - Invalid V1 block size - # - RSA bad padding / key mismatch - # - Malformed JSON (V2) - # - Invalid token / signature - # --------------------------------------------------------------------------- - - # How long to track failures before resetting the counter - Window: "2m" - - # Failures within the window before throttling (normal / non-tunnel) - Failures: 20 - - # How long to block further connections once throttled - ThrottleFor: "5m" - - # --------------------------------------------------------------------------- - # Tunnel-mode overrides (playit, etc.) - # - # These are intentionally more aggressive because scanners all share - # the same tunnel IP. - # --------------------------------------------------------------------------- - - # Failures before throttling when in tunnel mode - TunnelFailures: 8 - - # Throttle duration when in tunnel mode - TunnelThrottleFor: "10m" - - # --------------------------------------------------------------------------- - # Per-client bans (ONLY when real client IP is known) - # - # Requires: - # - PROXY protocol v1 providing the real source IP - # - # If enabled and the same real IP repeatedly fails validation, - # that IP will be temporarily banned. - # - # If real IP is NOT known (typical playit setup), - # this section is ignored automatically. - # --------------------------------------------------------------------------- - PerClientBan: - - # Enable per-client banning - Enabled: true - - # Failures within the window before banning a real client IP - Failures: 6 - - # How long the real client IP is banned - BanFor: "15m" - - # --------------------------------------------------------------------------- - # Log rate limiting - # - # Prevents console spam by allowing only ONE warning per key - # (IP + error type) per window. - # - # Additional messages are suppressed and summarized. - # --------------------------------------------------------------------------- - LogWindow: "60s" - -# If your using VotingPlugin you don't need this -# Doesn't support tokens yet -Forwarding: - server1: - Enabled: false - Host: '' - Port: 8193 - Key: '' - # If token is set a token will be used instead of the key - Token: '' +# Debug levels: +# NONE +# INFO +# EXTRA +DebugLevel: NONE +# The host VotifierPlus will listen on +host: 0.0.0.0 +# The port VotifierPlus will listen on +port: 8192 +# Enables Votifier v2 token/HMAC support while retaining V1 compatibility. +TokenSupport: false +# Rejects all legacy Votifier v1 RSA packets and forces a V2 handshake. +# Keep this false if any configured voting site only supports V1. +DisableV1: false + +# ----------------------------------------------------------------------------- +# Connection throttling & spam reduction for Votifier +# +# Purpose: +# - Reduce console spam from random scanners / port probes +# - Prevent CPU waste from repeated invalid votes (bad padding, short payloads) +# - Work safely behind tunnels (playit.gg) without blocking legit votes +# +# Notes: +# - All time values use ParsedDuration (examples: 30s, 2m, 10m, 1h) +# - Per-client bans ONLY apply when a real client IP is known +# (e.g. via PROXY protocol v1). If behind playit without PROXY, +# only tunnel-level throttling is used. +# ----------------------------------------------------------------------------- +ConnectionThrottle: + + # Master switch + Enabled: false + + # --------------------------------------------------------------------------- + # Tunnel detection + # + # If the remote socket IP matches one of these, the connection is treated as + # "tunnel mode" (e.g. playit.gg). + # + # In tunnel mode: + # - Lower failure thresholds are used + # - Longer throttle durations are applied + # + # IMPORTANT: + # - Do NOT add your own backend/proxy IPs here + # - Only add tunnel / egress IPs (playit, cloudflared, etc.) + # --------------------------------------------------------------------------- + TunnelRemoteIps: + - "127.0.0.1" # playit.gg egress (example) + # - "x.x.x.x" # add more if needed + + # --------------------------------------------------------------------------- + # Sliding failure window + # + # If this many failures occur within the window, hard throttling starts. + # + # Failures counted include: + # - Invalid V1 block size + # - RSA bad padding / key mismatch + # - Malformed JSON (V2) + # - Invalid token / signature + # --------------------------------------------------------------------------- + + # How long to track failures before resetting the counter + Window: "2m" + + # Failures within the window before throttling (normal / non-tunnel) + Failures: 20 + + # How long to block further connections once throttled + ThrottleFor: "5m" + + # --------------------------------------------------------------------------- + # Tunnel-mode overrides (playit, etc.) + # + # These are intentionally more aggressive because scanners all share + # the same tunnel IP. + # --------------------------------------------------------------------------- + + # Failures before throttling when in tunnel mode + TunnelFailures: 8 + + # Throttle duration when in tunnel mode + TunnelThrottleFor: "10m" + + # --------------------------------------------------------------------------- + # Per-client bans (ONLY when real client IP is known) + # + # Requires: + # - PROXY protocol v1 providing the real source IP + # + # If enabled and the same real IP repeatedly fails validation, + # that IP will be temporarily banned. + # + # If real IP is NOT known (typical playit setup), + # this section is ignored automatically. + # --------------------------------------------------------------------------- + PerClientBan: + + # Enable per-client banning + Enabled: true + + # Failures within the window before banning a real client IP + Failures: 6 + + # How long the real client IP is banned + BanFor: "15m" + + # --------------------------------------------------------------------------- + # Log rate limiting + # + # Prevents console spam by allowing only ONE warning per key + # (IP + error type) per window. + # + # Additional messages are suppressed and summarized. + # --------------------------------------------------------------------------- + LogWindow: "60s" + +# If your using VotingPlugin you don't need this +# Doesn't support tokens yet +Forwarding: + server1: + Enabled: false + Host: '' + Port: 8193 + Key: '' + # If token is set a token will be used instead of the key + Token: '' \ No newline at end of file From e4931fd8f9458d7a764636c96dba45fdf59e0c45 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 17 Aug 2026 19:13:06 -0600 Subject: [PATCH 20/25] Preserve proxy config resource line endings --- .../src/main/resources/bungeeconfig.yml | 252 +++++++++--------- 1 file changed, 126 insertions(+), 126 deletions(-) diff --git a/VotifierPlus/src/main/resources/bungeeconfig.yml b/VotifierPlus/src/main/resources/bungeeconfig.yml index 468c091..0dca089 100644 --- a/VotifierPlus/src/main/resources/bungeeconfig.yml +++ b/VotifierPlus/src/main/resources/bungeeconfig.yml @@ -1,126 +1,126 @@ -Debug: false -# The host VotifierPlus will listen on -host: 0.0.0.0 -# The port VotifierPlus will listen on -port: 8192 -# Enables Votifier v2 token/HMAC support while retaining V1 compatibility. -TokenSupport: false -# Rejects all legacy Votifier v1 RSA packets and forces a V2 handshake. -# Keep this false if any configured voting site only supports V1. -DisableV1: false - -# ----------------------------------------------------------------------------- -# Connection throttling & spam reduction for Votifier -# -# Purpose: -# - Reduce console spam from random scanners / port probes -# - Prevent CPU waste from repeated invalid votes (bad padding, short payloads) -# - Work safely behind tunnels (playit.gg) without blocking legit votes -# -# Notes: -# - All time values use ParsedDuration (examples: 30s, 2m, 10m, 1h) -# - Per-client bans ONLY apply when a real client IP is known -# (e.g. via PROXY protocol v1). If behind playit without PROXY, -# only tunnel-level throttling is used. -# ----------------------------------------------------------------------------- -ConnectionThrottle: - - # Master switch - Enabled: false - - # --------------------------------------------------------------------------- - # Tunnel detection - # - # If the remote socket IP matches one of these, the connection is treated as - # "tunnel mode" (e.g. playit.gg). - # - # In tunnel mode: - # - Lower failure thresholds are used - # - Longer throttle durations are applied - # - # IMPORTANT: - # - Do NOT add your own backend/proxy IPs here - # - Only add tunnel / egress IPs (playit, cloudflared, etc.) - # --------------------------------------------------------------------------- - TunnelRemoteIps: - - "127.0.0.1" # playit.gg egress (example) - # - "x.x.x.x" # add more if needed - - # --------------------------------------------------------------------------- - # Sliding failure window - # - # If this many failures occur within the window, hard throttling starts. - # - # Failures counted include: - # - Invalid V1 block size - # - RSA bad padding / key mismatch - # - Malformed JSON (V2) - # - Invalid token / signature - # --------------------------------------------------------------------------- - - # How long to track failures before resetting the counter - Window: "2m" - - # Failures within the window before throttling (normal / non-tunnel) - Failures: 20 - - # How long to block further connections once throttled - ThrottleFor: "5m" - - # --------------------------------------------------------------------------- - # Tunnel-mode overrides (playit, etc.) - # - # These are intentionally more aggressive because scanners all share - # the same tunnel IP. - # --------------------------------------------------------------------------- - - # Failures before throttling when in tunnel mode - TunnelFailures: 8 - - # Throttle duration when in tunnel mode - TunnelThrottleFor: "10m" - - # --------------------------------------------------------------------------- - # Per-client bans (ONLY when real client IP is known) - # - # Requires: - # - PROXY protocol v1 providing the real source IP - # - # If enabled and the same real IP repeatedly fails validation, - # that IP will be temporarily banned. - # - # If real IP is NOT known (typical playit setup), - # this section is ignored automatically. - # --------------------------------------------------------------------------- - PerClientBan: - - # Enable per-client banning - Enabled: true - - # Failures within the window before banning a real client IP - Failures: 6 - - # How long the real client IP is banned - BanFor: "15m" - - # --------------------------------------------------------------------------- - # Log rate limiting - # - # Prevents console spam by allowing only ONE warning per key - # (IP + error type) per window. - # - # Additional messages are suppressed and summarized. - # --------------------------------------------------------------------------- - LogWindow: "60s" - - -# If your using VotingPlugin you don't need this -# Doesn't support tokens yet -Forwarding: - server1: - Enabled: false - Host: '' - Port: '' - Key: '' - # If token is set a token will be used instead of the key - Token: '' +Debug: false +# The host VotifierPlus will listen on +host: 0.0.0.0 +# The port VotifierPlus will listen on +port: 8192 +# Enables Votifier v2 token/HMAC support while retaining V1 compatibility. +TokenSupport: false +# Rejects all legacy Votifier v1 RSA packets and forces a V2 handshake. +# Keep this false if any configured voting site only supports V1. +DisableV1: false + +# ----------------------------------------------------------------------------- +# Connection throttling & spam reduction for Votifier +# +# Purpose: +# - Reduce console spam from random scanners / port probes +# - Prevent CPU waste from repeated invalid votes (bad padding, short payloads) +# - Work safely behind tunnels (playit.gg) without blocking legit votes +# +# Notes: +# - All time values use ParsedDuration (examples: 30s, 2m, 10m, 1h) +# - Per-client bans ONLY apply when a real client IP is known +# (e.g. via PROXY protocol v1). If behind playit without PROXY, +# only tunnel-level throttling is used. +# ----------------------------------------------------------------------------- +ConnectionThrottle: + + # Master switch + Enabled: false + + # --------------------------------------------------------------------------- + # Tunnel detection + # + # If the remote socket IP matches one of these, the connection is treated as + # "tunnel mode" (e.g. playit.gg). + # + # In tunnel mode: + # - Lower failure thresholds are used + # - Longer throttle durations are applied + # + # IMPORTANT: + # - Do NOT add your own backend/proxy IPs here + # - Only add tunnel / egress IPs (playit, cloudflared, etc.) + # --------------------------------------------------------------------------- + TunnelRemoteIps: + - "127.0.0.1" # playit.gg egress (example) + # - "x.x.x.x" # add more if needed + + # --------------------------------------------------------------------------- + # Sliding failure window + # + # If this many failures occur within the window, hard throttling starts. + # + # Failures counted include: + # - Invalid V1 block size + # - RSA bad padding / key mismatch + # - Malformed JSON (V2) + # - Invalid token / signature + # --------------------------------------------------------------------------- + + # How long to track failures before resetting the counter + Window: "2m" + + # Failures within the window before throttling (normal / non-tunnel) + Failures: 20 + + # How long to block further connections once throttled + ThrottleFor: "5m" + + # --------------------------------------------------------------------------- + # Tunnel-mode overrides (playit, etc.) + # + # These are intentionally more aggressive because scanners all share + # the same tunnel IP. + # --------------------------------------------------------------------------- + + # Failures before throttling when in tunnel mode + TunnelFailures: 8 + + # Throttle duration when in tunnel mode + TunnelThrottleFor: "10m" + + # --------------------------------------------------------------------------- + # Per-client bans (ONLY when real client IP is known) + # + # Requires: + # - PROXY protocol v1 providing the real source IP + # + # If enabled and the same real IP repeatedly fails validation, + # that IP will be temporarily banned. + # + # If real IP is NOT known (typical playit setup), + # this section is ignored automatically. + # --------------------------------------------------------------------------- + PerClientBan: + + # Enable per-client banning + Enabled: true + + # Failures within the window before banning a real client IP + Failures: 6 + + # How long the real client IP is banned + BanFor: "15m" + + # --------------------------------------------------------------------------- + # Log rate limiting + # + # Prevents console spam by allowing only ONE warning per key + # (IP + error type) per window. + # + # Additional messages are suppressed and summarized. + # --------------------------------------------------------------------------- + LogWindow: "60s" + + +# If your using VotingPlugin you don't need this +# Doesn't support tokens yet +Forwarding: + server1: + Enabled: false + Host: '' + Port: '' + Key: '' + # If token is set a token will be used instead of the key + Token: '' \ No newline at end of file From 4de35749ecf2b84327331452f04b1b9c7081ae54 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 17 Aug 2026 19:17:23 -0600 Subject: [PATCH 21/25] Restore existing vote timeout log contract --- .../com/vexsoftware/votifier/net/VoteConnectionHandler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteConnectionHandler.java b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteConnectionHandler.java index c9b6b94..e3c553c 100644 --- a/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteConnectionHandler.java +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteConnectionHandler.java @@ -153,7 +153,7 @@ public Vote handle(Socket socket) { "Decryption failed: Invalid V1 vote block / public key mismatch from " + remoteIp); } catch (SocketTimeoutException ex) { throttleService.logWarning(receiver, "timeout|" + remoteIp, - "Connection timeout while reading vote data from " + remoteIp + " - " + ex.getMessage()); + "Connection timeout while waiting for vote payload from " + remoteIp + " - " + ex.getMessage()); } catch (SocketException ex) { throttleService.logWarning(receiver, "socket|" + remoteIp, "Connection error: Protocol error from " + remoteIp + " - " + ex.getLocalizedMessage()); From 1b66f1ebe7df617bf4e65e91248963fc13e6065f Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 17 Aug 2026 19:18:19 -0600 Subject: [PATCH 22/25] Use a valid V1 username length in security test --- .../bencodez/votifierplus/tests/VoteProtocolSecurityTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteProtocolSecurityTest.java b/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteProtocolSecurityTest.java index f335e49..da5e91f 100644 --- a/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteProtocolSecurityTest.java +++ b/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteProtocolSecurityTest.java @@ -92,14 +92,14 @@ public void testTokenCompatibilityModeStillAcceptsPresentV1Packet() throws Excep try (ServerSocket serverSocket = new ServerSocket(0); Socket client = new Socket("127.0.0.1", serverSocket.getLocalPort()); Socket accepted = serverSocket.accept()) { - client.getOutputStream().write(createV1Packet("compatibilityUser")); + client.getOutputStream().write(createV1Packet("compatibilityUsr")); client.getOutputStream().flush(); Future future = executor.submit(() -> handler.handle(accepted)); Vote vote = future.get(2, TimeUnit.SECONDS); assertNotNull(vote); - assertEquals("compatibilityUser", vote.getUsername()); + assertEquals("compatibilityUsr", vote.getUsername()); } } From 9eb7f34cc37c7606fe1f8768144cd3aeeaa90c2d Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 17 Aug 2026 19:22:30 -0600 Subject: [PATCH 23/25] Refresh DisableV1 policy on Bukkit reload --- .../main/java/com/vexsoftware/votifier/config/Config.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/VotifierPlus/src/main/java/com/vexsoftware/votifier/config/Config.java b/VotifierPlus/src/main/java/com/vexsoftware/votifier/config/Config.java index 390b1f3..35f21e3 100644 --- a/VotifierPlus/src/main/java/com/vexsoftware/votifier/config/Config.java +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/config/Config.java @@ -31,6 +31,12 @@ public void loadValues() { VoteProtocolPolicy.setDisableV1(disableV1); } + @Override + public void reloadData() { + super.reloadData(); + VoteProtocolPolicy.setDisableV1(getData().getBoolean("DisableV1", false)); + } + @Override public void onFileCreation() { VotifierPlus.getInstance().saveResource("config.yml", true); From ed69d289109724706264004ae10db387fe9d3a43 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 17 Aug 2026 19:25:16 -0600 Subject: [PATCH 24/25] Read fragmented V1 packets through the parser --- .../vexsoftware/votifier/net/VoteConnectionHandler.java | 8 -------- 1 file changed, 8 deletions(-) diff --git a/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteConnectionHandler.java b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteConnectionHandler.java index e3c553c..143cbc3 100644 --- a/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteConnectionHandler.java +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteConnectionHandler.java @@ -90,14 +90,6 @@ public Vote handle(Socket socket) { throw new VoteAuthenticationException("Votifier V1 votes are disabled by configuration"); } - if (version == VoteProtocolVersion.V1 && in.available() < 256) { - throttleService.fail(throttleKey, tunnelMode, realIpKnown); - throttleService.logWarning(receiver, "shortv1|" + throttleKey, - "Invalid vote format: Insufficient data for V1 vote block from " - + (realIpKnown ? realIp : remoteIp) + " (expected 256 bytes)"); - return null; - } - VoteRequest request = voteParser.parse(in, version, receiver, address, challenge); Vote vote = new Vote(); From 378fc73caf980ca83d313e3a45c46eb12c04bcc2 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 17 Aug 2026 19:26:38 -0600 Subject: [PATCH 25/25] Test fragmented V1 packet handling --- .../tests/VoteProtocolSecurityTest.java | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteProtocolSecurityTest.java b/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteProtocolSecurityTest.java index da5e91f..59d5896 100644 --- a/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteProtocolSecurityTest.java +++ b/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteProtocolSecurityTest.java @@ -103,6 +103,35 @@ public void testTokenCompatibilityModeStillAcceptsPresentV1Packet() throws Excep } } + @Test + public void testFragmentedV1PacketIsReadCompletely() throws Exception { + receiver.setUseTokens(false); + VoteProtocolPolicy.setDisableV1(false); + VoteConnectionHandler handler = new VoteConnectionHandler(receiver, new VoteThrottleService(null)); + + try (ServerSocket serverSocket = new ServerSocket(0); + Socket client = new Socket("127.0.0.1", serverSocket.getLocalPort()); + Socket accepted = serverSocket.accept()) { + Future future = executor.submit(() -> handler.handle(accepted)); + BufferedReader reader = new BufferedReader( + new InputStreamReader(client.getInputStream(), StandardCharsets.UTF_8)); + OutputStream output = client.getOutputStream(); + + assertEquals("VOTIFIER 1", reader.readLine()); + byte[] packet = createV1Packet("fragmentedUser"); + output.write(packet, 0, 32); + output.flush(); + Thread.sleep(50); + output.write(packet, 32, packet.length - 32); + output.flush(); + + assertTrue(reader.readLine().contains("\"status\":\"ok\"")); + Vote vote = future.get(2, TimeUnit.SECONDS); + assertNotNull(vote); + assertEquals("fragmentedUser", vote.getUsername()); + } + } + @Test public void testDisableV1RejectsDelayedV1PacketInTokenMode() throws Exception { receiver.setUseTokens(true);