Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
285 changes: 266 additions & 19 deletions VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteParser.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,14 @@
*/
package com.vexsoftware.votifier.net;

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.Arrays;
import java.util.Base64;
import java.util.Map;

Expand All @@ -29,6 +33,10 @@ public class VoteParser {

private static final Gson GSON = new Gson();
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 int V1_COLLISION_GRACE_TIMEOUT_MS = 250;

private static final String FIELD_PAYLOAD = "payload";
private static final String FIELD_SIGNATURE = "signature";
Expand All @@ -47,19 +55,26 @@ 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_VERSION_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) {
return VoteProtocolVersion.V2;
Expand All @@ -81,14 +96,150 @@ 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);
}
return parseV2(in, receiver, address, challenge);

ByteArrayOutputStream voteData = new ByteArrayOutputStream();
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, socket);
}
if ((char) prefix[0] == '{') {
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, Socket socket) 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 parseV1Candidate(voteData.toByteArray(), receiver, address);
} catch (Exception ex) {
v1Failure = ex;
}
}

if (!readToSize(in, voteData, frameBytes)) {
throw new InvalidVoteException("Incomplete V2 frame from " + address + " (expected " + frameBytes
+ " bytes, got " + voteData.size() + ")");
}

try {
return parseV2(voteData.toByteArray(), receiver, address, challenge);
} catch (Exception v2Failure) {
if (v1Failure != null) {
v2Failure.addSuppressed(v1Failure);
}
return fallbackToBufferedV1OrThrow(in, voteData, receiver, address, v2Failure, socket);
}
}

private VoteRequest parseUnframedV2(PushbackInputStream in, ByteArrayOutputStream voteData, VoteReceiver receiver,
String address, String challenge, Socket socket) throws Exception {
JsonObjectBoundaryScanner scanner = new JsonObjectBoundaryScanner();
byte[] prefix = voteData.toByteArray();
int jsonBoundaryBytes = scanner.scan(prefix, 0, prefix.length);
boolean complete = jsonBoundaryBytes >= 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 parseV1Candidate(voteData.toByteArray(), receiver, address);
} catch (Exception ex) {
v1Failure = ex;
}
}

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;
}

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);
}
throw failure;
}

int previousSize = voteData.size();
int completeAt = scanner.scan(buffer, 0, read);
// 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 {
byte[] candidate = voteData.toByteArray();
return parseV2(Arrays.copyOf(candidate, jsonBoundaryBytes), receiver, address, challenge);
} catch (Exception v2Failure) {
if (v1Failure != null) {
v2Failure.addSuppressed(v1Failure);
}
return fallbackToBufferedV1OrThrow(in, voteData, receiver, address, v2Failure, socket);
}
}

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) {
Expand All @@ -99,9 +250,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;
Expand Down Expand Up @@ -141,18 +292,114 @@ private VoteRequest parseV1(PushbackInputStream in, VoteReceiver receiver, Strin
return request;
}

private VoteRequest parseV2(PushbackInputStream in, VoteReceiver receiver, String address, String challenge)
throws Exception {
ByteArrayOutputStream data = new ByteArrayOutputStream();
int b;
while ((b = in.read()) != -1) {
data.write(b);
if (in.available() == 0) {
break;
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);
}
return true;
}

private VoteRequest fallbackToBufferedV1OrThrow(PushbackInputStream in, ByteArrayOutputStream voteData,
VoteReceiver receiver, String address, Exception v2Failure, Socket socket) throws Exception {
if (receiver.isDisableV1() || voteData.size() > V1_BLOCK_BYTES) {
throw v2Failure;
}

String voteData = data.toString("UTF-8").trim();
int remaining = V1_BLOCK_BYTES - voteData.size();
if (remaining > 0) {
if (socket == null) {
if (in.available() < remaining || !readToSize(in, voteData, V1_BLOCK_BYTES)) {
throw v2Failure;
}
} else if (!readV1CollisionRemainder(in, voteData, socket, v2Failure)) {
throw v2Failure;
}
}

try {
return parseV1Candidate(voteData.toByteArray(), receiver, address);
} catch (Exception v1Failure) {
v2Failure.addSuppressed(v1Failure);
throw v2Failure;
}
}

private boolean readV1CollisionRemainder(PushbackInputStream in, ByteArrayOutputStream voteData, Socket socket,
Exception v2Failure) throws Exception {
int previousTimeout = socket.getSoTimeout();
long deadlineNanos = System.nanoTime() + V1_COLLISION_GRACE_TIMEOUT_MS * 1_000_000L;
byte[] buffer = new byte[V1_BLOCK_BYTES - voteData.size()];
try {
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;
} 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);
}

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)
throws Exception {
String voteData = new String(data, StandardCharsets.UTF_8).trim();
receiver.debug("Received raw V2 vote payload: [" + voteData + "]");

int firstBrace = voteData.indexOf('{');
Expand Down Expand Up @@ -289,4 +536,4 @@ private boolean hmacEqual(byte[] providedSig, byte[] data, Key key) throws Excep
}
return diff == 0;
}
}
}
Loading
Loading