Skip to content

Commit 4ac86f2

Browse files
fix(okhttp): omit proxy headers on TLS and explicit direct WebSockets (#1297)
WebSocket requests now omit `Proxy-Authorization` on TLS connections and explicit direct or SOCKS routes. Plaintext HTTP proxies keep the header, including proxies selected automatically. Automatic plaintext routing otherwise keeps its existing behavior. Bedrock excludes the proxy header from AWS signatures, then restores its original values for the transport. Removing the header on a protected route therefore does not invalidate the signature. Tests cover actual WebSocket handshakes through direct, HTTP-proxy and SOCKS routes, plus golden signatures and handshakes for Bedrock authentication. Runtime, lint, binary API compatibility, and OkTest passed (236 passed, 0 failed, 0 skipped). Co-authored-by: markstuart-oai <323302876+markstuart-oai@users.noreply.github.com>
1 parent ccb201d commit 4ac86f2

8 files changed

Lines changed: 426 additions & 12 deletions

File tree

‎README.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1687,6 +1687,11 @@ with any caller network interceptor, or an `authenticator` or `proxyAuthenticato
16871687
WebSocket, before a network connection starts. Application interceptors are supported.
16881688
Supplying a native client does not change these WebSocket restrictions.
16891689

1690+
TLS WebSockets and explicitly configured direct or SOCKS routes omit `Proxy-Authorization` from the
1691+
origin request. Plaintext WebSockets through an explicit or automatically selected HTTP proxy retain
1692+
preemptive proxy credentials. For automatic plaintext proxy selection, the SDK does not remove
1693+
`Proxy-Authorization`.
1694+
16901695
### Telemetry headers
16911696

16921697
Disable SDK-generated telemetry for a client:

‎openai-java-bedrock/build.gradle.kts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,5 @@ dependencies {
4242
testImplementation("org.assertj:assertj-core:3.27.7")
4343
testImplementation("org.junit.jupiter:junit-jupiter-api:5.9.3")
4444
testImplementation("org.junit-pioneer:junit-pioneer:1.9.1")
45+
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
4546
}

‎openai-java-bedrock/src/main/kotlin/com/openai/bedrock/BedrockAuth.kt‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -453,7 +453,14 @@ private class SigV4Authenticator(
453453
.putProperty(HttpSigner.SIGNING_CLOCK, clock)
454454
}
455455

456-
return preparedRequest.toBuilder().headers(signed.request().headers()).build()
456+
return preparedRequest
457+
.toBuilder()
458+
.headers(signed.request().headers())
459+
.putHeaders(
460+
"Proxy-Authorization",
461+
preparedRequest.headers.values("Proxy-Authorization"),
462+
)
463+
.build()
457464
}
458465

459466
override fun close() {
@@ -521,7 +528,10 @@ private fun HttpRequest.toAwsRequest(uri: URI): SdkHttpRequest {
521528
val builder =
522529
SdkHttpRequest.builder().uri(uri).method(SdkHttpMethod.fromValue(method.name)).apply {
523530
this@toAwsRequest.headers.names().forEach { name ->
524-
if (!name.equals("connection", ignoreCase = true)) {
531+
if (
532+
!name.equals("connection", ignoreCase = true) &&
533+
!name.equals("proxy-authorization", ignoreCase = true)
534+
) {
525535
this@toAwsRequest.headers.values(name).forEach { value ->
526536
appendHeader(name, value)
527537
}

‎openai-java-bedrock/src/test/kotlin/com/openai/bedrock/BedrockAuthTest.kt‎

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -48,16 +48,15 @@ internal class BedrockAuthTest {
4848
)
4949
.resolve(getenv = { null }, regionProvider = { null })
5050

51-
val signed =
52-
configuration.authenticator.authenticate(
53-
HttpRequest.builder()
54-
.method(HttpMethod.POST)
55-
.baseUrl("https://bedrock-mantle.us-east-1.api.aws/openai/v1")
56-
.addPathSegment("responses")
57-
.putHeader("Content-Type", requestFixture["contentType"].asText())
58-
.body(StringBody(requestFixture["body"].asText()))
59-
.build()
60-
)
51+
val request =
52+
HttpRequest.builder()
53+
.method(HttpMethod.POST)
54+
.baseUrl("https://bedrock-mantle.us-east-1.api.aws/openai/v1")
55+
.addPathSegment("responses")
56+
.putHeader("Content-Type", requestFixture["contentType"].asText())
57+
.body(StringBody(requestFixture["body"].asText()))
58+
.build()
59+
val signed = configuration.authenticator.authenticate(request)
6160

6261
assertThat(signed.headers.values("X-Amz-Date")).containsExactly(expected["date"].asText())
6362
assertThat(signed.headers.values("X-Amz-Content-Sha256"))
@@ -66,6 +65,17 @@ internal class BedrockAuthTest {
6665
.containsExactly(credentials["sessionToken"].asText())
6766
assertThat(signed.headers.values("Authorization"))
6867
.containsExactly(expected["authorization"].asText())
68+
assertThat(signed.headers.values("Proxy-Authorization")).isEmpty()
69+
70+
val proxyHeaders = listOf("Basic Zml4dHVyZTpmaXh0dXJl", "Basic c2Vjb25kOmZpeHR1cmU=")
71+
val signedWithProxy =
72+
configuration.authenticator.authenticate(
73+
request.toBuilder().putHeaders("pRoXy-AuThOrIzAtIoN", proxyHeaders).build()
74+
)
75+
assertThat(signedWithProxy.headers.values("Authorization"))
76+
.containsExactly(expected["authorization"].asText())
77+
assertThat(signedWithProxy.headers.values("Proxy-Authorization"))
78+
.containsExactlyElementsOf(proxyHeaders)
6979
}
7080

7181
@Test
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package com.openai.client.okhttp
2+
3+
import com.openai.core.http.ResponseWebSocketOptions
4+
import java.net.Proxy
5+
import java.time.Clock
6+
import java.time.Duration
7+
import java.time.Instant
8+
import java.time.ZoneOffset
9+
import java.util.concurrent.TimeUnit
10+
import java.util.stream.Stream
11+
import okhttp3.WebSocket
12+
import okhttp3.WebSocketListener
13+
import okhttp3.mockwebserver.MockResponse
14+
import okhttp3.mockwebserver.MockWebServer
15+
import org.assertj.core.api.Assertions.assertThat
16+
import org.junit.jupiter.api.DynamicTest
17+
import org.junit.jupiter.api.DynamicTest.dynamicTest
18+
import org.junit.jupiter.api.TestFactory
19+
20+
internal class BedrockWebSocketProxyTest {
21+
22+
@TestFactory
23+
fun sigV4SurvivesProxyHeaderPreparation(): Stream<DynamicTest> =
24+
Stream.of(false, true).map { throughHttpProxy ->
25+
dynamicTest("explicit HTTP proxy=$throughHttpProxy") {
26+
MockWebServer().use { peer ->
27+
repeat(2) {
28+
peer.enqueue(
29+
MockResponse()
30+
.withWebSocketUpgrade(
31+
object : WebSocketListener() {
32+
override fun onClosing(
33+
webSocket: WebSocket,
34+
code: Int,
35+
reason: String,
36+
) {
37+
webSocket.close(code, reason)
38+
}
39+
}
40+
)
41+
)
42+
}
43+
peer.start()
44+
val url =
45+
if (throughHttpProxy) "http://api.proxy-fixture.invalid/openai/v1/"
46+
else peer.url("/openai/v1/").toString()
47+
val proxy = if (throughHttpProxy) peer.toProxyAddress() else Proxy.NO_PROXY
48+
val sdk =
49+
BedrockOpenAIOkHttpClient.builder()
50+
.baseUrl(url)
51+
.proxy(proxy)
52+
.awsRegion("us-east-1")
53+
.awsAccessKeyId("fixtureAccessKey")
54+
.awsSecretAccessKey("fixtureSecretKey")
55+
.clock(
56+
Clock.fixed(Instant.parse("2026-01-02T03:04:05Z"), ZoneOffset.UTC)
57+
)
58+
.disableTelemetry(true)
59+
.timeout(Duration.ofSeconds(5))
60+
.build()
61+
try {
62+
val baseline =
63+
sdk.responses().connect().use {
64+
peer.takeRequest(5, TimeUnit.SECONDS)!!
65+
}
66+
val withProxyHeader =
67+
sdk.responses()
68+
.connect(
69+
ResponseWebSocketOptions.builder()
70+
.putHeader(
71+
"pRoXy-AuThOrIzAtIoN",
72+
"Basic Zml4dHVyZTpmaXh0dXJl",
73+
)
74+
.build()
75+
)
76+
.use { peer.takeRequest(5, TimeUnit.SECONDS)!! }
77+
78+
assertThat(baseline.method).isEqualTo("GET")
79+
assertThat(withProxyHeader.method).isEqualTo("GET")
80+
assertThat(baseline.getHeader("Proxy-Authorization")).isNull()
81+
assertThat(baseline.getHeader("Authorization"))
82+
.startsWith("AWS4-HMAC-SHA256 ")
83+
.contains("SignedHeaders=")
84+
assertThat(withProxyHeader.getHeader("Authorization"))
85+
.isEqualTo(baseline.getHeader("Authorization"))
86+
.doesNotContain("proxy-authorization")
87+
if (throughHttpProxy) {
88+
assertThat(withProxyHeader.getHeader("Proxy-Authorization"))
89+
.isEqualTo("Basic Zml4dHVyZTpmaXh0dXJl")
90+
} else {
91+
assertThat(withProxyHeader.getHeader("Proxy-Authorization")).isNull()
92+
}
93+
} finally {
94+
sdk.close()
95+
}
96+
}
97+
}
98+
}
99+
}

‎openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/BoundedWebSocket.kt‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import com.openai.core.http.WebSocketHandshakeException
66
import com.openai.core.http.WebSocketWriteNotAttempted
77
import java.io.IOException
88
import java.net.ProtocolException
9+
import java.net.Proxy
910
import java.time.Duration
1011
import java.util.concurrent.CompletableFuture
1112
import java.util.concurrent.TimeUnit
@@ -102,6 +103,17 @@ internal object BoundedWebSocket {
102103
.request()
103104
.newBuilder()
104105
.removeHeader("Sec-WebSocket-Extensions")
106+
.apply {
107+
val proxy = client.proxy
108+
// These routes cannot use a proxy-only origin header.
109+
// Leave dynamic plaintext routing to the native client.
110+
if (
111+
chain.request().url.isHttps ||
112+
(proxy != null && proxy.type() != Proxy.Type.HTTP)
113+
) {
114+
removeHeader("Proxy-Authorization")
115+
}
116+
}
105117
.build()
106118
)
107119
if (

0 commit comments

Comments
 (0)