Skip to content

Commit 3009565

Browse files
devhritwikclaude
andcommitted
Add proxy.protocol.accepted.ip.range config to guard PROXY protocol data by peer IP
During v3/v4 migration on GCP and Azure, both load balancer (v3, no PROXY protocol) and Envoy (v4, with PROXY protocol) connections coexist. Without this guard, an attacker can send a fake PROXY header through the v3 LB to spoof their IP and bypass IP filtering. This adds a new config `proxy.protocol.accepted.ip.range` (CIDR notation) that tells ProxyCustomizer to only trust PROXY protocol data from peers whose raw TCP IP falls within the range. Connections from outside the range get wrapped with a RawPeerRequest that overrides getRemoteAddr() back to the raw TCP peer IP using Jetty 12's ConnectionMetaData.Wrapper, effectively undoing the ProxyEndPoint's address override. Companion change to ce-kafka PR #28385. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b1d0d67 commit 3009565

File tree

7 files changed

+623
-3
lines changed

7 files changed

+623
-3
lines changed

core/src/main/java/io/confluent/rest/ApplicationServer.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import com.google.common.base.Preconditions;
2121
import com.google.common.collect.ImmutableMap;
2222
import com.google.common.collect.Maps;
23+
import io.confluent.rest.customizer.CidrRange;
2324
import io.confluent.rest.customizer.ProxyCustomizer;
2425
import io.confluent.rest.errorhandlers.StackTraceErrorHandler;
2526
import java.lang.management.ManagementFactory;
@@ -287,7 +288,8 @@ private void configureConnectors() {
287288
connectorConfig.getInt(RestConfig.MAX_RESPONSE_HEADER_SIZE_CONFIG));
288289

289290
if (proxyProtocolEnabled) {
290-
httpConfiguration.addCustomizer(new ProxyCustomizer());
291+
httpConfiguration.addCustomizer(
292+
new ProxyCustomizer(parseAcceptedIpRange(connectorConfig)));
291293
}
292294

293295
// Use original IP in forwarded requests
@@ -447,6 +449,12 @@ private ConnectionFactory[] getConnectionFactories(HttpConfiguration httpConfigu
447449
return connectionFactories.toArray(new ConnectionFactory[0]);
448450
}
449451

