Skip to content

Commit 25d026c

Browse files
committed
api,netty: Add custom header support for HTTP CONNECT proxy
Allow users to specify custom HTTP headers when connecting through an HTTP CONNECT proxy. This extends HttpConnectProxiedSocketAddress with an optional headers field (Map<String, String>), which is converted to Netty's HttpHeaders in the protocol negotiator. This change is fully backward-compatible. Existing code without headers continues to work as before. Fixes #9826
1 parent 2360771 commit 25d026c

File tree

5 files changed

+388
-11
lines changed

5 files changed

+388
-11
lines changed

api/src/main/java/io/grpc/HttpConnectProxiedSocketAddress.java

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
import com.google.common.base.Objects;
2424
import java.net.InetSocketAddress;
2525
import java.net.SocketAddress;
26+
import java.util.Collections;
27+
import java.util.HashMap;
28+
import java.util.Map;
2629
import javax.annotation.Nullable;
2730

2831
/**
@@ -33,6 +36,8 @@ public final class HttpConnectProxiedSocketAddress extends ProxiedSocketAddress
3336

3437
private final SocketAddress proxyAddress;
3538
private final InetSocketAddress targetAddress;
39+
@SuppressWarnings("serial")
40+
private final Map<String, String> headers;
3641
@Nullable
3742
private final String username;
3843
@Nullable
@@ -41,6 +46,7 @@ public final class HttpConnectProxiedSocketAddress extends ProxiedSocketAddress
4146
private HttpConnectProxiedSocketAddress(
4247
SocketAddress proxyAddress,
4348
InetSocketAddress targetAddress,
49+
Map<String, String> headers,
4450
@Nullable String username,
4551
@Nullable String password) {
4652
checkNotNull(proxyAddress, "proxyAddress");
@@ -53,6 +59,7 @@ private HttpConnectProxiedSocketAddress(
5359
}
5460
this.proxyAddress = proxyAddress;
5561
this.targetAddress = targetAddress;
62+
this.headers = headers;
5663
this.username = username;
5764
this.password = password;
5865
}
@@ -87,6 +94,14 @@ public InetSocketAddress getTargetAddress() {
8794
return targetAddress;
8895
}
8996

97+
/**
98+
* Returns the custom HTTP headers to be sent during the HTTP CONNECT handshake.
99+
*/
100+
@ExperimentalApi("https://github.com/grpc/grpc-java/issues/12479")
101+
public Map<String, String> getHeaders() {
102+
return headers;
103+
}
104+
90105
@Override
91106
public boolean equals(Object o) {
92107
if (!(o instanceof HttpConnectProxiedSocketAddress)) {
@@ -95,20 +110,22 @@ public boolean equals(Object o) {
95110
HttpConnectProxiedSocketAddress that = (HttpConnectProxiedSocketAddress) o;
96111
return Objects.equal(proxyAddress, that.proxyAddress)
97112
&& Objects.equal(targetAddress, that.targetAddress)
113+
&& Objects.equal(headers, that.headers)
98114
&& Objects.equal(username, that.username)
99115
&& Objects.equal(password, that.password);
100116
}
101117

102118
@Override
103119
public int hashCode() {
104-
return Objects.hashCode(proxyAddress, targetAddress, username, password);
120+
return Objects.hashCode(proxyAddress, targetAddress, username, password, headers);
105121
}
106122

107123
@Override
108124
public String toString() {
109125
return MoreObjects.toStringHelper(this)
110126
.add("proxyAddr", proxyAddress)
111127
.add("targetAddr", targetAddress)
128+
.add("headers", headers)
112129
.add("username", username)
113130
// Intentionally mask out password
114131
.add("hasPassword", password != null)
@@ -129,6 +146,7 @@ public static final class Builder {
129146

130147
private SocketAddress proxyAddress;
131148
private InetSocketAddress targetAddress;
149+
private Map<String, String> headers = Collections.emptyMap();
132150
@Nullable
133151
private String username;
134152
@Nullable
@@ -153,6 +171,18 @@ public Builder setTargetAddress(InetSocketAddress targetAddress) {
153171
return this;
154172
}
155173

174+
/**
175+
* Sets custom HTTP headers to be sent during the HTTP CONNECT handshake. This is an optional
176+
* field. The headers will be sent in addition to any authentication headers (if username and
177+
* password are set).
178+
*/
179+
@ExperimentalApi("https://github.com/grpc/grpc-java/issues/12479")
180+
public Builder setHeaders(Map<String, String> headers) {
181+
this.headers = Collections.unmodifiableMap(
182+
new HashMap<>(checkNotNull(headers, "headers")));
183+
return this;
184+
}
185+
156186
/**
157187
* Sets the username used to connect to the proxy. This is an optional field and can be {@code
158188
* null}.
@@ -175,7 +205,8 @@ public Builder setPassword(@Nullable String password) {
175205
* Creates an {@code HttpConnectProxiedSocketAddress}.
176206
*/
177207
public HttpConnectProxiedSocketAddress build() {
178-
return new HttpConnectProxiedSocketAddress(proxyAddress, targetAddress, username, password);
208+
return new HttpConnectProxiedSocketAddress(
209+
proxyAddress, targetAddress, headers, username, password);
179210
}
180211
}
181212
}
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
/*
2+
* Copyright 2025 The gRPC Authors
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.grpc;
18+
19+
import static com.google.common.truth.Truth.assertThat;
20+
import static org.junit.Assert.assertNotEquals;
21+
import static org.junit.Assert.assertThrows;
22+
23+
import com.google.common.testing.EqualsTester;
24+
import java.net.InetAddress;
25+
import java.net.InetSocketAddress;
26+
import java.util.Collections;
27+
import java.util.HashMap;
28+
import java.util.Map;
29+
import org.junit.Test;
30+
import org.junit.runner.RunWith;
31+
import org.junit.runners.JUnit4;
32+
33+
@RunWith(JUnit4.class)
34+
public class HttpConnectProxiedSocketAddressTest {
35+
36+
private final InetSocketAddress proxyAddress =
37+
new InetSocketAddress(InetAddress.getLoopbackAddress(), 8080);
38+
private final InetSocketAddress targetAddress =
39+
InetSocketAddress.createUnresolved("example.com", 443);
40+
41+
@Test
42+
public void buildWithAllFields() {
43+
Map<String, String> headers = new HashMap<>();
44+
headers.put("X-Custom-Header", "custom-value");
45+
headers.put("Proxy-Authorization", "Bearer token");
46+
47+
HttpConnectProxiedSocketAddress address = HttpConnectProxiedSocketAddress.newBuilder()
48+
.setProxyAddress(proxyAddress)
49+
.setTargetAddress(targetAddress)
50+
.setHeaders(headers)
51+
.setUsername("user")
52+
.setPassword("pass")
53+
.build();
54+
55+
assertThat(address.getProxyAddress()).isEqualTo(proxyAddress);
56+
assertThat(address.getTargetAddress()).isEqualTo(targetAddress);
57+
assertThat(address.getHeaders()).hasSize(2);
58+
assertThat(address.getHeaders()).containsEntry("X-Custom-Header", "custom-value");
59+
assertThat(address.getHeaders()).containsEntry("Proxy-Authorization", "Bearer token");
60+
assertThat(address.getUsername()).isEqualTo("user");
61+
assertThat(address.getPassword()).isEqualTo("pass");
62+
}
63+
64+
@Test
65+
public void buildWithoutOptionalFields() {
66+
HttpConnectProxiedSocketAddress address = HttpConnectProxiedSocketAddress.newBuilder()
67+
.setProxyAddress(proxyAddress)
68+
.setTargetAddress(targetAddress)
69+
.build();
70+
71+
assertThat(address.getProxyAddress()).isEqualTo(proxyAddress);
72+
assertThat(address.getTargetAddress()).isEqualTo(targetAddress);
73+
assertThat(address.getHeaders()).isEmpty();
74+
assertThat(address.getUsername()).isNull();
75+
assertThat(address.getPassword()).isNull();
76+
}
77+
78+
@Test
79+
public void buildWithEmptyHeaders() {
80+
HttpConnectProxiedSocketAddress address = HttpConnectProxiedSocketAddress.newBuilder()
81+
.setProxyAddress(proxyAddress)
82+
.setTargetAddress(targetAddress)
83+
.setHeaders(Collections.emptyMap())
84+
.build();
85+
86+
assertThat(address.getHeaders()).isEmpty();
87+
}
88+
89+
@Test
90+
public void headersAreImmutable() {
91+
Map<String, String> headers = new HashMap<>();
92+
headers.put("key1", "value1");
93+
94+
HttpConnectProxiedSocketAddress address = HttpConnectProxiedSocketAddress.newBuilder()
95+
.setProxyAddress(proxyAddress)
96+
.setTargetAddress(targetAddress)
97+
.setHeaders(headers)
98+
.build();
99+
100+
headers.put("key2", "value2");
101+
102+
assertThat(address.getHeaders()).hasSize(1);
103+
assertThat(address.getHeaders()).containsEntry("key1", "value1");
104+
assertThat(address.getHeaders()).doesNotContainKey("key2");
105+
}
106+
107+
@Test
108+
public void returnedHeadersAreUnmodifiable() {
109+
Map<String, String> headers = new HashMap<>();
110+
headers.put("key", "value");
111+
112+
HttpConnectProxiedSocketAddress address = HttpConnectProxiedSocketAddress.newBuilder()
113+
.setProxyAddress(proxyAddress)
114+
.setTargetAddress(targetAddress)
115+
.setHeaders(headers)
116+
.build();
117+
118+
assertThrows(UnsupportedOperationException.class,
119+
() -> address.getHeaders().put("newKey", "newValue"));
120+
}
121+
122+
@Test
123+
public void nullHeadersThrowsException() {
124+
assertThrows(NullPointerException.class,
125+
() -> HttpConnectProxiedSocketAddress.newBuilder()
126+
.setProxyAddress(proxyAddress)
127+
.setTargetAddress(targetAddress)
128+
.setHeaders(null)
129+
.build());
130+
}
131+
132+
@Test
133+
public void equalsAndHashCode() {
134+
Map<String, String> headers1 = new HashMap<>();
135+
headers1.put("header", "value");
136+
137+
Map<String, String> headers2 = new HashMap<>();
138+
headers2.put("header", "value");
139+
140+
Map<String, String> differentHeaders = new HashMap<>();
141+
differentHeaders.put("different", "header");
142+
143+
new EqualsTester()
144+
.addEqualityGroup(
145+
HttpConnectProxiedSocketAddress.newBuilder()
146+
.setProxyAddress(proxyAddress)
147+
.setTargetAddress(targetAddress)
148+
.setHeaders(headers1)
149+
.setUsername("user")
150+
.setPassword("pass")
151+
.build(),
152+
HttpConnectProxiedSocketAddress.newBuilder()
153+
.setProxyAddress(proxyAddress)
154+
.setTargetAddress(targetAddress)
155+
.setHeaders(headers2)
156+
.setUsername("user")
157+
.setPassword("pass")
158+
.build())
159+
.addEqualityGroup(
160+
HttpConnectProxiedSocketAddress.newBuilder()
161+
.setProxyAddress(proxyAddress)
162+
.setTargetAddress(targetAddress)
163+
.setHeaders(differentHeaders)
164+
.setUsername("user")
165+
.setPassword("pass")
166+
.build())
167+
.addEqualityGroup(
168+
HttpConnectProxiedSocketAddress.newBuilder()
169+
.setProxyAddress(proxyAddress)
170+
.setTargetAddress(targetAddress)
171+
.build())
172+
.testEquals();
173+
}
174+
175+
@Test
176+
public void toStringContainsHeaders() {
177+
Map<String, String> headers = new HashMap<>();
178+
headers.put("X-Test", "test-value");
179+
180+
HttpConnectProxiedSocketAddress address = HttpConnectProxiedSocketAddress.newBuilder()
181+
.setProxyAddress(proxyAddress)
182+
.setTargetAddress(targetAddress)
183+
.setHeaders(headers)
184+
.setUsername("user")
185+
.setPassword("secret")
186+
.build();
187+
188+
String toString = address.toString();
189+
assertThat(toString).contains("headers");
190+
assertThat(toString).contains("X-Test");
191+
assertThat(toString).contains("hasPassword=true");
192+
assertThat(toString).doesNotContain("secret");
193+
}
194+
195+
@Test
196+
public void toStringWithoutPassword() {
197+
HttpConnectProxiedSocketAddress address = HttpConnectProxiedSocketAddress.newBuilder()
198+
.setProxyAddress(proxyAddress)
199+
.setTargetAddress(targetAddress)
200+
.build();
201+
202+
String toString = address.toString();
203+
assertThat(toString).contains("hasPassword=false");
204+
}
205+
206+
@Test
207+
public void hashCodeDependsOnHeaders() {
208+
Map<String, String> headers1 = new HashMap<>();
209+
headers1.put("header", "value1");
210+
211+
Map<String, String> headers2 = new HashMap<>();
212+
headers2.put("header", "value2");
213+
214+
HttpConnectProxiedSocketAddress address1 = HttpConnectProxiedSocketAddress.newBuilder()
215+
.setProxyAddress(proxyAddress)
216+
.setTargetAddress(targetAddress)
217+
.setHeaders(headers1)
218+
.build();
219+
220+
HttpConnectProxiedSocketAddress address2 = HttpConnectProxiedSocketAddress.newBuilder()
221+
.setProxyAddress(proxyAddress)
222+
.setTargetAddress(targetAddress)
223+
.setHeaders(headers2)
224+
.build();
225+
226+
assertNotEquals(address1.hashCode(), address2.hashCode());
227+
}
228+
229+
@Test
230+
public void multipleHeadersSupported() {
231+
Map<String, String> headers = new HashMap<>();
232+
headers.put("X-Header-1", "value1");
233+
headers.put("X-Header-2", "value2");
234+
headers.put("X-Header-3", "value3");
235+
236+
HttpConnectProxiedSocketAddress address = HttpConnectProxiedSocketAddress.newBuilder()
237+
.setProxyAddress(proxyAddress)
238+
.setTargetAddress(targetAddress)
239+
.setHeaders(headers)
240+
.build();
241+
242+
assertThat(address.getHeaders()).hasSize(3);
243+
assertThat(address.getHeaders()).containsEntry("X-Header-1", "value1");
244+
assertThat(address.getHeaders()).containsEntry("X-Header-2", "value2");
245+
assertThat(address.getHeaders()).containsEntry("X-Header-3", "value3");
246+
}
247+
}
248+

netty/src/main/java/io/grpc/netty/NettyChannelBuilder.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -818,6 +818,7 @@ public ConnectionClientTransport newClientTransport(
818818
serverAddress = proxiedAddr.getTargetAddress();
819819
localNegotiator = ProtocolNegotiators.httpProxy(
820820
proxiedAddr.getProxyAddress(),
821+
proxiedAddr.getHeaders(),
821822
proxiedAddr.getUsername(),
822823
proxiedAddr.getPassword(),
823824
protocolNegotiator);

0 commit comments

Comments
 (0)