Skip to content

Commit eaac0ee

Browse files
pavelzemanclaude
andcommitted
test: add unit tests for header validation fixes
Add standalone test-runner with 7 tests covering both interceptor fixes: CompressedResponseSizeInterceptor (AZ02): - brokenInterceptor_failsWithArabicLocale: proves locale-dependent formatting crashes - fixedInterceptor_succeedsWithArabicLocale: proves Locale.US fix works - fixedInterceptor_succeedsWithUSLocale: regression test BearerTokenInterceptor (AYPW): - brokenInterceptor_failsWithControlCharInToken: proves corrupt tokens crash - fixedInterceptor_succeedsWithControlCharInToken: proves sanitization works - fixedInterceptor_skipsHeaderWhenTokenIsAllControlChars: edge case - fixedInterceptor_passesCleanTokenUnmodified: regression test Run with: cd test-runner && ./gradlew test Co-authored-by: Claude <claude@anthropic.com>
1 parent 8862bca commit eaac0ee

2 files changed

Lines changed: 323 additions & 0 deletions

File tree

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
package com.mattermost.networkclient
2+
3+
import okhttp3.OkHttpClient
4+
import okhttp3.Request
5+
import okhttp3.mockwebserver.MockResponse
6+
import okhttp3.mockwebserver.MockWebServer
7+
import okhttp3.Interceptor
8+
import okhttp3.Response
9+
import org.junit.Assert
10+
import org.junit.Test
11+
12+
/**
13+
* Tests for bearer token sanitization in BearerTokenInterceptor.
14+
*
15+
* Reproduces MATTERMOST-MOBILE-ANDROID-AYPW: corrupted tokens containing
16+
* non-ASCII control characters (e.g. 0x02) cause OkHttp to throw
17+
* IllegalArgumentException when setting the Authorization header.
18+
*/
19+
class BearerTokenSanitizationTest {
20+
21+
/**
22+
* Interceptor that sets Authorization header WITHOUT sanitization (broken behavior).
23+
*/
24+
class BrokenTokenInterceptor(private val token: String) : Interceptor {
25+
override fun intercept(chain: Interceptor.Chain): Response {
26+
val request = chain.request().newBuilder()
27+
.header("Authorization", "Bearer $token")
28+
.build()
29+
return chain.proceed(request)
30+
}
31+
}
32+
33+
/**
34+
* Interceptor that sanitizes token to ASCII printable characters (fixed behavior).
35+
*/
36+
class FixedTokenInterceptor(private val token: String) : Interceptor {
37+
override fun intercept(chain: Interceptor.Chain): Response {
38+
val sanitizedToken = token.replace(Regex("[^\\x20-\\x7E]"), "")
39+
if (sanitizedToken.isEmpty()) {
40+
return chain.proceed(chain.request())
41+
}
42+
val request = chain.request().newBuilder()
43+
.header("Authorization", "Bearer $sanitizedToken")
44+
.build()
45+
return chain.proceed(request)
46+
}
47+
}
48+
49+
@Test
50+
fun brokenInterceptor_failsWithControlCharInToken() {
51+
val corruptToken = "abc\u0002def" // Contains 0x02 control character
52+
53+
val server = MockWebServer()
54+
server.enqueue(MockResponse().setBody("ok"))
55+
server.start()
56+
57+
val client = OkHttpClient.Builder()
58+
.addInterceptor(BrokenTokenInterceptor(corruptToken))
59+
.build()
60+
61+
val request = Request.Builder()
62+
.url(server.url("/test"))
63+
.build()
64+
65+
try {
66+
client.newCall(request).execute()
67+
Assert.fail("Expected IllegalArgumentException from OkHttp header validation")
68+
} catch (e: IllegalArgumentException) {
69+
Assert.assertTrue(
70+
"Expected error about unexpected char",
71+
e.message?.contains("Unexpected char") == true
72+
)
73+
}
74+
75+
server.shutdown()
76+
}
77+
78+
@Test
79+
fun fixedInterceptor_succeedsWithControlCharInToken() {
80+
val corruptToken = "abc\u0002def"
81+
82+
val server = MockWebServer()
83+
server.enqueue(MockResponse().setBody("ok"))
84+
server.start()
85+
86+
val client = OkHttpClient.Builder()
87+
.addInterceptor(FixedTokenInterceptor(corruptToken))
88+
.build()
89+
90+
val request = Request.Builder()
91+
.url(server.url("/test"))
92+
.build()
93+
94+
val response = client.newCall(request).execute()
95+
96+
// Should succeed without throwing
97+
Assert.assertEquals(200, response.code)
98+
99+
// Verify the server received the sanitized token
100+
val recordedRequest = server.takeRequest()
101+
val authHeader = recordedRequest.getHeader("Authorization")
102+
Assert.assertEquals("Bearer abcdef", authHeader)
103+
104+
server.shutdown()
105+
}
106+
107+
@Test
108+
fun fixedInterceptor_skipsHeaderWhenTokenIsAllControlChars() {
109+
val allControlChars = "\u0001\u0002\u0003"
110+
111+
val server = MockWebServer()
112+
server.enqueue(MockResponse().setBody("ok"))
113+
server.start()
114+
115+
val client = OkHttpClient.Builder()
116+
.addInterceptor(FixedTokenInterceptor(allControlChars))
117+
.build()
118+
119+
val request = Request.Builder()
120+
.url(server.url("/test"))
121+
.build()
122+
123+
val response = client.newCall(request).execute()
124+
Assert.assertEquals(200, response.code)
125+
126+
// No Authorization header should be set
127+
val recordedRequest = server.takeRequest()
128+
Assert.assertNull(recordedRequest.getHeader("Authorization"))
129+
130+
server.shutdown()
131+
}
132+
133+
@Test
134+
fun fixedInterceptor_passesCleanTokenUnmodified() {
135+
val cleanToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.valid.token"
136+
137+
val server = MockWebServer()
138+
server.enqueue(MockResponse().setBody("ok"))
139+
server.start()
140+
141+
val client = OkHttpClient.Builder()
142+
.addInterceptor(FixedTokenInterceptor(cleanToken))
143+
.build()
144+
145+
val request = Request.Builder()
146+
.url(server.url("/test"))
147+
.build()
148+
149+
val response = client.newCall(request).execute()
150+
Assert.assertEquals(200, response.code)
151+
152+
val recordedRequest = server.takeRequest()
153+
Assert.assertEquals("Bearer $cleanToken", recordedRequest.getHeader("Authorization"))
154+
155+
server.shutdown()
156+
}
157+
}
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
package com.mattermost.networkclient
2+
3+
import okhttp3.OkHttpClient
4+
import okhttp3.Request
5+
import okhttp3.mockwebserver.MockResponse
6+
import okhttp3.mockwebserver.MockWebServer
7+
import okhttp3.Interceptor
8+
import okhttp3.Response
9+
import okhttp3.ResponseBody.Companion.toResponseBody
10+
import org.junit.Assert
11+
import org.junit.Test
12+
import java.util.Locale
13+
14+
/**
15+
* Standalone test for CompressedResponseSizeInterceptor locale behavior.
16+
*
17+
* Reproduces MATTERMOST-MOBILE-ANDROID-AZ02: on Arabic-locale devices,
18+
* String.format() produces Arabic-Indic digits (U+0660 range) in the
19+
* X-Speed-Mbps header, which OkHttp rejects as invalid header characters.
20+
*/
21+
class CompressedResponseSizeInterceptorTest {
22+
23+
/**
24+
* Interceptor using the BROKEN locale-dependent formatting.
25+
* This is what the code did before the fix.
26+
*/
27+
class BrokenInterceptor : Interceptor {
28+
override fun intercept(chain: Interceptor.Chain): Response {
29+
val startTime = System.nanoTime()
30+
val response = chain.proceed(chain.request())
31+
val endTime = System.nanoTime()
32+
val elapsedTimeSeconds = (endTime - startTime) / 1_000_000_000.0
33+
val compressedSize = response.header("Content-Length")?.toLongOrNull() ?: 0L
34+
val speedMbps = if (elapsedTimeSeconds > 0 && compressedSize > 0) {
35+
(compressedSize * 8 / elapsedTimeSeconds) / 1_000_000.0
36+
} else {
37+
0.0
38+
}
39+
return response.newBuilder()
40+
.header("X-Speed-Mbps", "%.4f".format(speedMbps)) // locale-dependent!
41+
.build()
42+
}
43+
}
44+
45+
/**
46+
* Interceptor using the FIXED locale-independent formatting.
47+
*/
48+
class FixedInterceptor : Interceptor {
49+
override fun intercept(chain: Interceptor.Chain): Response {
50+
val startTime = System.nanoTime()
51+
val response = chain.proceed(chain.request())
52+
val endTime = System.nanoTime()
53+
val elapsedTimeSeconds = (endTime - startTime) / 1_000_000_000.0
54+
val compressedSize = response.header("Content-Length")?.toLongOrNull() ?: 0L
55+
val speedMbps = if (elapsedTimeSeconds > 0 && compressedSize > 0) {
56+
(compressedSize * 8 / elapsedTimeSeconds) / 1_000_000.0
57+
} else {
58+
0.0
59+
}
60+
return response.newBuilder()
61+
.header("X-Speed-Mbps", String.format(Locale.US, "%.4f", speedMbps))
62+
.build()
63+
}
64+
}
65+
66+
@Test
67+
fun brokenInterceptor_failsWithArabicLocale() {
68+
val originalLocale = Locale.getDefault()
69+
try {
70+
// Set Arabic locale — causes String.format to use Arabic-Indic digits
71+
Locale.setDefault(Locale("ar"))
72+
73+
val server = MockWebServer()
74+
server.enqueue(MockResponse()
75+
.setBody("hello")
76+
.setHeader("Content-Length", "5"))
77+
server.start()
78+
79+
val client = OkHttpClient.Builder()
80+
.addInterceptor(BrokenInterceptor())
81+
.build()
82+
83+
val request = Request.Builder()
84+
.url(server.url("/test"))
85+
.build()
86+
87+
// The broken interceptor produces Arabic-Indic digits in the header value.
88+
// OkHttp's header validation rejects non-ASCII characters.
89+
try {
90+
client.newCall(request).execute()
91+
Assert.fail("Expected IllegalArgumentException from OkHttp header validation")
92+
} catch (e: IllegalArgumentException) {
93+
Assert.assertTrue(
94+
"Expected error about unexpected char in header value",
95+
e.message?.contains("Unexpected char") == true
96+
)
97+
}
98+
99+
server.shutdown()
100+
} finally {
101+
Locale.setDefault(originalLocale)
102+
}
103+
}
104+
105+
@Test
106+
fun fixedInterceptor_succeedsWithArabicLocale() {
107+
val originalLocale = Locale.getDefault()
108+
try {
109+
Locale.setDefault(Locale("ar"))
110+
111+
val server = MockWebServer()
112+
server.enqueue(MockResponse()
113+
.setBody("hello")
114+
.setHeader("Content-Length", "5"))
115+
server.start()
116+
117+
val client = OkHttpClient.Builder()
118+
.addInterceptor(FixedInterceptor())
119+
.build()
120+
121+
val request = Request.Builder()
122+
.url(server.url("/test"))
123+
.build()
124+
125+
val response = client.newCall(request).execute()
126+
127+
// Should succeed without throwing
128+
val speedHeader = response.header("X-Speed-Mbps")
129+
Assert.assertNotNull("X-Speed-Mbps header should be present", speedHeader)
130+
131+
// Verify the value contains only ASCII characters
132+
Assert.assertTrue(
133+
"Header value should contain only ASCII: $speedHeader",
134+
speedHeader!!.all { it.code in 0x20..0x7E }
135+
)
136+
137+
server.shutdown()
138+
} finally {
139+
Locale.setDefault(originalLocale)
140+
}
141+
}
142+
143+
@Test
144+
fun fixedInterceptor_succeedsWithUSLocale() {
145+
val server = MockWebServer()
146+
server.enqueue(MockResponse()
147+
.setBody("hello")
148+
.setHeader("Content-Length", "5"))
149+
server.start()
150+
151+
val client = OkHttpClient.Builder()
152+
.addInterceptor(FixedInterceptor())
153+
.build()
154+
155+
val request = Request.Builder()
156+
.url(server.url("/test"))
157+
.build()
158+
159+
val response = client.newCall(request).execute()
160+
val speedHeader = response.header("X-Speed-Mbps")
161+
Assert.assertNotNull(speedHeader)
162+
Assert.assertTrue(speedHeader!!.all { it.code in 0x20..0x7E })
163+
164+
server.shutdown()
165+
}
166+
}

0 commit comments

Comments
 (0)