452+
private static CidrRange parseAcceptedIpRange(RestConfig connectorConfig) {
453+
String value = connectorConfig.getString(
454+
RestConfig.PROXY_PROTOCOL_ACCEPTED_IP_RANGE_CONFIG).trim();
455+
return value.isEmpty() ? null : CidrRange.parse(value);
456+
}
457+
450458
private void configureConnectionLimits() {
451459
int serverConnectionLimit = serverConfig.getServerConnectionLimit();
452460
if (serverConnectionLimit > 0) {

core/src/main/java/io/confluent/rest/RestConfig.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,18 @@ public class RestConfig extends AbstractConfig {
593593
+ "Default is false.";
594594
protected static final boolean PROXY_PROTOCOL_ENABLED_DEFAULT = false;
595595

596+
public static final String PROXY_PROTOCOL_ACCEPTED_IP_RANGE_CONFIG =
597+
"proxy.protocol.accepted.ip.range";
598+
protected static final String PROXY_PROTOCOL_ACCEPTED_IP_RANGE_DOC =
599+
"If set, the server will only use PROXY protocol header information from connections "
600+
+ "whose peer IP address falls within the specified CIDR range (e.g., '10.240.0.0/16'). "
601+
+ "Connections from outside this range will have their PROXY protocol data ignored, "
602+
+ "and the raw peer IP will be used instead. This prevents IP spoofing during migrations "
603+
+ "where some connections come through a proxy (e.g., Envoy) and others do not. "
604+
+ "If empty (default), PROXY protocol data is used unconditionally when "
605+
+ "proxy.protocol.enabled is true.";
606+
protected static final String PROXY_PROTOCOL_ACCEPTED_IP_RANGE_DEFAULT = "";
607+
596608
public static final String SUPPRESS_STACK_TRACE_IN_RESPONSE = "suppress.stack.trace.response";
597609

598610
protected static final String SUPPRESS_STACK_TRACE_IN_RESPONSE_DOC =
@@ -1249,6 +1261,12 @@ private static ConfigDef incompleteBaseConfigDef() {
12491261
PROXY_PROTOCOL_ENABLED_DEFAULT,
12501262
Importance.LOW,
12511263
PROXY_PROTOCOL_ENABLED_DOC
1264+
).define(
1265+
PROXY_PROTOCOL_ACCEPTED_IP_RANGE_CONFIG,
1266+
Type.STRING,
1267+
PROXY_PROTOCOL_ACCEPTED_IP_RANGE_DEFAULT,
1268+
Importance.LOW,
1269+
PROXY_PROTOCOL_ACCEPTED_IP_RANGE_DOC
12521270
).define(
12531271
NOSNIFF_PROTECTION_ENABLED,
12541272
Type.BOOLEAN,
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/*
2+
* Copyright 2025 Confluent Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.confluent.rest.customizer;
18+
19+
import org.apache.kafka.common.config.ConfigException;
20+
21+
import java.net.InetAddress;
22+
import java.net.UnknownHostException;
23+
24+
/**
25+
* Represents a CIDR range (e.g., "10.240.0.0/16") and provides a method to check
26+
* whether a given IP address falls within the range.
27+
*/
28+
public class CidrRange {
29+
30+
private final byte[] networkBytes;
31+
private final int prefixLength;
32+
33+
private CidrRange(byte[] networkBytes, int prefixLength) {
34+
this.networkBytes = networkBytes;
35+
this.prefixLength = prefixLength;
36+
}
37+
38+
/**
39+
* Parses a CIDR notation string (e.g., "10.240.0.0/16") into a {@link CidrRange}.
40+
*
41+
* @param cidr the CIDR string to parse
42+
* @return a {@link CidrRange} instance
43+
* @throws ConfigException if the CIDR string is malformed
44+
*/
45+
public static CidrRange parse(String cidr) {
46+
String[] parts = cidr.split("/");
47+
if (parts.length != 2) {
48+
throw new ConfigException("Invalid CIDR notation: " + cidr);
49+
}
50+
51+
InetAddress network;
52+
try {
53+
network = InetAddress.getByName(parts[0]);
54+
} catch (UnknownHostException e) {
55+
throw new ConfigException("Invalid network address in CIDR: " + parts[0]);
56+
}
57+
58+
int prefixLength;
59+
try {
60+
prefixLength = Integer.parseInt(parts[1]);
61+
} catch (NumberFormatException e) {
62+
throw new ConfigException("Invalid prefix length in CIDR: " + parts[1]);
63+
}
64+
65+
byte[] networkBytes = network.getAddress();
66+
int maxPrefix = networkBytes.length * 8;
67+
if (prefixLength < 0 || prefixLength > maxPrefix) {
68+
throw new ConfigException(
69+
"Prefix length " + prefixLength + " is out of range for "
70+
+ (networkBytes.length == 4 ? "IPv4" : "IPv6")
71+
+ " address (0-" + maxPrefix + ")");
72+
}
73+
74+
return new CidrRange(networkBytes, prefixLength);
75+
}
76+
77+
/**
78+
* Checks whether the given address is within this CIDR range.
79+
*
80+
* @param address the address to check
81+
* @return true if the address is within this range, false otherwise
82+
*/
83+
public boolean contains(InetAddress address) {
84+
byte[] addrBytes = address.getAddress();
85+
86+
// IPv4 vs IPv6 mismatch
87+
if (addrBytes.length != networkBytes.length) {
88+
return false;
89+
}
90+
91+
// Compare full bytes
92+
int fullBytes = prefixLength / 8;
93+
for (int i = 0; i < fullBytes; i++) {
94+
if (addrBytes[i] != networkBytes[i]) {
95+
return false;
96+
}
97+
}
98+
99+
// Compare remaining bits
100+
int remainingBits = prefixLength % 8;
101+
if (remainingBits > 0) {
102+
int mask = 0xFF << (8 - remainingBits);
103+
if ((addrBytes[fullBytes] & mask) != (networkBytes[fullBytes] & mask)) {
104+
return false;
105+
}
106+
}
107+
108+
return true;
109+
}
110+
}

core/src/main/java/io/confluent/rest/customizer/ProxyCustomizer.java

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,20 +19,32 @@
1919
import org.eclipse.jetty.http.HttpFields;
2020
import org.eclipse.jetty.io.EndPoint;
2121
import org.eclipse.jetty.io.ssl.SslConnection;
22+
import org.eclipse.jetty.server.ConnectionMetaData;
2223
import org.eclipse.jetty.server.HttpConfiguration;
2324
import org.eclipse.jetty.server.ProxyConnectionFactory;
2425
import org.eclipse.jetty.server.Request;
26+
import org.slf4j.Logger;
27+
import org.slf4j.LoggerFactory;
2528

29+
import java.net.InetAddress;
2630
import java.net.InetSocketAddress;
2731
import java.net.SocketAddress;
2832
import java.util.HashSet;
2933
import java.util.Set;
3034

3135
/**
32-
* Similar to {@link org.eclipse.jetty.server.ProxyCustomizer} but allowing access to tlvs as well
36+
* Similar to {@link org.eclipse.jetty.server.ProxyCustomizer} but allowing access to tlvs as well.
37+
*
38+
* <p>When an {@code acceptedIpRange} is configured, this customizer checks whether the
39+
* underlying (raw) peer IP falls within the CIDR range. If it does NOT, the PROXY protocol
40+
* data is ignored and the request's remote address is overridden to the raw TCP peer IP.
41+
* This prevents IP spoofing during v3/v4 migrations where some connections arrive through
42+
* a proxy (e.g., Envoy) and others come directly through a load balancer.</p>
3343
*/
3444
public class ProxyCustomizer implements HttpConfiguration.Customizer {
3545

46+
private static final Logger log = LoggerFactory.getLogger(ProxyCustomizer.class);
47+
3648
// The remote address attribute name.
3749
public static final String REMOTE_ADDRESS_ATTRIBUTE_NAME =
3850
"io.confluent.rest.proxy.remote.address";
@@ -54,6 +66,16 @@ public class ProxyCustomizer implements HttpConfiguration.Customizer {
5466
public static final String TLV_PROVIDER_ATTRIBUTE_NAME
5567
= "io.confluent.rest.proxy.tlv.provider";
5668

69+
private final CidrRange acceptedIpRange;
70+
71+
public ProxyCustomizer() {
72+
this(null);
73+
}
74+
75+
public ProxyCustomizer(CidrRange acceptedIpRange) {
76+
this.acceptedIpRange = acceptedIpRange;
77+
}
78+
5779
@Override
5880
public Request customize(Request request, HttpFields.Mutable mutable) {
5981
EndPoint endPoint = request.getConnectionMetaData().getConnection().getEndPoint();
@@ -65,6 +87,23 @@ public Request customize(Request request, HttpFields.Mutable mutable) {
6587

6688
if (endPoint instanceof ProxyConnectionFactory.ProxyEndPoint proxyEndPoint) {
6789
EndPoint underlyingEndpoint = proxyEndPoint.unwrap();
90+
91+
// Check peer IP against accepted range before using PROXY data
92+
if (acceptedIpRange != null) {
93+
SocketAddress rawRemote = underlyingEndpoint.getRemoteSocketAddress();
94+
if (rawRemote instanceof InetSocketAddress inetRemote) {
95+
InetAddress peerAddress = inetRemote.getAddress();
96+
if (!acceptedIpRange.contains(peerAddress)) {
97+
log.debug(
98+
"Peer IP {} is not in accepted range, ignoring PROXY protocol data",
99+
peerAddress.getHostAddress());
100+
// Override the connection metadata to use raw TCP addresses,
101+
// undoing the ProxyEndPoint's effect on getRemoteAddr()
102+
return new RawPeerRequest(request, underlyingEndpoint);
103+
}
104+
}
105+
}
106+
68107
request = new ProxyRequest(request,
69108
underlyingEndpoint.getLocalSocketAddress(),
70109
underlyingEndpoint.getRemoteSocketAddress(),
@@ -73,6 +112,37 @@ public Request customize(Request request, HttpFields.Mutable mutable) {
73112
return request;
74113
}
75114

115+
/**
116+
* Request wrapper that overrides the connection metadata to use raw TCP peer
117+
* addresses instead of the PROXY-parsed addresses. This effectively "undoes"
118+
* the {@link ProxyConnectionFactory.ProxyEndPoint}'s address override when
119+
* the peer is not in the accepted IP range.
120+
*/
121+
private static class RawPeerRequest extends Request.Wrapper {
122+
private final ConnectionMetaData rawMetaData;
123+
124+
private RawPeerRequest(Request request, EndPoint rawEndpoint) {
125+
super(request);
126+
this.rawMetaData = new ConnectionMetaData.Wrapper(
127+
request.getConnectionMetaData()) {
128+
@Override
129+
public SocketAddress getRemoteSocketAddress() {
130+
return rawEndpoint.getRemoteSocketAddress();
131+
}
132+
133+
@Override
134+
public SocketAddress getLocalSocketAddress() {
135+
return rawEndpoint.getLocalSocketAddress();
136+
}
137+
};
138+
}
139+
140+
@Override
141+
public ConnectionMetaData getConnectionMetaData() {
142+
return rawMetaData;
143+
}
144+
}
145+
76146
private static class ProxyRequest extends Request.Wrapper {
77147
private final String remoteAddress;
78148
private final String localAddress;

0 commit comments

Comments
 (0)