From 3648fe59c6265e8e2b9ac17b92f1fe870c707b7f Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 26 Aug 2026 19:38:31 -0600 Subject: [PATCH 01/13] Avoid V1 and V2 magic-prefix collisions --- .../vexsoftware/votifier/net/VoteParser.java | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteParser.java b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteParser.java index 8fe76d0..a146368 100644 --- a/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteParser.java +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteParser.java @@ -29,6 +29,7 @@ public class VoteParser { private static final Gson GSON = new Gson(); private static final short PROTOCOL_2_MAGIC = (short) 0x733A; + private static final int PROTOCOL_2_PREFIX_BYTES = 5; private static final String FIELD_PAYLOAD = "payload"; private static final String FIELD_SIGNATURE = "signature"; @@ -47,21 +48,30 @@ public class VoteParser { * @throws Exception if there is not enough data to determine the protocol */ public VoteProtocolVersion detectVersion(PushbackInputStream in) throws Exception { - byte[] header = new byte[2]; - int bytesRead = in.read(header); + byte[] header = new byte[PROTOCOL_2_PREFIX_BYTES]; + int bytesRead = 0; + while (bytesRead < header.length) { + int read = in.read(header, bytesRead, header.length - bytesRead); + if (read == -1) { + break; + } + bytesRead += read; + } + if (bytesRead < 2) { throw new Exception("Not enough data available to determine vote protocol version."); } + in.unread(header, 0, bytesRead); + if ((char) header[0] == '{') { - in.unread(header, 0, bytesRead); return VoteProtocolVersion.V2; } - in.unread(header, 0, bytesRead); - short magic = (short) (((header[0] & 0xFF) << 8) | (header[1] & 0xFF)); - if (magic == PROTOCOL_2_MAGIC) { + int payloadLength = ((header[2] & 0xFF) << 8) | (header[3] & 0xFF); + if (bytesRead == PROTOCOL_2_PREFIX_BYTES && magic == PROTOCOL_2_MAGIC && payloadLength > 0 + && header[4] == '{') { return VoteProtocolVersion.V2; } From a5232ae520d668b0e357ec399af02c722d58371d Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 26 Aug 2026 19:38:33 -0600 Subject: [PATCH 02/13] Add protocol magic-collision regression coverage --- .../votifierplus/tests/VoteReceiverTest.java | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteReceiverTest.java b/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteReceiverTest.java index 97eeade..6d981b0 100644 --- a/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteReceiverTest.java +++ b/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteReceiverTest.java @@ -186,6 +186,38 @@ public void testDetectV2VoteProtocol() throws Exception { assertEquals(VoteProtocolVersion.V2, version); } + @Test + public void testDetectFramedV2VoteProtocol() throws Exception { + byte[] jsonPayload = "{\"payload\":\"{}\",\"signature\":\"dGVzdA==\"}".getBytes(StandardCharsets.UTF_8); + ByteArrayOutputStream framedPayload = new ByteArrayOutputStream(); + framedPayload.write(0x73); + framedPayload.write(0x3A); + framedPayload.write((jsonPayload.length >>> 8) & 0xFF); + framedPayload.write(jsonPayload.length & 0xFF); + framedPayload.write(jsonPayload); + + PushbackInputStream in = new PushbackInputStream( + new ByteArrayInputStream(framedPayload.toByteArray()), 512); + + VoteProtocolVersion version = parser.detectVersion(in); + assertEquals(VoteProtocolVersion.V2, version); + } + + @Test + public void testV1MagicPrefixCollisionIsNotDetectedAsV2() throws Exception { + byte[] v1Block = new byte[256]; + v1Block[0] = 0x73; + v1Block[1] = 0x3A; + v1Block[2] = 0; + v1Block[3] = 64; + v1Block[4] = 1; + + PushbackInputStream in = new PushbackInputStream(new ByteArrayInputStream(v1Block), 512); + + VoteProtocolVersion version = parser.detectVersion(in); + assertEquals(VoteProtocolVersion.V1, version); + } + @Test public void testParseV2Vote() throws Exception { JsonObject inner = new JsonObject(); From 0c724470941d89c4dde1b64083b6f3c3a4d0982a Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 26 Aug 2026 19:47:42 -0600 Subject: [PATCH 03/13] Fall back to V1 after ambiguous V2 candidates --- .../vexsoftware/votifier/net/VoteParser.java | 41 +++++++++++++------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteParser.java b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteParser.java index a146368..fa05edc 100644 --- a/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteParser.java +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteParser.java @@ -6,6 +6,7 @@ */ package com.vexsoftware.votifier.net; +import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.PushbackInputStream; import java.nio.charset.StandardCharsets; @@ -29,7 +30,8 @@ public class VoteParser { private static final Gson GSON = new Gson(); private static final short PROTOCOL_2_MAGIC = (short) 0x733A; - private static final int PROTOCOL_2_PREFIX_BYTES = 5; + private static final int PROTOCOL_VERSION_PREFIX_BYTES = 2; + private static final int V1_BLOCK_BYTES = 256; private static final String FIELD_PAYLOAD = "payload"; private static final String FIELD_SIGNATURE = "signature"; @@ -48,7 +50,7 @@ public class VoteParser { * @throws Exception if there is not enough data to determine the protocol */ public VoteProtocolVersion detectVersion(PushbackInputStream in) throws Exception { - byte[] header = new byte[PROTOCOL_2_PREFIX_BYTES]; + byte[] header = new byte[PROTOCOL_VERSION_PREFIX_BYTES]; int bytesRead = 0; while (bytesRead < header.length) { int read = in.read(header, bytesRead, header.length - bytesRead); @@ -69,9 +71,7 @@ public VoteProtocolVersion detectVersion(PushbackInputStream in) throws Exceptio } short magic = (short) (((header[0] & 0xFF) << 8) | (header[1] & 0xFF)); - int payloadLength = ((header[2] & 0xFF) << 8) | (header[3] & 0xFF); - if (bytesRead == PROTOCOL_2_PREFIX_BYTES && magic == PROTOCOL_2_MAGIC && payloadLength > 0 - && header[4] == '{') { + if (magic == PROTOCOL_2_MAGIC) { return VoteProtocolVersion.V2; } @@ -94,11 +94,25 @@ public VoteRequest parse(PushbackInputStream in, VoteProtocolVersion version, Vo if (version == VoteProtocolVersion.V1) { return parseV1(in, receiver, address); } - return parseV2(in, receiver, address, challenge); + + byte[] voteData = readVoteData(in); + try { + return parseV2(voteData, receiver, address, challenge); + } catch (Exception v2Failure) { + if (!receiver.isDisableV1() && voteData.length == V1_BLOCK_BYTES) { + try { + return parseV1(new PushbackInputStream(new ByteArrayInputStream(voteData), V1_BLOCK_BYTES), receiver, + address); + } catch (Exception v1Failure) { + v2Failure.addSuppressed(v1Failure); + } + } + throw v2Failure; + } } private VoteRequest parseV1(PushbackInputStream in, VoteReceiver receiver, String address) throws Exception { - byte[] block = new byte[256]; + byte[] block = new byte[V1_BLOCK_BYTES]; int totalRead = 0; while (totalRead < block.length) { @@ -109,9 +123,9 @@ private VoteRequest parseV1(PushbackInputStream in, VoteReceiver receiver, Strin totalRead += read; } - if (totalRead != 256) { + if (totalRead != V1_BLOCK_BYTES) { throw new InvalidVoteException("Failed to read complete V1 vote block from " + address - + " (expected 256 bytes, got " + totalRead + ")"); + + " (expected " + V1_BLOCK_BYTES + " bytes, got " + totalRead + ")"); } byte[] decrypted; @@ -151,8 +165,7 @@ private VoteRequest parseV1(PushbackInputStream in, VoteReceiver receiver, Strin return request; } - private VoteRequest parseV2(PushbackInputStream in, VoteReceiver receiver, String address, String challenge) - throws Exception { + private byte[] readVoteData(PushbackInputStream in) throws Exception { ByteArrayOutputStream data = new ByteArrayOutputStream(); int b; while ((b = in.read()) != -1) { @@ -161,8 +174,12 @@ private VoteRequest parseV2(PushbackInputStream in, VoteReceiver receiver, Strin break; } } + return data.toByteArray(); + } - String voteData = data.toString("UTF-8").trim(); + private VoteRequest parseV2(byte[] data, VoteReceiver receiver, String address, String challenge) + throws Exception { + String voteData = new String(data, StandardCharsets.UTF_8).trim(); receiver.debug("Received raw V2 vote payload: [" + voteData + "]"); int firstBrace = voteData.indexOf('{'); From 8215bbbbcb17b41bd8ae0c1d9c43a7fb0f916a2d Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 26 Aug 2026 19:47:45 -0600 Subject: [PATCH 04/13] Cover V1 fallbacks and whitespace-framed V2 --- .../votifierplus/tests/VoteReceiverTest.java | 63 ++++++++++++++++--- 1 file changed, 55 insertions(+), 8 deletions(-) diff --git a/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteReceiverTest.java b/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteReceiverTest.java index 6d981b0..da42fca 100644 --- a/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteReceiverTest.java +++ b/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteReceiverTest.java @@ -76,6 +76,7 @@ public void tearDown() { private static class TestVoteReceiver extends VoteReceiver { private final String testChallenge = "testChallenge"; + private boolean disableV1; public TestVoteReceiver(String host, int port) throws Exception { super(host, port); @@ -86,6 +87,15 @@ public boolean isUseTokens() { return false; } + @Override + public boolean isDisableV1() { + return disableV1; + } + + public void setDisableV1(boolean disableV1) { + this.disableV1 = disableV1; + } + @Override public void logWarning(String warn) { } @@ -148,10 +158,9 @@ public ThrottleConfig getThrottleConfig() { @Test public void testDetectV1VoteProtocol() throws Exception { - String voteMsg = "VOTE\nvotifier.bencodez.com\ntestUser\n127.0.0.1\nTestTimestamp\n"; - Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding"); - cipher.init(Cipher.ENCRYPT_MODE, testKeyPair.getPublic()); - byte[] encrypted = cipher.doFinal(voteMsg.getBytes(StandardCharsets.UTF_8)); + byte[] encrypted = new byte[256]; + encrypted[0] = 1; + encrypted[1] = 2; PushbackInputStream in = new PushbackInputStream(new ByteArrayInputStream(encrypted), 512); VoteProtocolVersion version = parser.detectVersion(in); @@ -188,7 +197,7 @@ public void testDetectV2VoteProtocol() throws Exception { @Test public void testDetectFramedV2VoteProtocol() throws Exception { - byte[] jsonPayload = "{\"payload\":\"{}\",\"signature\":\"dGVzdA==\"}".getBytes(StandardCharsets.UTF_8); + byte[] jsonPayload = " \r\n{\"payload\":\"{}\",\"signature\":\"dGVzdA==\"}".getBytes(StandardCharsets.UTF_8); ByteArrayOutputStream framedPayload = new ByteArrayOutputStream(); framedPayload.write(0x73); framedPayload.write(0x3A); @@ -204,18 +213,56 @@ public void testDetectFramedV2VoteProtocol() throws Exception { } @Test - public void testV1MagicPrefixCollisionIsNotDetectedAsV2() throws Exception { + public void testV1MagicPrefixCollisionAttemptsV1Fallback() throws Exception { byte[] v1Block = new byte[256]; v1Block[0] = 0x73; v1Block[1] = 0x3A; v1Block[2] = 0; v1Block[3] = 64; - v1Block[4] = 1; + v1Block[4] = '{'; + v1Block[5] = '}'; PushbackInputStream in = new PushbackInputStream(new ByteArrayInputStream(v1Block), 512); VoteProtocolVersion version = parser.detectVersion(in); - assertEquals(VoteProtocolVersion.V1, version); + assertEquals(VoteProtocolVersion.V2, version); + Exception failure = assertThrows(Exception.class, + () -> parser.parse(in, version, receiver, "test-address", receiver.getChallenge())); + assertEquals(1, failure.getSuppressed().length); + } + + @Test + public void testV1MagicPrefixCollisionDoesNotFallbackWhenV1IsDisabled() throws Exception { + byte[] v1Block = new byte[256]; + v1Block[0] = 0x73; + v1Block[1] = 0x3A; + v1Block[2] = 0; + v1Block[3] = 64; + v1Block[4] = '{'; + v1Block[5] = '}'; + receiver.setDisableV1(true); + + PushbackInputStream in = new PushbackInputStream(new ByteArrayInputStream(v1Block), 512); + + VoteProtocolVersion version = parser.detectVersion(in); + Exception failure = assertThrows(Exception.class, + () -> parser.parse(in, version, receiver, "test-address", receiver.getChallenge())); + assertEquals(0, failure.getSuppressed().length); + } + + @Test + public void testV1JsonPrefixCollisionAttemptsV1Fallback() throws Exception { + byte[] v1Block = new byte[256]; + v1Block[0] = '{'; + v1Block[1] = '}'; + + PushbackInputStream in = new PushbackInputStream(new ByteArrayInputStream(v1Block), 512); + + VoteProtocolVersion version = parser.detectVersion(in); + assertEquals(VoteProtocolVersion.V2, version); + Exception failure = assertThrows(Exception.class, + () -> parser.parse(in, version, receiver, "test-address", receiver.getChallenge())); + assertEquals(1, failure.getSuppressed().length); } @Test From 7f5dac2bd28e324638ba9b3f31e3f5a6402568ce Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 26 Aug 2026 19:56:16 -0600 Subject: [PATCH 05/13] Handle fragmented protocol collision candidates --- .../vexsoftware/votifier/net/VoteParser.java | 59 ++++++++++++++----- 1 file changed, 44 insertions(+), 15 deletions(-) diff --git a/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteParser.java b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteParser.java index fa05edc..5a266ec 100644 --- a/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteParser.java +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteParser.java @@ -9,6 +9,7 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.PushbackInputStream; +import java.net.SocketTimeoutException; import java.nio.charset.StandardCharsets; import java.security.Key; import java.util.Base64; @@ -32,6 +33,7 @@ public class VoteParser { private static final short PROTOCOL_2_MAGIC = (short) 0x733A; private static final int PROTOCOL_VERSION_PREFIX_BYTES = 2; private static final int V1_BLOCK_BYTES = 256; + private static final int MAX_V2_PACKET_BYTES = 4 + 0xFFFF; private static final String FIELD_PAYLOAD = "payload"; private static final String FIELD_SIGNATURE = "signature"; @@ -95,19 +97,41 @@ public VoteRequest parse(PushbackInputStream in, VoteProtocolVersion version, Vo return parseV1(in, receiver, address); } - byte[] voteData = readVoteData(in); - try { - return parseV2(voteData, receiver, address, challenge); - } catch (Exception v2Failure) { - if (!receiver.isDisableV1() && voteData.length == V1_BLOCK_BYTES) { + ByteArrayOutputStream voteData = new ByteArrayOutputStream(); + readMoreVoteData(in, voteData); + boolean v1FallbackAttempted = false; + + while (true) { + byte[] candidate = voteData.toByteArray(); + Exception v2Failure; + try { + return parseV2(candidate, receiver, address, challenge); + } catch (Exception ex) { + v2Failure = ex; + } + + if (!receiver.isDisableV1() && !v1FallbackAttempted && candidate.length == V1_BLOCK_BYTES) { + v1FallbackAttempted = true; try { - return parseV1(new PushbackInputStream(new ByteArrayInputStream(voteData), V1_BLOCK_BYTES), receiver, + return parseV1(new PushbackInputStream(new ByteArrayInputStream(candidate), V1_BLOCK_BYTES), receiver, address); } catch (Exception v1Failure) { v2Failure.addSuppressed(v1Failure); } } - throw v2Failure; + + if (candidate.length >= MAX_V2_PACKET_BYTES) { + throw v2Failure; + } + + try { + if (!readMoreVoteData(in, voteData)) { + throw v2Failure; + } + } catch (SocketTimeoutException ex) { + v2Failure.addSuppressed(ex); + throw v2Failure; + } } } @@ -165,16 +189,21 @@ private VoteRequest parseV1(PushbackInputStream in, VoteReceiver receiver, Strin return request; } - private byte[] readVoteData(PushbackInputStream in) throws Exception { - ByteArrayOutputStream data = new ByteArrayOutputStream(); - int b; - while ((b = in.read()) != -1) { - data.write(b); - if (in.available() == 0) { + private boolean readMoreVoteData(PushbackInputStream in, ByteArrayOutputStream data) throws Exception { + int next = in.read(); + if (next == -1) { + return false; + } + data.write(next); + + while (data.size() < MAX_V2_PACKET_BYTES && in.available() > 0) { + next = in.read(); + if (next == -1) { break; } + data.write(next); } - return data.toByteArray(); + return true; } private VoteRequest parseV2(byte[] data, VoteReceiver receiver, String address, String challenge) @@ -316,4 +345,4 @@ private boolean hmacEqual(byte[] providedSig, byte[] data, Key key) throws Excep } return diff == 0; } -} \ No newline at end of file +} From b352edbf0d205ae4c65333eeac484ef1e34356aa Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 26 Aug 2026 19:56:24 -0600 Subject: [PATCH 06/13] Test fragmented V1 collision fallback --- .../votifierplus/tests/VoteReceiverTest.java | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteReceiverTest.java b/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteReceiverTest.java index da42fca..d3cc9bb 100644 --- a/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteReceiverTest.java +++ b/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteReceiverTest.java @@ -231,6 +231,31 @@ public void testV1MagicPrefixCollisionAttemptsV1Fallback() throws Exception { assertEquals(1, failure.getSuppressed().length); } + @Test + public void testFragmentedV1MagicPrefixCollisionAttemptsV1Fallback() throws Exception { + byte[] v1Block = new byte[256]; + v1Block[0] = 0x73; + v1Block[1] = 0x3A; + v1Block[2] = 0; + v1Block[3] = 64; + v1Block[4] = '{'; + v1Block[5] = '}'; + + ByteArrayInputStream fragmented = new ByteArrayInputStream(v1Block) { + @Override + public synchronized int available() { + return pos <= 2 ? 0 : super.available(); + } + }; + PushbackInputStream in = new PushbackInputStream(fragmented, 512); + + VoteProtocolVersion version = parser.detectVersion(in); + assertEquals(VoteProtocolVersion.V2, version); + Exception failure = assertThrows(Exception.class, + () -> parser.parse(in, version, receiver, "test-address", receiver.getChallenge())); + assertEquals(1, failure.getSuppressed().length); + } + @Test public void testV1MagicPrefixCollisionDoesNotFallbackWhenV1IsDisabled() throws Exception { byte[] v1Block = new byte[256]; @@ -499,4 +524,4 @@ public void testBuildVoteFromParsedRequest() throws Exception { assertEquals("TestTimestamp", vote.getTimeStamp()); assertEquals("192.168.1.1", vote.getSourceAddress()); } -} \ No newline at end of file +} From 2ed170ef5a93ef15a5aba070eaf590eaf6b20d2d Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 26 Aug 2026 20:07:56 -0600 Subject: [PATCH 07/13] Honor V2 frame boundaries --- .../vexsoftware/votifier/net/VoteParser.java | 195 +++++++++++++++--- 1 file changed, 162 insertions(+), 33 deletions(-) diff --git a/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteParser.java b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteParser.java index 5a266ec..ee9068e 100644 --- a/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteParser.java +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteParser.java @@ -9,7 +9,6 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.PushbackInputStream; -import java.net.SocketTimeoutException; import java.nio.charset.StandardCharsets; import java.security.Key; import java.util.Base64; @@ -98,40 +97,114 @@ public VoteRequest parse(PushbackInputStream in, VoteProtocolVersion version, Vo } ByteArrayOutputStream voteData = new ByteArrayOutputStream(); - readMoreVoteData(in, voteData); - boolean v1FallbackAttempted = false; + if (!readToSize(in, voteData, PROTOCOL_VERSION_PREFIX_BYTES)) { + throw new InvalidVoteException("Incomplete V2 protocol prefix from " + address); + } + + byte[] prefix = voteData.toByteArray(); + short magic = (short) (((prefix[0] & 0xFF) << 8) | (prefix[1] & 0xFF)); + if (magic == PROTOCOL_2_MAGIC) { + return parseFramedV2(in, voteData, receiver, address, challenge); + } + if ((char) prefix[0] == '{') { + return parseUnframedV2(in, voteData, receiver, address, challenge); + } + + throw new InvalidVoteException("Invalid V2 protocol prefix from " + address); + } - while (true) { - byte[] candidate = voteData.toByteArray(); - Exception v2Failure; + private VoteRequest parseFramedV2(PushbackInputStream in, ByteArrayOutputStream voteData, VoteReceiver receiver, + String address, String challenge) throws Exception { + if (!readToSize(in, voteData, 4)) { + throw new InvalidVoteException("Incomplete V2 frame header from " + address); + } + + byte[] header = voteData.toByteArray(); + int payloadBytes = ((header[2] & 0xFF) << 8) | (header[3] & 0xFF); + int frameBytes = 4 + payloadBytes; + Exception v1Failure = null; + + // A randomized V1 block can claim a framed length greater than 256. Test + // the complete V1-sized prefix before blocking for the rest of that frame. + if (frameBytes > V1_BLOCK_BYTES && !receiver.isDisableV1()) { + if (!readToSize(in, voteData, V1_BLOCK_BYTES)) { + throw new InvalidVoteException("Incomplete V2 frame from " + address + " (expected " + frameBytes + + " bytes, got " + voteData.size() + ")"); + } try { - return parseV2(candidate, receiver, address, challenge); + return parseV1Candidate(voteData.toByteArray(), receiver, address); } catch (Exception ex) { - v2Failure = ex; + v1Failure = ex; } + } + + if (!readToSize(in, voteData, frameBytes)) { + throw new InvalidVoteException("Incomplete V2 frame from " + address + " (expected " + frameBytes + + " bytes, got " + voteData.size() + ")"); + } - if (!receiver.isDisableV1() && !v1FallbackAttempted && candidate.length == V1_BLOCK_BYTES) { - v1FallbackAttempted = true; + try { + return parseV2(voteData.toByteArray(), receiver, address, challenge); + } catch (Exception v2Failure) { + if (v1Failure != null) { + v2Failure.addSuppressed(v1Failure); + } + return fallbackToBufferedV1OrThrow(in, voteData, receiver, address, v2Failure); + } + } + + private VoteRequest parseUnframedV2(PushbackInputStream in, ByteArrayOutputStream voteData, VoteReceiver receiver, + String address, String challenge) throws Exception { + JsonObjectBoundaryScanner scanner = new JsonObjectBoundaryScanner(); + byte[] prefix = voteData.toByteArray(); + boolean complete = scanner.scan(prefix, 0, prefix.length) >= 0; + Exception v1Failure = null; + boolean v1Attempted = false; + byte[] buffer = new byte[4096]; + + while (!complete) { + if (!receiver.isDisableV1() && !v1Attempted && voteData.size() == V1_BLOCK_BYTES) { + v1Attempted = true; try { - return parseV1(new PushbackInputStream(new ByteArrayInputStream(candidate), V1_BLOCK_BYTES), receiver, - address); - } catch (Exception v1Failure) { - v2Failure.addSuppressed(v1Failure); + return parseV1Candidate(voteData.toByteArray(), receiver, address); + } catch (Exception ex) { + v1Failure = ex; } } - if (candidate.length >= MAX_V2_PACKET_BYTES) { - throw v2Failure; + if (voteData.size() >= MAX_V2_PACKET_BYTES) { + InvalidVoteException failure = new InvalidVoteException( + "V2 JSON payload exceeds maximum size from " + address); + if (v1Failure != null) { + failure.addSuppressed(v1Failure); + } + throw failure; } - try { - if (!readMoreVoteData(in, voteData)) { - throw v2Failure; + int nextBoundary = voteData.size() < V1_BLOCK_BYTES ? V1_BLOCK_BYTES : MAX_V2_PACKET_BYTES; + int maxRead = Math.min(buffer.length, nextBoundary - voteData.size()); + int read = in.read(buffer, 0, maxRead); + if (read == -1) { + InvalidVoteException failure = new InvalidVoteException("Incomplete V2 JSON payload from " + address); + if (v1Failure != null) { + failure.addSuppressed(v1Failure); } - } catch (SocketTimeoutException ex) { - v2Failure.addSuppressed(ex); - throw v2Failure; + throw failure; + } + + int completeAt = scanner.scan(buffer, 0, read); + int bytesToKeep = completeAt >= 0 ? completeAt : read; + voteData.write(buffer, 0, bytesToKeep); + complete = completeAt >= 0; + } + + try { + return parseV2(voteData.toByteArray(), receiver, address, challenge); + } catch (Exception v2Failure) { + if (v1Failure != null) { + v2Failure.addSuppressed(v1Failure); } + return fallbackToBufferedV1OrThrow(in, voteData, receiver, address, v2Failure); } } @@ -189,21 +262,77 @@ private VoteRequest parseV1(PushbackInputStream in, VoteReceiver receiver, Strin return request; } - private boolean readMoreVoteData(PushbackInputStream in, ByteArrayOutputStream data) throws Exception { - int next = in.read(); - if (next == -1) { - return false; + private boolean readToSize(PushbackInputStream in, ByteArrayOutputStream data, int targetBytes) throws Exception { + byte[] buffer = new byte[Math.min(4096, Math.max(1, targetBytes - data.size()))]; + while (data.size() < targetBytes) { + int read = in.read(buffer, 0, Math.min(buffer.length, targetBytes - data.size())); + if (read == -1) { + return false; + } + data.write(buffer, 0, read); } - data.write(next); + return true; + } - while (data.size() < MAX_V2_PACKET_BYTES && in.available() > 0) { - next = in.read(); - if (next == -1) { - break; + private VoteRequest fallbackToBufferedV1OrThrow(PushbackInputStream in, ByteArrayOutputStream voteData, + VoteReceiver receiver, String address, Exception v2Failure) throws Exception { + if (receiver.isDisableV1() || voteData.size() > V1_BLOCK_BYTES) { + throw v2Failure; + } + + int remaining = V1_BLOCK_BYTES - voteData.size(); + if (remaining > 0) { + // Do not hold a connection worker after a complete V2 packet. A colliding + // V1 sender has already written the rest of its fixed-size block, so only + // consume it when the whole remainder is currently buffered. + if (in.available() < remaining || !readToSize(in, voteData, V1_BLOCK_BYTES)) { + throw v2Failure; } - data.write(next); } - return true; + + try { + return parseV1Candidate(voteData.toByteArray(), receiver, address); + } catch (Exception v1Failure) { + v2Failure.addSuppressed(v1Failure); + throw v2Failure; + } + } + + private VoteRequest parseV1Candidate(byte[] candidate, VoteReceiver receiver, String address) throws Exception { + return parseV1(new PushbackInputStream(new ByteArrayInputStream(candidate), V1_BLOCK_BYTES), receiver, address); + } + + private static class JsonObjectBoundaryScanner { + private int depth; + private boolean escaped; + private boolean inString; + private boolean started; + + private int scan(byte[] data, int offset, int length) { + for (int i = offset; i < offset + length; i++) { + char current = (char) (data[i] & 0xFF); + if (inString) { + if (escaped) { + escaped = false; + } else if (current == '\\') { + escaped = true; + } else if (current == '"') { + inString = false; + } + continue; + } + + if (current == '"') { + inString = true; + } else if (current == '{') { + started = true; + depth++; + } else if (current == '}' && started && --depth == 0) { + return i - offset + 1; + } + } + return -1; + } } private VoteRequest parseV2(byte[] data, VoteReceiver receiver, String address, String challenge) From 8281ff4523c6dee54522493b5488cdf80ee58fa8 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 26 Aug 2026 20:08:02 -0600 Subject: [PATCH 08/13] Test framed V2 boundaries and fragmentation --- .../votifierplus/tests/VoteReceiverTest.java | 102 +++++++++++++----- 1 file changed, 77 insertions(+), 25 deletions(-) diff --git a/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteReceiverTest.java b/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteReceiverTest.java index d3cc9bb..25d58a9 100644 --- a/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteReceiverTest.java +++ b/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteReceiverTest.java @@ -198,20 +198,42 @@ public void testDetectV2VoteProtocol() throws Exception { @Test public void testDetectFramedV2VoteProtocol() throws Exception { byte[] jsonPayload = " \r\n{\"payload\":\"{}\",\"signature\":\"dGVzdA==\"}".getBytes(StandardCharsets.UTF_8); - ByteArrayOutputStream framedPayload = new ByteArrayOutputStream(); - framedPayload.write(0x73); - framedPayload.write(0x3A); - framedPayload.write((jsonPayload.length >>> 8) & 0xFF); - framedPayload.write(jsonPayload.length & 0xFF); - framedPayload.write(jsonPayload); PushbackInputStream in = new PushbackInputStream( - new ByteArrayInputStream(framedPayload.toByteArray()), 512); + new ByteArrayInputStream(frameV2Payload(jsonPayload)), 512); VoteProtocolVersion version = parser.detectVersion(in); assertEquals(VoteProtocolVersion.V2, version); } + @Test + public void testCompleteInvalidFramedV2DoesNotReadPastDeclaredLength() throws Exception { + byte[] jsonPayload = "{\"signature\":\"dummySignature\"}".getBytes(StandardCharsets.UTF_8); + byte[] framedPayload = frameV2Payload(jsonPayload); + ByteArrayInputStream boundaryChecked = new ByteArrayInputStream(framedPayload) { + @Override + public synchronized int read() { + if (pos >= count) { + throw new AssertionError("Parser read past the declared V2 frame boundary"); + } + return super.read(); + } + + @Override + public synchronized int read(byte[] bytes, int offset, int length) { + if (pos >= count) { + throw new AssertionError("Parser read past the declared V2 frame boundary"); + } + return super.read(bytes, offset, length); + } + }; + PushbackInputStream in = new PushbackInputStream(boundaryChecked, 512); + + VoteProtocolVersion version = parser.detectVersion(in); + assertThrows(InvalidVoteException.class, + () -> parser.parse(in, version, receiver, "test-address", receiver.getChallenge())); + } + @Test public void testV1MagicPrefixCollisionAttemptsV1Fallback() throws Exception { byte[] v1Block = new byte[256]; @@ -292,25 +314,8 @@ public void testV1JsonPrefixCollisionAttemptsV1Fallback() throws Exception { @Test public void testParseV2Vote() throws Exception { - JsonObject inner = new JsonObject(); - inner.addProperty("serviceName", "votifier.bencodez.com"); - inner.addProperty("username", "testUserV2"); - inner.addProperty("address", "127.0.0.1"); - inner.addProperty("timestamp", "TestTimestampV2"); - inner.addProperty("challenge", "testChallenge"); - String payload = inner.toString(); - - Mac mac = Mac.getInstance("HmacSHA256"); - mac.init(dummyTokenKey); - byte[] signatureBytes = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)); - String signature = Base64.getEncoder().encodeToString(signatureBytes); - - JsonObject outer = new JsonObject(); - outer.addProperty("payload", payload); - outer.addProperty("signature", signature); - PushbackInputStream in = new PushbackInputStream( - new ByteArrayInputStream(outer.toString().getBytes(StandardCharsets.UTF_8)), 512); + new ByteArrayInputStream(createSignedV2Payload("testUserV2")), 512); VoteRequest request = parser.parse(in, VoteProtocolVersion.V2, receiver, "test-address", receiver.getChallenge()); @@ -321,6 +326,23 @@ public void testParseV2Vote() throws Exception { assertEquals("TestTimestampV2", request.getTimeStamp()); } + @Test + public void testParseByteFragmentedFramedV2Vote() throws Exception { + byte[] framedPayload = frameV2Payload(createSignedV2Payload("fragmentedUser")); + ByteArrayInputStream fragmented = new ByteArrayInputStream(framedPayload) { + @Override + public synchronized int read(byte[] bytes, int offset, int length) { + return super.read(bytes, offset, Math.min(1, length)); + } + }; + PushbackInputStream in = new PushbackInputStream(fragmented, 512); + + VoteProtocolVersion version = parser.detectVersion(in); + VoteRequest request = parser.parse(in, version, receiver, "test-address", receiver.getChallenge()); + + assertEquals("fragmentedUser", request.getUsername()); + } + @Test public void testProxyV1Header() throws Exception { String proxyHeader = "PROXY TCP4 192.168.1.1 192.168.1.2 1234 80\r\n"; @@ -524,4 +546,34 @@ public void testBuildVoteFromParsedRequest() throws Exception { assertEquals("TestTimestamp", vote.getTimeStamp()); assertEquals("192.168.1.1", vote.getSourceAddress()); } + + private byte[] createSignedV2Payload(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", "TestTimestampV2"); + inner.addProperty("challenge", "testChallenge"); + String payload = inner.toString(); + + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(dummyTokenKey); + 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().getBytes(StandardCharsets.UTF_8); + } + + private byte[] frameV2Payload(byte[] jsonPayload) throws Exception { + ByteArrayOutputStream framedPayload = new ByteArrayOutputStream(); + framedPayload.write(0x73); + framedPayload.write(0x3A); + framedPayload.write((jsonPayload.length >>> 8) & 0xFF); + framedPayload.write(jsonPayload.length & 0xFF); + framedPayload.write(jsonPayload); + return framedPayload.toByteArray(); + } } From c5033b71ee520b457906a64ed4f1c21745c54c80 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 26 Aug 2026 20:16:08 -0600 Subject: [PATCH 09/13] Bound V1 collision fragment grace period --- .../vexsoftware/votifier/net/VoteParser.java | 60 +++++++++++++++---- 1 file changed, 49 insertions(+), 11 deletions(-) diff --git a/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteParser.java b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteParser.java index ee9068e..fe876ca 100644 --- a/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteParser.java +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteParser.java @@ -9,6 +9,8 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.PushbackInputStream; +import java.net.Socket; +import java.net.SocketTimeoutException; import java.nio.charset.StandardCharsets; import java.security.Key; import java.util.Base64; @@ -33,6 +35,7 @@ public class VoteParser { private static final int PROTOCOL_VERSION_PREFIX_BYTES = 2; private static final int V1_BLOCK_BYTES = 256; private static final int MAX_V2_PACKET_BYTES = 4 + 0xFFFF; + private static final int V1_COLLISION_GRACE_TIMEOUT_MS = 250; private static final String FIELD_PAYLOAD = "payload"; private static final String FIELD_SIGNATURE = "signature"; @@ -92,6 +95,24 @@ public VoteProtocolVersion detectVersion(PushbackInputStream in) throws Exceptio */ public VoteRequest parse(PushbackInputStream in, VoteProtocolVersion version, VoteReceiver receiver, String address, String challenge) throws Exception { + return parse(in, version, receiver, address, challenge, null); + } + + /** + * Parses a vote payload with access to the connection socket so an ambiguous + * V1 collision can receive a short, bounded TCP-fragment grace period. + * + * @param in the input stream + * @param version the detected protocol version + * @param receiver the vote receiver + * @param address remote address string for logging/errors + * @param challenge expected challenge for V2 + * @param socket accepted socket, or null when no timeout control is available + * @return parsed vote request data + * @throws Exception on parse/validation/authentication errors + */ + public VoteRequest parse(PushbackInputStream in, VoteProtocolVersion version, VoteReceiver receiver, String address, + String challenge, Socket socket) throws Exception { if (version == VoteProtocolVersion.V1) { return parseV1(in, receiver, address); } @@ -104,17 +125,17 @@ public VoteRequest parse(PushbackInputStream in, VoteProtocolVersion version, Vo byte[] prefix = voteData.toByteArray(); short magic = (short) (((prefix[0] & 0xFF) << 8) | (prefix[1] & 0xFF)); if (magic == PROTOCOL_2_MAGIC) { - return parseFramedV2(in, voteData, receiver, address, challenge); + return parseFramedV2(in, voteData, receiver, address, challenge, socket); } if ((char) prefix[0] == '{') { - return parseUnframedV2(in, voteData, receiver, address, challenge); + return parseUnframedV2(in, voteData, receiver, address, challenge, socket); } throw new InvalidVoteException("Invalid V2 protocol prefix from " + address); } private VoteRequest parseFramedV2(PushbackInputStream in, ByteArrayOutputStream voteData, VoteReceiver receiver, - String address, String challenge) throws Exception { + String address, String challenge, Socket socket) throws Exception { if (!readToSize(in, voteData, 4)) { throw new InvalidVoteException("Incomplete V2 frame header from " + address); } @@ -149,12 +170,12 @@ private VoteRequest parseFramedV2(PushbackInputStream in, ByteArrayOutputStream if (v1Failure != null) { v2Failure.addSuppressed(v1Failure); } - return fallbackToBufferedV1OrThrow(in, voteData, receiver, address, v2Failure); + return fallbackToBufferedV1OrThrow(in, voteData, receiver, address, v2Failure, socket); } } private VoteRequest parseUnframedV2(PushbackInputStream in, ByteArrayOutputStream voteData, VoteReceiver receiver, - String address, String challenge) throws Exception { + String address, String challenge, Socket socket) throws Exception { JsonObjectBoundaryScanner scanner = new JsonObjectBoundaryScanner(); byte[] prefix = voteData.toByteArray(); boolean complete = scanner.scan(prefix, 0, prefix.length) >= 0; @@ -204,7 +225,7 @@ private VoteRequest parseUnframedV2(PushbackInputStream in, ByteArrayOutputStrea if (v1Failure != null) { v2Failure.addSuppressed(v1Failure); } - return fallbackToBufferedV1OrThrow(in, voteData, receiver, address, v2Failure); + return fallbackToBufferedV1OrThrow(in, voteData, receiver, address, v2Failure, socket); } } @@ -275,17 +296,18 @@ private boolean readToSize(PushbackInputStream in, ByteArrayOutputStream data, i } private VoteRequest fallbackToBufferedV1OrThrow(PushbackInputStream in, ByteArrayOutputStream voteData, - VoteReceiver receiver, String address, Exception v2Failure) throws Exception { + VoteReceiver receiver, String address, Exception v2Failure, Socket socket) throws Exception { if (receiver.isDisableV1() || voteData.size() > V1_BLOCK_BYTES) { throw v2Failure; } int remaining = V1_BLOCK_BYTES - voteData.size(); if (remaining > 0) { - // Do not hold a connection worker after a complete V2 packet. A colliding - // V1 sender has already written the rest of its fixed-size block, so only - // consume it when the whole remainder is currently buffered. - if (in.available() < remaining || !readToSize(in, voteData, V1_BLOCK_BYTES)) { + if (socket == null) { + if (in.available() < remaining || !readToSize(in, voteData, V1_BLOCK_BYTES)) { + throw v2Failure; + } + } else if (!readV1CollisionRemainder(in, voteData, socket, v2Failure)) { throw v2Failure; } } @@ -298,6 +320,22 @@ private VoteRequest fallbackToBufferedV1OrThrow(PushbackInputStream in, ByteArra } } + private boolean readV1CollisionRemainder(PushbackInputStream in, ByteArrayOutputStream voteData, Socket socket, + Exception v2Failure) throws Exception { + int previousTimeout = socket.getSoTimeout(); + int graceTimeout = previousTimeout <= 0 ? V1_COLLISION_GRACE_TIMEOUT_MS + : Math.min(previousTimeout, V1_COLLISION_GRACE_TIMEOUT_MS); + try { + socket.setSoTimeout(graceTimeout); + return readToSize(in, voteData, V1_BLOCK_BYTES); + } catch (SocketTimeoutException ex) { + v2Failure.addSuppressed(ex); + return false; + } finally { + socket.setSoTimeout(previousTimeout); + } + } + private VoteRequest parseV1Candidate(byte[] candidate, VoteReceiver receiver, String address) throws Exception { return parseV1(new PushbackInputStream(new ByteArrayInputStream(candidate), V1_BLOCK_BYTES), receiver, address); } From c365644deba51953a96fba45f4c774350eab23c7 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 26 Aug 2026 20:16:14 -0600 Subject: [PATCH 10/13] Pass socket to collision-aware parser --- .../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 143cbc3..7da1835 100644 --- a/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteConnectionHandler.java +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteConnectionHandler.java @@ -90,7 +90,7 @@ public Vote handle(Socket socket) { throw new VoteAuthenticationException("Votifier V1 votes are disabled by configuration"); } - VoteRequest request = voteParser.parse(in, version, receiver, address, challenge); + VoteRequest request = voteParser.parse(in, version, receiver, address, challenge, accepted); Vote vote = new Vote(); vote.setServiceName(request.getServiceName()); From 32b47f1d7157c50991f019ab863f104624972b11 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 26 Aug 2026 20:16:21 -0600 Subject: [PATCH 11/13] Test delayed collision fragments and bounded rejection --- .../votifierplus/tests/VoteReceiverTest.java | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteReceiverTest.java b/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteReceiverTest.java index 25d58a9..7eee64c 100644 --- a/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteReceiverTest.java +++ b/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteReceiverTest.java @@ -10,6 +10,8 @@ import java.io.ByteArrayOutputStream; import java.io.OutputStreamWriter; import java.io.PushbackInputStream; +import java.net.ServerSocket; +import java.net.Socket; import java.nio.charset.StandardCharsets; import java.security.Key; import java.security.KeyPair; @@ -234,6 +236,30 @@ public synchronized int read(byte[] bytes, int offset, int length) { () -> parser.parse(in, version, receiver, "test-address", receiver.getChallenge())); } + @Test + public void testInvalidFramedV2CollisionGraceIsBounded() throws Exception { + byte[] framedPayload = frameV2Payload( + "{\"signature\":\"dummySignature\"}".getBytes(StandardCharsets.UTF_8)); + + try (ServerSocket server = new ServerSocket(0); + Socket client = new Socket("127.0.0.1", server.getLocalPort()); + Socket accepted = server.accept(); + PushbackInputStream in = new PushbackInputStream(accepted.getInputStream(), 512)) { + accepted.setSoTimeout(5000); + client.getOutputStream().write(framedPayload); + client.getOutputStream().flush(); + + VoteProtocolVersion version = parser.detectVersion(in); + long startedAt = System.nanoTime(); + assertThrows(InvalidVoteException.class, () -> parser.parse(in, version, receiver, "test-address", + receiver.getChallenge(), accepted)); + long elapsedMillis = (System.nanoTime() - startedAt) / 1_000_000; + + assertTrue(elapsedMillis < 1500, + "Invalid framed V2 packet exceeded bounded collision grace: " + elapsedMillis + "ms"); + } + } + @Test public void testV1MagicPrefixCollisionAttemptsV1Fallback() throws Exception { byte[] v1Block = new byte[256]; @@ -278,6 +304,42 @@ public synchronized int available() { assertEquals(1, failure.getSuppressed().length); } + @Test + public void testDelayedV1MagicCollisionRemainderUsesBoundedGraceRead() throws Exception { + byte[] v1Block = new byte[256]; + v1Block[0] = 0x73; + v1Block[1] = 0x3A; + v1Block[2] = 0; + v1Block[3] = 64; + v1Block[4] = '{'; + v1Block[5] = '}'; + + try (ServerSocket server = new ServerSocket(0); + Socket client = new Socket("127.0.0.1", server.getLocalPort()); + Socket accepted = server.accept(); + PushbackInputStream in = new PushbackInputStream(accepted.getInputStream(), 512)) { + client.getOutputStream().write(v1Block, 0, 68); + client.getOutputStream().flush(); + + Thread remainderWriter = new Thread(() -> { + try { + Thread.sleep(50); + client.getOutputStream().write(v1Block, 68, v1Block.length - 68); + client.getOutputStream().flush(); + } catch (Exception ex) { + throw new RuntimeException(ex); + } + }); + remainderWriter.start(); + + VoteProtocolVersion version = parser.detectVersion(in); + Exception failure = assertThrows(Exception.class, () -> parser.parse(in, version, receiver, + "test-address", receiver.getChallenge(), accepted)); + remainderWriter.join(); + assertEquals(1, failure.getSuppressed().length); + } + } + @Test public void testV1MagicPrefixCollisionDoesNotFallbackWhenV1IsDisabled() throws Exception { byte[] v1Block = new byte[256]; From f6fc2a768e3e2c9981cc74f496a876fe156f15d0 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 26 Aug 2026 20:25:56 -0600 Subject: [PATCH 12/13] Enforce total collision grace deadline --- .../vexsoftware/votifier/net/VoteParser.java | 40 +++++++++++++++---- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteParser.java b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteParser.java index fe876ca..5aced45 100644 --- a/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteParser.java +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteParser.java @@ -13,6 +13,7 @@ import java.net.SocketTimeoutException; import java.nio.charset.StandardCharsets; import java.security.Key; +import java.util.Arrays; import java.util.Base64; import java.util.Map; @@ -178,7 +179,8 @@ private VoteRequest parseUnframedV2(PushbackInputStream in, ByteArrayOutputStrea String address, String challenge, Socket socket) throws Exception { JsonObjectBoundaryScanner scanner = new JsonObjectBoundaryScanner(); byte[] prefix = voteData.toByteArray(); - boolean complete = scanner.scan(prefix, 0, prefix.length) >= 0; + int jsonBoundaryBytes = scanner.scan(prefix, 0, prefix.length); + boolean complete = jsonBoundaryBytes >= 0; Exception v1Failure = null; boolean v1Attempted = false; byte[] buffer = new byte[4096]; @@ -213,14 +215,21 @@ private VoteRequest parseUnframedV2(PushbackInputStream in, ByteArrayOutputStrea throw failure; } + int previousSize = voteData.size(); int completeAt = scanner.scan(buffer, 0, read); - int bytesToKeep = completeAt >= 0 ? completeAt : read; - voteData.write(buffer, 0, bytesToKeep); + // The entire chunk has already been consumed from the socket. Preserve its + // suffix for a possible fixed-size V1 fallback, while parsing V2 only up + // to the detected JSON boundary. + voteData.write(buffer, 0, read); + if (completeAt >= 0) { + jsonBoundaryBytes = previousSize + completeAt; + } complete = completeAt >= 0; } try { - return parseV2(voteData.toByteArray(), receiver, address, challenge); + byte[] candidate = voteData.toByteArray(); + return parseV2(Arrays.copyOf(candidate, jsonBoundaryBytes), receiver, address, challenge); } catch (Exception v2Failure) { if (v1Failure != null) { v2Failure.addSuppressed(v1Failure); @@ -323,11 +332,26 @@ private VoteRequest fallbackToBufferedV1OrThrow(PushbackInputStream in, ByteArra private boolean readV1CollisionRemainder(PushbackInputStream in, ByteArrayOutputStream voteData, Socket socket, Exception v2Failure) throws Exception { int previousTimeout = socket.getSoTimeout(); - int graceTimeout = previousTimeout <= 0 ? V1_COLLISION_GRACE_TIMEOUT_MS - : Math.min(previousTimeout, V1_COLLISION_GRACE_TIMEOUT_MS); + long deadlineNanos = System.nanoTime() + V1_COLLISION_GRACE_TIMEOUT_MS * 1_000_000L; + byte[] buffer = new byte[V1_BLOCK_BYTES - voteData.size()]; try { - socket.setSoTimeout(graceTimeout); - return readToSize(in, voteData, V1_BLOCK_BYTES); + while (voteData.size() < V1_BLOCK_BYTES) { + long remainingNanos = deadlineNanos - System.nanoTime(); + if (remainingNanos <= 0) { + return false; + } + + int remainingMillis = (int) Math.max(1, (remainingNanos + 999_999L) / 1_000_000L); + int readTimeout = previousTimeout <= 0 ? remainingMillis : Math.min(previousTimeout, remainingMillis); + socket.setSoTimeout(readTimeout); + + int read = in.read(buffer, 0, Math.min(buffer.length, V1_BLOCK_BYTES - voteData.size())); + if (read == -1) { + return false; + } + voteData.write(buffer, 0, read); + } + return true; } catch (SocketTimeoutException ex) { v2Failure.addSuppressed(ex); return false; From 671bc32fe140dc6b0887fef7459b58765f2b8fa3 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 26 Aug 2026 20:26:05 -0600 Subject: [PATCH 13/13] Test slow trickle deadline and JSON suffix retention --- .../votifierplus/tests/VoteReceiverTest.java | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteReceiverTest.java b/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteReceiverTest.java index 7eee64c..2d68e4e 100644 --- a/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteReceiverTest.java +++ b/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteReceiverTest.java @@ -248,14 +248,27 @@ public void testInvalidFramedV2CollisionGraceIsBounded() throws Exception { accepted.setSoTimeout(5000); client.getOutputStream().write(framedPayload); client.getOutputStream().flush(); + Thread trickleWriter = new Thread(() -> { + try { + for (int i = 0; i < 3; i++) { + Thread.sleep(100); + client.getOutputStream().write(1); + client.getOutputStream().flush(); + } + } catch (Exception ex) { + throw new RuntimeException(ex); + } + }); + trickleWriter.start(); VoteProtocolVersion version = parser.detectVersion(in); long startedAt = System.nanoTime(); assertThrows(InvalidVoteException.class, () -> parser.parse(in, version, receiver, "test-address", receiver.getChallenge(), accepted)); long elapsedMillis = (System.nanoTime() - startedAt) / 1_000_000; + trickleWriter.join(); - assertTrue(elapsedMillis < 1500, + assertTrue(elapsedMillis < 1000, "Invalid framed V2 packet exceeded bounded collision grace: " + elapsedMillis + "ms"); } } @@ -374,6 +387,24 @@ public void testV1JsonPrefixCollisionAttemptsV1Fallback() throws Exception { assertEquals(1, failure.getSuppressed().length); } + @Test + public void testV1JsonPrefixBulkReadPreservesSuffixForFallback() throws Exception { + byte[] v1Block = new byte[256]; + v1Block[0] = '{'; + v1Block[1] = '"'; + v1Block[2] = 'a'; + v1Block[3] = '"'; + v1Block[4] = '}'; + + PushbackInputStream in = new PushbackInputStream(new ByteArrayInputStream(v1Block), 512); + + VoteProtocolVersion version = parser.detectVersion(in); + assertEquals(VoteProtocolVersion.V2, version); + Exception failure = assertThrows(Exception.class, + () -> parser.parse(in, version, receiver, "test-address", receiver.getChallenge())); + assertEquals(1, failure.getSuppressed().length); + } + @Test public void testParseV2Vote() throws Exception { PushbackInputStream in = new PushbackInputStream(