diff --git a/VotifierPlus/src/main/java/com/vexsoftware/votifier/VotifierPlus.java b/VotifierPlus/src/main/java/com/vexsoftware/votifier/VotifierPlus.java index fce0f2b..4cbcb85 100644 --- a/VotifierPlus/src/main/java/com/vexsoftware/votifier/VotifierPlus.java +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/VotifierPlus.java @@ -338,6 +338,11 @@ public boolean isUseTokens() { return configFile.isTokenSupport(); } + @Override + public java.util.Set getTrustedProxyIps() { + return new java.util.HashSet(getConfigFile().getData().getStringList("TrustedProxyIps")); + } + @Override public ThrottleConfig getThrottleConfig() { ConfigurationSection root = getConfigFile().getData().getConfigurationSection("ConnectionThrottle"); diff --git a/VotifierPlus/src/main/java/com/vexsoftware/votifier/bungee/VotifierPlusBungee.java b/VotifierPlus/src/main/java/com/vexsoftware/votifier/bungee/VotifierPlusBungee.java index 8d6ffe9..0cc9377 100644 --- a/VotifierPlus/src/main/java/com/vexsoftware/votifier/bungee/VotifierPlusBungee.java +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/bungee/VotifierPlusBungee.java @@ -220,6 +220,12 @@ public boolean isUseTokens() { } @Override + public java.util.Set getTrustedProxyIps() { + List ips = getConfig().getData().getStringList("TrustedProxyIps"); + return ips == null ? Collections.emptySet() : new HashSet(ips); + } + + @Override public ThrottleConfig getThrottleConfig() { Configuration root = getConfig().getData().getSection("ConnectionThrottle"); diff --git a/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/IpLiteral.java b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/IpLiteral.java new file mode 100644 index 0000000..ffbf1fa --- /dev/null +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/IpLiteral.java @@ -0,0 +1,111 @@ +package com.vexsoftware.votifier.net; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.ArrayList; +import java.util.List; + +/** Parses address literals without resolving host names or accepting scoped IPv6 addresses. */ +final class IpLiteral { + private IpLiteral() { + } + + static byte[] parse(String value, int family) throws InvalidVoteException { + if (value == null || value.isEmpty()) { + throw new InvalidVoteException("Invalid PROXY address literal"); + } + if (family == 4) { + String[] parts = value.split("\\.", -1); + if (parts.length != 4) { + throw new InvalidVoteException("Invalid PROXY IPv4 literal"); + } + byte[] address = new byte[4]; + for (int i = 0; i < 4; i++) { + String part = parts[i]; + if (part.isEmpty() || part.length() > 3 || (part.length() > 1 && part.charAt(0) == '0')) { + throw new InvalidVoteException("Invalid PROXY IPv4 literal"); + } + int octet = 0; + for (int j = 0; j < part.length(); j++) { + char c = part.charAt(j); + if (c < '0' || c > '9') { + throw new InvalidVoteException("Invalid PROXY IPv4 literal"); + } + octet = octet * 10 + c - '0'; + } + if (octet > 255) { + throw new InvalidVoteException("Invalid PROXY IPv4 literal"); + } + address[i] = (byte) octet; + } + return address; + } + if (family != 6 || value.indexOf(':') < 0) { + throw new InvalidVoteException("Invalid PROXY IPv6 literal"); + } + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') || c == ':' || c == '.')) { + throw new InvalidVoteException("Invalid PROXY IPv6 literal"); + } + } + return parseIpv6(value); + } + + private static byte[] parseIpv6(String value) throws InvalidVoteException { + String[] halves = value.split("::", -1); + if (halves.length > 2) throw new InvalidVoteException("Invalid PROXY IPv6 literal"); + + List left = parseIpv6Half(halves[0], halves.length == 1); + List right = halves.length == 2 ? parseIpv6Half(halves[1], true) : List.of(); + int omitted = 8 - left.size() - right.size(); + if (halves.length == 1 ? omitted != 0 : omitted < 1) { + throw new InvalidVoteException("Invalid PROXY IPv6 literal"); + } + + byte[] address = new byte[16]; + int index = 0; + for (int group : left) index = writeGroup(address, index, group); + index += omitted * 2; + for (int group : right) index = writeGroup(address, index, group); + return address; + } + + private static List parseIpv6Half(String half, boolean mayEndWithIpv4) throws InvalidVoteException { + List groups = new ArrayList<>(); + if (half.isEmpty()) return groups; + String[] parts = half.split(":", -1); + for (int i = 0; i < parts.length; i++) { + String part = parts[i]; + if (part.isEmpty()) throw new InvalidVoteException("Invalid PROXY IPv6 literal"); + if (part.indexOf('.') >= 0) { + if (!mayEndWithIpv4 || i != parts.length - 1) { + throw new InvalidVoteException("Invalid PROXY IPv6 literal"); + } + byte[] ipv4 = parse(part, 4); + groups.add((ipv4[0] & 0xFF) << 8 | ipv4[1] & 0xFF); + groups.add((ipv4[2] & 0xFF) << 8 | ipv4[3] & 0xFF); + continue; + } + if (part.length() > 4) throw new InvalidVoteException("Invalid PROXY IPv6 literal"); + int group = 0; + for (int j = 0; j < part.length(); j++) group = group * 16 + Character.digit(part.charAt(j), 16); + groups.add(group); + } + return groups; + } + + private static int writeGroup(byte[] address, int index, int group) { + address[index++] = (byte) (group >>> 8); + address[index++] = (byte) group; + return index; + } + + static String format(byte[] address) throws InvalidVoteException { + try { + return InetAddress.getByAddress(address).getHostAddress(); + } catch (UnknownHostException ex) { + throw new InvalidVoteException("Invalid PROXY address length"); + } + } +} 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 48c8985..5412aa4 100644 --- a/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/ProxyHeaderProcessor.java +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/ProxyHeaderProcessor.java @@ -9,10 +9,14 @@ import java.io.BufferedWriter; import java.io.ByteArrayOutputStream; import java.io.PushbackInputStream; +import java.net.InetAddress; +import java.net.Inet6Address; import java.net.Socket; import java.net.SocketException; import java.net.SocketTimeoutException; import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Set; import java.util.concurrent.TimeUnit; import lombok.Getter; @@ -26,6 +30,8 @@ public class ProxyHeaderProcessor { 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 int PROXY_V2_IPV4_BYTES = 12; + private static final int PROXY_V2_IPV6_BYTES = 36; private static final byte[] PROXY_V1_PREFIX = "PROXY".getBytes(StandardCharsets.US_ASCII); private static final byte[] CONNECT_PREFIX = "CONNECT".getBytes(StandardCharsets.US_ASCII); @@ -73,18 +79,12 @@ private ProxyHeaderResult processWithDeadline(PushbackInputStream in, BufferedWr 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)) { + requireTrustedPeer(receiver, socket); 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"); + "PROXY protocol v1 header exceeds " + MAX_PROXY_V1_HEADER_BYTES + " bytes", true); receiver.debug("Discarded PROXY (v1) header (" + proxyHeader.length() + " chars)"); - - String[] parts = proxyHeader.split("\\s+"); - if (parts.length >= 3) { - String srcIp = parts[2].trim(); - if (!srcIp.isEmpty()) { - result.setRealIp(srcIp); - } - } + parseV1(proxyHeader, result); return result; } } @@ -102,7 +102,7 @@ private ProxyHeaderResult processWithDeadline(PushbackInputStream in, BufferedWr 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()) { + if (line.trim().isEmpty()) { break; } if (++headerCount > MAX_CONNECT_HEADERS) { @@ -124,8 +124,9 @@ private ProxyHeaderResult processWithDeadline(PushbackInputStream in, BufferedWr throw new InvalidVoteException("Incomplete PROXY protocol v2 header"); } if (bytesRead == 16 && startsWith(prefix, bytesRead, PROXY_V2_SIGNATURE)) { + requireTrustedPeer(receiver, socket); int addressLength = ((prefix[14] & 0xFF) << 8) | (prefix[15] & 0xFF); - discardFully(in, addressLength, socket, deadlineNanos); + parseV2(in, prefix, addressLength, socket, deadlineNanos, result); receiver.debug("Discarded PROXY protocol v2 header (" + (16 + addressLength) + " bytes)"); return result; } @@ -135,6 +136,117 @@ private ProxyHeaderResult processWithDeadline(PushbackInputStream in, BufferedWr return result; } + private void requireTrustedPeer(VoteReceiver receiver, Socket socket) throws InvalidVoteException { + InetAddress peer = socket == null ? null : socket.getInetAddress(); + Set configured = receiver.getTrustedProxyIps(); + // Unscoped text cannot distinguish the interface of a link-local IPv6 peer. + // Fail closed instead of trusting the same address bytes on another interface. + if (peer instanceof Inet6Address ipv6 && ipv6.isLinkLocalAddress()) { + throw new InvalidVoteException("Link-local IPv6 peers cannot be trusted for PROXY protocol"); + } + if (peer != null && configured != null) { + for (String literal : configured) { + if (literal == null) { + continue; + } + try { + if (Arrays.equals(peer.getAddress(), IpLiteral.parse(literal.trim(), peer.getAddress().length == 4 ? 4 : 6))) { + return; + } + } catch (InvalidVoteException ignored) { + // Invalid configuration entries cannot grant trust. + } + } + } + throw new InvalidVoteException("PROXY protocol header from untrusted socket peer"); + } + + private void parseV1(String header, ProxyHeaderResult result) throws InvalidVoteException { + String[] parts = header.split(" ", -1); + if (parts.length >= 2 && "PROXY".equals(parts[0]) && "UNKNOWN".equals(parts[1])) { + return; + } + if (parts.length != 6 || !"PROXY".equals(parts[0])) { + throw new InvalidVoteException("Invalid PROXY protocol v1 header"); + } + int family; + if ("TCP4".equals(parts[1])) { + family = 4; + } else if ("TCP6".equals(parts[1])) { + family = 6; + } else { + throw new InvalidVoteException("Unsupported PROXY protocol v1 family"); + } + byte[] source = IpLiteral.parse(parts[2], family); + IpLiteral.parse(parts[3], family); + parsePort(parts[4]); + parsePort(parts[5]); + result.setRealIp(IpLiteral.format(source)); + } + + private void parsePort(String value) throws InvalidVoteException { + if (value.isEmpty() || value.length() > 5) { + throw new InvalidVoteException("Invalid PROXY port"); + } + int port = 0; + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c < '0' || c > '9') { + throw new InvalidVoteException("Invalid PROXY port"); + } + port = port * 10 + c - '0'; + } + if (port > 65535) { + throw new InvalidVoteException("Invalid PROXY port"); + } + } + + private void parseV2(PushbackInputStream in, byte[] header, int length, Socket socket, long deadlineNanos, + ProxyHeaderResult result) throws Exception { + int versionCommand = header[12] & 0xFF; + int familyTransport = header[13] & 0xFF; + if ((versionCommand & 0xF0) != 0x20) { + throw new InvalidVoteException("Invalid PROXY protocol v2 version"); + } + int command = versionCommand & 0x0F; + if (command == 0) { + discardFully(in, length, socket, deadlineNanos); + return; + } + if (command != 1) { + throw new InvalidVoteException("Unsupported PROXY protocol v2 command"); + } + if (familyTransport == 0) { + discardFully(in, length, socket, deadlineNanos); + return; + } + int addressBytes; + int ipBytes; + if (familyTransport == 0x11) { + addressBytes = PROXY_V2_IPV4_BYTES; + ipBytes = 4; + } else if (familyTransport == 0x21) { + addressBytes = PROXY_V2_IPV6_BYTES; + ipBytes = 16; + } else { + throw new InvalidVoteException("Unsupported PROXY protocol v2 family or transport"); + } + if (length < addressBytes) { + throw new InvalidVoteException("Incomplete PROXY protocol v2 address block"); + } + byte[] addresses = new byte[addressBytes]; + int read = 0; + while (read < addressBytes) { + int count = readWithDeadline(in, addresses, read, addressBytes - read, socket, deadlineNanos); + if (count == -1) { + throw new InvalidVoteException("Incomplete PROXY protocol v2 header"); + } + read += count; + } + discardFully(in, length - addressBytes, socket, deadlineNanos); + result.setRealIp(IpLiteral.format(Arrays.copyOf(addresses, ipBytes))); + } + private int readPrefix(PushbackInputStream in, byte[] prefix, int targetLength, Socket socket, long deadlineNanos) throws Exception { return readPrefix(in, prefix, targetLength, 0, socket, deadlineNanos); @@ -167,6 +279,11 @@ private void discardFully(PushbackInputStream in, int length, Socket socket, lon private String readLine(PushbackInputStream in, Socket socket, long deadlineNanos, int maxLineBytes, int[] totalBytes, String overflowMessage) throws Exception { + return readLine(in, socket, deadlineNanos, maxLineBytes, totalBytes, overflowMessage, false); + } + + private String readLine(PushbackInputStream in, Socket socket, long deadlineNanos, int maxLineBytes, + int[] totalBytes, String overflowMessage, boolean requireCrLf) throws Exception { ByteArrayOutputStream lineBuffer = new ByteArrayOutputStream(Math.min(128, maxLineBytes)); int lineBytes = 0; @@ -183,6 +300,9 @@ private String readLine(PushbackInputStream in, Socket socket, long deadlineNano incrementTotalBytes(totalBytes); if (value == '\n') { + if (requireCrLf) { + throw new InvalidVoteException("PROXY protocol v1 header requires CRLF"); + } break; } @@ -207,7 +327,7 @@ private String readLine(PushbackInputStream in, Socket socket, long deadlineNano lineBuffer.write(value); } - return lineBuffer.toString(StandardCharsets.US_ASCII.name()).trim(); + return lineBuffer.toString(StandardCharsets.US_ASCII.name()); } private void incrementTotalBytes(int[] totalBytes) throws InvalidVoteException { 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 cecfe22..c9cf52b 100644 --- a/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteReceiver.java +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/net/VoteReceiver.java @@ -71,6 +71,7 @@ import java.security.PublicKey; import java.security.spec.X509EncodedKeySpec; import java.util.Base64; +import java.util.Collections; import java.util.Map; import java.util.Set; import java.util.concurrent.ArrayBlockingQueue; @@ -314,6 +315,11 @@ public boolean isDisableV1() { public abstract ThrottleConfig getThrottleConfig(); + /** Socket peers allowed to supply PROXY protocol metadata. Empty by default. */ + public Set getTrustedProxyIps() { + return Collections.emptySet(); + } + public abstract void logWarning(String warn); public abstract void logSevere(String msg); diff --git a/VotifierPlus/src/main/java/com/vexsoftware/votifier/velocity/VotifierPlusVelocity.java b/VotifierPlus/src/main/java/com/vexsoftware/votifier/velocity/VotifierPlusVelocity.java index bb4d4b3..3fab23e 100644 --- a/VotifierPlus/src/main/java/com/vexsoftware/votifier/velocity/VotifierPlusVelocity.java +++ b/VotifierPlus/src/main/java/com/vexsoftware/votifier/velocity/VotifierPlusVelocity.java @@ -310,6 +310,19 @@ public boolean isUseTokens() { } @Override + public java.util.Set getTrustedProxyIps() { + Set ips = new HashSet(); + ConfigurationNode node = getConfig().getNode("TrustedProxyIps"); + if (node != null && !node.virtual()) { + for (ConfigurationNode item : node.childrenList()) { + Object raw = item.raw(); + if (raw instanceof String) ips.add((String) raw); + } + } + return ips; + } + + @Override public ThrottleConfig getThrottleConfig() { ConfigurationNode root = getConfig().getNode("ConnectionThrottle"); if (root == null || root.virtual()) { diff --git a/VotifierPlus/src/main/resources/bungeeconfig.yml b/VotifierPlus/src/main/resources/bungeeconfig.yml index 0dca089..2fc721d 100644 --- a/VotifierPlus/src/main/resources/bungeeconfig.yml +++ b/VotifierPlus/src/main/resources/bungeeconfig.yml @@ -8,6 +8,13 @@ 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 +# Only these direct socket peers may supply a PROXY protocol v1/v2 source IP. +# Use numeric IPv4/IPv6 addresses; link-local IPv6 peers are not supported. +# Keep empty unless a trusted proxy sends PROXY headers. +# This is separate from ConnectionThrottle.TunnelRemoteIps. +# Upgrades must add every trusted proxy address here before accepting PROXY traffic. +# A missing or empty setting rejects all PROXY v1/v2 headers. +TrustedProxyIps: [] # ----------------------------------------------------------------------------- # Connection throttling & spam reduction for Votifier @@ -20,7 +27,7 @@ DisableV1: false # 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, +# (e.g. via trusted PROXY protocol v1/v2). If behind playit without PROXY, # only tunnel-level throttling is used. # ----------------------------------------------------------------------------- ConnectionThrottle: @@ -84,7 +91,7 @@ ConnectionThrottle: # Per-client bans (ONLY when real client IP is known) # # Requires: - # - PROXY protocol v1 providing the real source IP + # - trusted PROXY protocol v1/v2 providing the real source IP # # If enabled and the same real IP repeatedly fails validation, # that IP will be temporarily banned. @@ -123,4 +130,4 @@ Forwarding: Port: '' Key: '' # If token is set a token will be used instead of the key - Token: '' \ No newline at end of file + Token: '' diff --git a/VotifierPlus/src/main/resources/config.yml b/VotifierPlus/src/main/resources/config.yml index 187cd3e..1526183 100644 --- a/VotifierPlus/src/main/resources/config.yml +++ b/VotifierPlus/src/main/resources/config.yml @@ -12,6 +12,13 @@ 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 +# Only these direct socket peers may supply a PROXY protocol v1/v2 source IP. +# Use numeric IPv4/IPv6 addresses; link-local IPv6 peers are not supported. +# Keep empty unless a trusted proxy sends PROXY headers. +# This is separate from ConnectionThrottle.TunnelRemoteIps. +# Upgrades must add every trusted proxy address here before accepting PROXY traffic. +# A missing or empty setting rejects all PROXY v1/v2 headers. +TrustedProxyIps: [] # ----------------------------------------------------------------------------- # Connection throttling & spam reduction for Votifier @@ -24,7 +31,7 @@ DisableV1: false # 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, +# (e.g. via trusted PROXY protocol v1/v2). If behind playit without PROXY, # only tunnel-level throttling is used. # ----------------------------------------------------------------------------- ConnectionThrottle: @@ -88,7 +95,7 @@ ConnectionThrottle: # Per-client bans (ONLY when real client IP is known) # # Requires: - # - PROXY protocol v1 providing the real source IP + # - trusted PROXY protocol v1/v2 providing the real source IP # # If enabled and the same real IP repeatedly fails validation, # that IP will be temporarily banned. @@ -126,4 +133,4 @@ Forwarding: Port: 8193 Key: '' # If token is set a token will be used instead of the key - Token: '' \ No newline at end of file + Token: '' diff --git a/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/ProxyHeaderProcessorSecurityTest.java b/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/ProxyHeaderProcessorSecurityTest.java index 72a32f0..667b13a 100644 --- a/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/ProxyHeaderProcessorSecurityTest.java +++ b/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/ProxyHeaderProcessorSecurityTest.java @@ -9,6 +9,7 @@ import java.io.ByteArrayOutputStream; import java.io.OutputStreamWriter; import java.io.PushbackInputStream; +import java.net.InetAddress; import java.net.Socket; import java.nio.charset.StandardCharsets; import java.security.Key; @@ -68,7 +69,37 @@ public void testValidProxyV1HeaderPreservesVotePayload() throws Exception { String payload = "VOTE\nsite\nuser\n127.0.0.1\ntimestamp\n"; PushbackInputStream input = input(header + payload); - ProxyHeaderProcessor.ProxyHeaderResult result = processor.process(input, writer(), receiver); + ProxyHeaderProcessor.ProxyHeaderResult result = processor.process(input, writer(), receiver, new RecordingSocket()); + + assertEquals("192.0.2.10", result.getRealIp()); + assertEquals(payload, readRemaining(input)); + } + + @Test + public void testLinkLocalIpv6PeerCannotGainTrustWithoutAnInterfaceScope() throws Exception { + receiver = new StubVoteReceiver("127.0.0.1", 0) { + @Override + public Set getTrustedProxyIps() { + return Collections.singleton("fe80::1"); + } + }; + String header = "PROXY TCP6 2001:db8::10 2001:db8::20 1234 8192\r\n"; + + InvalidVoteException exception = assertThrows(InvalidVoteException.class, + () -> processor.process(input(header), writer(), receiver, + new AddressSocket(InetAddress.getByName("fe80::1")))); + + assertTrue(exception.getMessage().contains("Link-local IPv6")); + } + + @Test + public void testTcp6AcceptsIpv4MappedLiteral() throws Exception { + String payload = "VOTE\nsite\nuser\n127.0.0.1\ntimestamp\n"; + PushbackInputStream input = input( + "PROXY TCP6 ::ffff:192.0.2.10 2001:db8::1 1234 8192\r\n" + payload); + + ProxyHeaderProcessor.ProxyHeaderResult result = processor.process(input, writer(), receiver, + new RecordingSocket()); assertEquals("192.0.2.10", result.getRealIp()); assertEquals(payload, readRemaining(input)); @@ -78,7 +109,7 @@ public void testValidProxyV1HeaderPreservesVotePayload() throws Exception { public void testProxyV1HeaderOver107BytesIsRejected() throws Exception { String oversized = "PROXY " + "A".repeat(100) + "\r\n"; InvalidVoteException exception = assertThrows(InvalidVoteException.class, - () -> processor.process(input(oversized), writer(), receiver)); + () -> processor.process(input(oversized), writer(), receiver, new RecordingSocket())); assertTrue(exception.getMessage().contains("exceeds 107 bytes")); } @@ -98,6 +129,21 @@ public void testValidConnectHeadersPreserveVotePayload() throws Exception { assertEquals(payload, readRemaining(input)); } + @Test + public void testWhitespaceOnlyConnectTerminatorPreservesVotePayload() throws Exception { + String payload = "VOTE\nsite\nuser\n127.0.0.1\ntimestamp\n"; + PushbackInputStream input = input("CONNECT vote.example:443 HTTP/1.1\r\nHost: vote.example:443\r\n \t\r\n" + + 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"; @@ -138,7 +184,7 @@ public void testConnectHeadersOver32KiBAreRejected() throws Exception { @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 }; + 0x21, 0x11, 0x00, 0x0C, 1, 2, 3, 4, 5, 6, 7, 8, 0, 1, 0, 2 }; ByteArrayInputStream fragmented = new ByteArrayInputStream(header) { @Override public synchronized int read(byte[] bytes, int offset, int length) { @@ -178,6 +224,11 @@ private String readRemaining(PushbackInputStream input) throws Exception { private static class RecordingSocket extends Socket { + @Override + public InetAddress getInetAddress() { + return InetAddress.getLoopbackAddress(); + } + private final List recordedTimeouts = new ArrayList<>(); private int timeout = 5000; @@ -197,12 +248,30 @@ List getRecordedTimeouts() { } } + private static final class AddressSocket extends RecordingSocket { + private final InetAddress address; + + private AddressSocket(InetAddress address) { + this.address = address; + } + + @Override + public InetAddress getInetAddress() { + return address; + } + } + private static class StubVoteReceiver extends VoteReceiver { StubVoteReceiver(String host, int port) throws Exception { super(host, port); } + @Override + public Set getTrustedProxyIps() { + return Collections.singleton("127.0.0.1"); + } + @Override public boolean isUseTokens() { return false; diff --git a/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteConnectionHandlerTest.java b/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteConnectionHandlerTest.java index cef1eae..ec366c7 100644 --- a/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteConnectionHandlerTest.java +++ b/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteConnectionHandlerTest.java @@ -6,10 +6,13 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; import java.io.InputStreamReader; import java.io.OutputStream; +import java.net.InetAddress; import java.net.ServerSocket; import java.net.Socket; +import java.net.SocketException; import java.net.SocketTimeoutException; import java.nio.charset.StandardCharsets; import java.security.Key; @@ -23,6 +26,7 @@ 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; @@ -85,6 +89,7 @@ private static class TestVoteReceiver extends VoteReceiver { private final String testChallenge = "testChallenge"; private volatile boolean useTokens = false; + private Set trustedProxyIps = Collections.emptySet(); public TestVoteReceiver(String host, int port) throws Exception { super(host, port); @@ -153,6 +158,11 @@ public String getChallenge() { return testChallenge; } + @Override + public Set getTrustedProxyIps() { + return trustedProxyIps; + } + @Override public ThrottleConfig getThrottleConfig() { return null; @@ -432,6 +442,7 @@ public Vote call() { @Test public void testHandleProxyV1UsesRealIpAsSourceAddress() throws Exception { receiver.setUseTokens(false); + receiver.trustedProxyIps = Collections.singleton("127.0.0.1"); VoteThrottleService throttleService = new VoteThrottleService(null); VoteConnectionHandler handler = new VoteConnectionHandler(receiver, throttleService); @@ -476,6 +487,7 @@ public Vote call() { @Test public void testNonTunnelProxyOverflowUsesRemoteAggregate() throws Exception { receiver.setUseTokens(false); + receiver.trustedProxyIps = Collections.singleton("127.0.0.1"); ThrottleConfig config = new ThrottleConfig(true, Collections.emptySet(), "5s", 1, "10s", 1, "10s", false, 999, "1s", "60s"); VoteThrottleService throttleService = new VoteThrottleService(config); @@ -550,4 +562,129 @@ public Vote call() { assertTrue(!clientReader.ready(), "Did not expect an OK response for TestVote"); } } + + @Test + public void testProxyHeadersRequireTrustedSocketPeer() throws Exception { + byte[] v1 = "PROXY TCP4 203.0.113.10 127.0.0.1 1234 8192\r\n" + .getBytes(StandardCharsets.US_ASCII); + byte[] v2 = proxyV2(4, "203.0.113.10", "127.0.0.1", 1234, 8192); + assertNull(sendV1Vote(v1)); + assertNull(sendV1Vote(v2)); + receiver.trustedProxyIps = Collections.singleton("127.0.0.1"); + assertEquals("203.0.113.10", sendV1Vote(v1).getSourceAddress()); + assertEquals("203.0.113.10", sendV1Vote(v2).getSourceAddress()); + } + + @Test + public void testMissingTrustedProxyIpsRejectsProxyHeaders() throws Exception { + byte[] v1 = "PROXY TCP4 203.0.113.10 127.0.0.1 1234 8192\r\n" + .getBytes(StandardCharsets.US_ASCII); + byte[] v2 = proxyV2(4, "203.0.113.10", "127.0.0.1", 1234, 8192); + assertNull(sendV1Vote(v1)); + assertNull(sendV1Vote(v2)); + } + + @Test + public void testTrustedProxyIpv6AndConnectAttribution() throws Exception { + receiver.trustedProxyIps = Collections.singleton("127.0.0.1"); + assertEquals("2001:db8:0:0:0:0:0:10", sendV1Vote( + "PROXY TCP6 2001:db8::10 2001:db8::20 1234 8192\r\n".getBytes(StandardCharsets.US_ASCII)) + .getSourceAddress()); + assertEquals("2001:db8:0:0:0:0:0:10", sendV1Vote( + proxyV2(6, "2001:db8::10", "2001:db8::20", 1234, 8192)).getSourceAddress()); + receiver.trustedProxyIps = Collections.emptySet(); + assertEquals("127.0.0.1", sendV1Vote( + "CONNECT vote.example:443 HTTP/1.1\r\nHost: vote.example:443\r\n\r\n" + .getBytes(StandardCharsets.US_ASCII)).getSourceAddress()); + } + + @Test + public void testTrustedProxyHeadersWithoutSourceKeepSocketAttribution() throws Exception { + receiver.trustedProxyIps = Collections.singleton("127.0.0.1"); + assertEquals("127.0.0.1", sendV1Vote("PROXY UNKNOWN\r\n".getBytes(StandardCharsets.US_ASCII)) + .getSourceAddress()); + byte[] local = proxyV2(4, "203.0.113.10", "127.0.0.1", 1234, 8192); + local[12] = 0x20; + assertEquals("127.0.0.1", sendV1Vote(local).getSourceAddress()); + byte[] withTlv = proxyV2(4, "203.0.113.10", "127.0.0.1", 1234, 8192); + withTlv[15] += 4; + ByteArrayOutputStream packet = new ByteArrayOutputStream(); + packet.write(withTlv); + packet.write(new byte[] { 0x01, 0, 0x01, 0x01 }); + assertEquals("203.0.113.10", sendV1Vote(packet.toByteArray()).getSourceAddress()); + } + + @Test + public void testMalformedProxyAddressesFamiliesAndPortsAreRejected() throws Exception { + receiver.trustedProxyIps = Collections.singleton("127.0.0.1"); + String[] invalid = { + "PROXY TCP4 example.com 127.0.0.1 1234 8192\r\n", + "PROXY TCP4 256.0.0.1 127.0.0.1 1234 8192\r\n", + "PROXY TCP4 ::1 127.0.0.1 1234 8192\r\n", + "PROXY TCP6 203.0.113.10 ::1 1234 8192\r\n", + "PROXY TCP4 203.0.113.10 ::1 1234 8192\r\n", + "PROXY TCP4 203.0.113.10 127.0.0.1 -1 8192\r\n", + "PROXY TCP4 203.0.113.10 127.0.0.1 65536 8192\r\n", + "PROXY TCP4 203.0.113.10 127.0.0.1 1234 99999\r\n", + "PROXY TCP4 203.0.113.10 127.0.0.1 1234 8192\n", + "PROXY UDP4 203.0.113.10 127.0.0.1 1234 8192\r\n" }; + for (String header : invalid) { + assertNull(sendV1Vote(header.getBytes(StandardCharsets.US_ASCII)), header); + } + byte[] wrongVersion = proxyV2(4, "203.0.113.10", "127.0.0.1", 1234, 8192); + wrongVersion[12] = 0x11; + assertNull(sendV1Vote(wrongVersion)); + byte[] wrongFamily = proxyV2(4, "203.0.113.10", "127.0.0.1", 1234, 8192); + wrongFamily[13] = 0x21; + assertNull(sendV1Vote(wrongFamily)); + byte[] datagram = proxyV2(4, "203.0.113.10", "127.0.0.1", 1234, 8192); + datagram[13] = 0x12; + assertNull(sendV1Vote(datagram)); + } + + private Vote sendV1Vote(byte[] prefix) throws Exception { + VoteConnectionHandler handler = new VoteConnectionHandler(receiver, new VoteThrottleService(null)); + try (ServerSocket server = new ServerSocket(0); + Socket client = new Socket("127.0.0.1", server.getLocalPort()); + Socket accepted = server.accept()) { + Future future = executor.submit(() -> handler.handle(accepted)); + BufferedReader reader = new BufferedReader(new InputStreamReader(client.getInputStream(), StandardCharsets.US_ASCII)); + assertEquals("VOTIFIER 1", reader.readLine()); + String message = "VOTE\nsite\nuser\n127.0.0.1\nNormalTimestamp\n"; + Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding"); + cipher.init(Cipher.ENCRYPT_MODE, testKeyPair.getPublic()); + OutputStream out = client.getOutputStream(); + ByteArrayOutputStream request = new ByteArrayOutputStream(); + request.write(prefix); + request.write(cipher.doFinal(message.getBytes(StandardCharsets.US_ASCII))); + try { + out.write(request.toByteArray()); + out.flush(); + client.shutdownOutput(); + } catch (SocketException ignored) { + // Rejected headers may close the connection while the client is writing. + } + return future.get(5, TimeUnit.SECONDS); + } + } + + private byte[] proxyV2(int family, String source, String destination, int sourcePort, int destinationPort) + throws Exception { + byte[] sourceBytes = InetAddress.getByName(source).getAddress(); + byte[] destinationBytes = InetAddress.getByName(destination).getAddress(); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(new byte[] { 0x0D, 0x0A, 0x0D, 0x0A, 0, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A }); + out.write(0x21); + out.write(family == 4 ? 0x11 : 0x21); + int length = sourceBytes.length + destinationBytes.length + 4; + out.write(length >>> 8); + out.write(length); + out.write(sourceBytes); + out.write(destinationBytes); + out.write(sourcePort >>> 8); + out.write(sourcePort); + out.write(destinationPort >>> 8); + out.write(destinationPort); + return out.toByteArray(); + } } diff --git a/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteLoggingSecurityTest.java b/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteLoggingSecurityTest.java index a4a14a8..bbf2e94 100644 --- a/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteLoggingSecurityTest.java +++ b/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteLoggingSecurityTest.java @@ -14,6 +14,7 @@ import java.io.InputStreamReader; import java.io.OutputStreamWriter; import java.io.PushbackInputStream; +import java.net.InetAddress; import java.net.ServerSocket; import java.net.Socket; import java.nio.charset.StandardCharsets; @@ -131,11 +132,18 @@ public void testInvalidSignatureAndOpcodeDoNotEchoAttackerText() throws Exceptio public void testProxyAndConnectDebugNeverIncludeHeaderValues() throws Exception { ProxyHeaderProcessor processor = new ProxyHeaderProcessor(); String proxy = "PROXY TCP4 injected\u001b[31m 127.0.0.1 1 2\r\nVOTE"; - PushbackInputStream input = new PushbackInputStream( + PushbackInputStream proxyInput = new PushbackInputStream( new ByteArrayInputStream(proxy.getBytes(StandardCharsets.US_ASCII)), 512); - processor.process(input, writer(), receiver); + Socket trustedSocket = new Socket() { + @Override + public InetAddress getInetAddress() { + return InetAddress.getLoopbackAddress(); + } + }; + assertThrows(InvalidVoteException.class, () -> processor.process(proxyInput, writer(), receiver, trustedSocket)); String connect = "CONNECT vote.example:443 HTTP/1.1\r\nAuthorization: secret-value\u001b[31m\r\n\r\nVOTE"; - input = new PushbackInputStream(new ByteArrayInputStream(connect.getBytes(StandardCharsets.US_ASCII)), 512); + PushbackInputStream input = new PushbackInputStream( + new ByteArrayInputStream(connect.getBytes(StandardCharsets.US_ASCII)), 512); processor.process(input, writer(), receiver); assertFalse(allLogs().contains("injected")); assertFalse(allLogs().contains("secret-value")); @@ -264,6 +272,7 @@ private static final class RecordingReceiver extends VoteReceiver { @Override public String getVersion() { return "test"; } @Override public String getChallenge() { return "challenge"; } @Override public boolean isUseTokens() { return true; } + @Override public Set getTrustedProxyIps() { return Collections.singleton("127.0.0.1"); } @Override public Set getServers() { return forwardServer == null ? Collections.emptySet() : Collections.singleton("backend"); } @Override public ForwardServer getServerData(String server) { return forwardServer; } @Override public KeyPair getKeyPair() { return keyPair; } 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 53fbf24..77c4305 100644 --- a/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteReceiverTest.java +++ b/VotifierPlus/src/test/java/com/bencodez/votifierplus/tests/VoteReceiverTest.java @@ -153,6 +153,11 @@ public String getChallenge() { return testChallenge; } + @Override + public Set getTrustedProxyIps() { + return Collections.singleton("127.0.0.1"); + } + @Override public ThrottleConfig getThrottleConfig() { return null; @@ -478,7 +483,12 @@ public void testProxyV1Header() throws Exception { ByteArrayOutputStream baos = new ByteArrayOutputStream(); BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(baos, StandardCharsets.US_ASCII)); - ProxyHeaderProcessor.ProxyHeaderResult result = proxyHeaderProcessor.process(pis, writer, receiver); + ProxyHeaderProcessor.ProxyHeaderResult result = proxyHeaderProcessor.process(pis, writer, receiver, new java.net.Socket() { + @Override + public java.net.InetAddress getInetAddress() { + return java.net.InetAddress.getLoopbackAddress(); + } + }); assertEquals("192.168.1.1", result.getRealIp());