Skip to content

Commit 2ce7276

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 2ce7276

File tree

5 files changed

+379
-11
lines changed

5 files changed

+379
-11
lines changed

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

Lines changed: 26 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,13 @@ public InetSocketAddress getTargetAddress() {
8794
return targetAddress;
8895
}
8996

97+
/**
98+
* Returns the headers associated with this proxied socket address.
99+
*/
100+
public Map<String, String> getHeaders() {
101+
return headers;
102+
}
103+
90104
@Override
91105
public boolean equals(Object o) {
92106
if (!(o instanceof HttpConnectProxiedSocketAddress)) {
@@ -95,20 +109,22 @@ public boolean equals(Object o) {
95109
HttpConnectProxiedSocketAddress that = (HttpConnectProxiedSocketAddress) o;
96110
return Objects.equal(proxyAddress, that.proxyAddress)
97111
&& Objects.equal(targetAddress, that.targetAddress)
112+
&& Objects.equal(headers, that.headers)
98113
&& Objects.equal(username, that.username)
99114
&& Objects.equal(password, that.password);
100115
}
101116

102117
@Override
103118
public int hashCode() {
104-
return Objects.hashCode(proxyAddress, targetAddress, username, password);
119+
return Objects.hashCode(proxyAddress, targetAddress, username, password, headers);
105120
}
106121

107122
@Override
108123
public String toString() {
109124
return MoreObjects.toStringHelper(this)
110125
.add("proxyAddr", proxyAddress)
111126
.add("targetAddr", targetAddress)
127+
.add("headers", headers)
112128
.add("username", username)
113129
// Intentionally mask out password
114130
.add("hasPassword", password != null)
@@ -129,6 +145,7 @@ public static final class Builder {
129145

130146
private SocketAddress proxyAddress;
131147
private InetSocketAddress targetAddress;
148+
private Map<String, String> headers = Collections.emptyMap();
132149
@Nullable
133150
private String username;
134151
@Nullable
@@ -153,6 +170,12 @@ public Builder setTargetAddress(InetSocketAddress targetAddress) {
153170
return this;
154171
}
155172

173+
public Builder setHeaders(Map<String, String> headers) {
174+
this.headers = Collections.unmodifiableMap(
175+
new HashMap<>(checkNotNull(headers, "headers")));
176+
return this;
177+
}
178+
156179
/**
157180
* Sets the username used to connect to the proxy. This is an optional field and can be {@code
158181
* null}.
@@ -175,7 +198,8 @@ public Builder setPassword(@Nullable String password) {
175198
* Creates an {@code HttpConnectProxiedSocketAddress}.
176199
*/
177200
public HttpConnectProxiedSocketAddress build() {
178-
return new HttpConnectProxiedSocketAddress(proxyAddress, targetAddress, username, password);
201+
return new HttpConnectProxiedSocketAddress(
202+
proxyAddress, targetAddress, headers, username, password);
179203
}
180204
}
181205
}
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)