@onekeyfe/react-native-sni-connect 3.0.70 → 3.0.72

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,9 @@ package com.sniconnect
2
2
 
3
3
  import java.net.Inet6Address
4
4
  import java.net.InetAddress
5
+ import java.nio.charset.StandardCharsets
5
6
  import java.util.Locale
7
+ import java.util.concurrent.atomic.AtomicBoolean
6
8
 
7
9
  /**
8
10
  * Boundary validation/normalization for SNI request inputs.
@@ -16,16 +18,86 @@ internal object SniConnectValidation {
16
18
 
17
19
  class ValidationException(message: String) : IllegalArgumentException(message)
18
20
 
21
+ const val MAX_REQUEST_ID_BYTES = 128
22
+ const val MAX_TIMEOUT_MILLIS = 120_000L
23
+ const val MAX_PATH_BYTES = 8 * 1024
24
+ const val MAX_REQUEST_BODY_BYTES = 1024 * 1024
25
+ const val MAX_RESPONSE_BODY_BYTES = 10 * 1024 * 1024L
26
+ const val MAX_HEADER_COUNT = 64
27
+ const val MAX_HEADER_NAME_BYTES = 128
28
+ const val MAX_HEADER_VALUE_BYTES = 8 * 1024
29
+ const val MAX_TOTAL_HEADER_BYTES = 32 * 1024
30
+ const val MAX_ACTIVE_REQUESTS = 64
31
+ const val MAX_ACTIVE_REQUESTS_PER_PAIR = 16
32
+
19
33
  private val ALLOWED_METHODS =
20
34
  setOf("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS")
21
35
 
36
+ private val MODULE_OWNED_HEADERS = setOf(
37
+ "host",
38
+ "content-length",
39
+ "accept-encoding",
40
+ "x-emascurl-config-id",
41
+ )
42
+ private val UNSAFE_HEADERS = setOf(
43
+ "connection",
44
+ "keep-alive",
45
+ "te",
46
+ "trailer",
47
+ "transfer-encoding",
48
+ "upgrade",
49
+ "expect",
50
+ )
51
+
22
52
  private val HOSTNAME_REGEX = Regex(
23
53
  "^(?=.{1,253}\$)([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)(\\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\$"
24
54
  )
25
55
  private val IPV4_REGEX = Regex("^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\$")
26
56
  private val SCHEME_REGEX = Regex("^[A-Za-z][A-Za-z0-9+.-]*:")
57
+ private val HEADER_TOKEN_REGEX = Regex("^[!#$%&'*+.^_`|~0-9A-Za-z-]+\$")
58
+
59
+ fun validateRequestId(requestId: String?) {
60
+ if (requestId == null) return
61
+ if (requestId.isEmpty() || containsControlChars(requestId) || byteSize(requestId) > MAX_REQUEST_ID_BYTES) {
62
+ throw ValidationException("Invalid requestId")
63
+ }
64
+ }
65
+
66
+ fun validateTimeout(timeoutMillis: Long) {
67
+ if (timeoutMillis < 1L || timeoutMillis > MAX_TIMEOUT_MILLIS) {
68
+ throw ValidationException("Invalid timeout: $timeoutMillis")
69
+ }
70
+ }
71
+
72
+ fun parseTimeoutMillis(rawTimeoutMillis: Double): Long {
73
+ if (!rawTimeoutMillis.isFinite() ||
74
+ rawTimeoutMillis < 1.0 ||
75
+ rawTimeoutMillis > MAX_TIMEOUT_MILLIS.toDouble()
76
+ ) {
77
+ throw ValidationException("Invalid timeout: $rawTimeoutMillis")
78
+ }
79
+ return rawTimeoutMillis.toLong()
80
+ }
81
+
82
+ fun validateBody(body: String?) {
83
+ if (body != null && byteSize(body) > MAX_REQUEST_BODY_BYTES) {
84
+ throw ValidationException("Request body too large")
85
+ }
86
+ }
87
+
88
+ fun validateMethodBody(method: String, body: String?) {
89
+ if ((method == "GET" || method == "HEAD") && body != null) {
90
+ throw ValidationException("Body not allowed for method: $method")
91
+ }
92
+ if ((method == "POST" || method == "PUT" || method == "PATCH") && body == null) {
93
+ throw ValidationException("Body required for method: $method")
94
+ }
95
+ }
27
96
 
28
97
  fun normalizeMethod(method: String): String {
98
+ if (containsControlChars(method)) {
99
+ throw ValidationException("Invalid method: $method")
100
+ }
29
101
  val upper = method.trim().uppercase(Locale.US)
30
102
  if (upper !in ALLOWED_METHODS) {
31
103
  throw ValidationException("Invalid method: $method")
@@ -34,7 +106,12 @@ internal object SniConnectValidation {
34
106
  }
35
107
 
36
108
  fun validateHostname(hostname: String) {
37
- if (hostname.isEmpty() || hostname.length > 253 || !HOSTNAME_REGEX.matches(hostname)) {
109
+ if (
110
+ hostname.isEmpty() ||
111
+ hostname.length > 253 ||
112
+ !HOSTNAME_REGEX.matches(hostname) ||
113
+ isIpLiteral(hostname)
114
+ ) {
38
115
  throw ValidationException("Invalid hostname: $hostname")
39
116
  }
40
117
  }
@@ -45,6 +122,9 @@ internal object SniConnectValidation {
45
122
  if (containsControlChars(trimmed)) {
46
123
  throw ValidationException("Invalid path")
47
124
  }
125
+ if (byteSize(trimmed) > MAX_PATH_BYTES) {
126
+ throw ValidationException("Path too large")
127
+ }
48
128
  if (trimmed.contains("://") || trimmed.startsWith("//") || SCHEME_REGEX.containsMatchIn(trimmed.take(64).substringBefore('/'))) {
49
129
  throw ValidationException("Invalid path: absolute URLs are not allowed")
50
130
  }
@@ -53,22 +133,78 @@ internal object SniConnectValidation {
53
133
  }
54
134
 
55
135
  fun validateHeaders(headers: Map<String, String>) {
136
+ normalizeHeaders(headers)
137
+ }
138
+
139
+ fun normalizeHeaders(headers: Map<String, String>): Map<String, String> {
140
+ if (headers.size > MAX_HEADER_COUNT) {
141
+ throw ValidationException("Too many headers")
142
+ }
143
+
144
+ var totalBytes = 0
145
+ val normalizedHeaders = linkedMapOf<String, String>()
56
146
  for ((key, value) in headers) {
57
- if (key.isEmpty() || containsControlChars(key) || containsControlChars(value)) {
147
+ val keyBytes = byteSize(key)
148
+ val valueBytes = byteSize(value)
149
+ totalBytes += keyBytes + valueBytes
150
+
151
+ if (
152
+ key.isEmpty() ||
153
+ containsControlChars(key) ||
154
+ containsControlChars(value) ||
155
+ keyBytes > MAX_HEADER_NAME_BYTES ||
156
+ valueBytes > MAX_HEADER_VALUE_BYTES ||
157
+ !HEADER_TOKEN_REGEX.matches(key)
158
+ ) {
58
159
  throw ValidationException("Invalid header: $key")
59
160
  }
161
+
162
+ val lowerKey = key.lowercase(Locale.US)
163
+ if (lowerKey.startsWith(":") || lowerKey.startsWith("proxy-") || lowerKey in UNSAFE_HEADERS) {
164
+ throw ValidationException("Unsafe header: $key")
165
+ }
166
+ if (lowerKey in MODULE_OWNED_HEADERS) {
167
+ continue
168
+ }
169
+ normalizedHeaders[key] = value
170
+ }
171
+ if (totalBytes > MAX_TOTAL_HEADER_BYTES) {
172
+ throw ValidationException("Headers too large")
60
173
  }
174
+ return normalizedHeaders
61
175
  }
62
176
 
63
177
  private fun containsControlChars(s: String): Boolean =
64
178
  s.any { it.code < 0x20 || it.code == 0x7F }
65
179
 
180
+ private fun byteSize(s: String): Int = s.toByteArray(StandardCharsets.UTF_8).size
181
+
182
+ private fun isIpLiteral(value: String): Boolean {
183
+ val octets = IPV4_REGEX.matchEntire(value)?.groupValues?.drop(1)?.map { it.toInt() }
184
+ if (octets != null && octets.all { it <= 255 }) return true
185
+ if (!value.contains(':')) return false
186
+ return try {
187
+ InetAddress.getByName(value) is Inet6Address
188
+ } catch (_: Exception) {
189
+ false
190
+ }
191
+ }
192
+
66
193
  /**
67
194
  * Validate `ip` is a literal IPv4/IPv6 address (never a hostname) routing to a
68
195
  * public/global-unicast destination. Rejects loopback, private, link-local
69
196
  * (incl. 169.254.169.254 metadata), CGNAT, multicast and reserved ranges.
70
197
  */
71
198
  fun validatePublicIp(ip: String) {
199
+ if (
200
+ ip.isEmpty() ||
201
+ ip.trim() != ip ||
202
+ ip.contains('[') ||
203
+ ip.contains(']') ||
204
+ ip.contains('%')
205
+ ) {
206
+ throw ValidationException("Invalid IP: $ip")
207
+ }
72
208
  val octets = IPV4_REGEX.matchEntire(ip)?.groupValues?.drop(1)?.map { it.toInt() }
73
209
  if (octets != null) {
74
210
  if (octets.any { it > 255 }) throw ValidationException("Invalid IP: $ip")
@@ -117,22 +253,57 @@ internal object SniConnectValidation {
117
253
  }
118
254
  val bytes = addr.address
119
255
  // Unique local fc00::/7
120
- if ((bytes[0].toInt() and 0xFE) == 0xFC) return true
256
+ if ((u(bytes[0]) and 0xFE) == 0xFC) return true
257
+ // Discard-only 100::/64
258
+ if (u(bytes[0]) == 0x01 && u(bytes[1]) == 0x00 && (2..7).all { u(bytes[it]) == 0 }) return true
259
+ // IETF protocol assignments that should not be accepted as public endpoints.
260
+ if (u(bytes[0]) == 0x20 && u(bytes[1]) == 0x01) {
261
+ if (u(bytes[2]) == 0x00 && u(bytes[3]) == 0x00) return true // 2001::/32 Teredo
262
+ if (u(bytes[2]) == 0x00 && (u(bytes[3]) and 0xF0) == 0x10) return true // 2001:10::/28 ORCHID
263
+ if (u(bytes[2]) == 0x00 && u(bytes[3]) == 0x02) return true // 2001:2::/48 benchmarking
264
+ if (u(bytes[2]) == 0x0D && u(bytes[3]) == 0xB8) return true // 2001:db8::/32 docs
265
+ }
266
+ // 6to4 embeds an IPv4 route target and is deprecated; reject it outright.
267
+ if (u(bytes[0]) == 0x20 && u(bytes[1]) == 0x02) return true // 2002::/16
268
+ // NAT64 well-known prefix. Allow only when the embedded IPv4 is public.
269
+ if (isNat64WellKnown(bytes)) return isForbiddenIpv4(embeddedIpv4(bytes, 12))
270
+ // NAT64 local-use prefix can route through operator-specific private policy.
271
+ if (isNat64LocalUse(bytes)) return true
272
+ // Deprecated IPv4-compatible IPv6 addresses.
273
+ if (isIpv4Compatible(bytes)) return true
121
274
  // IPv4-mapped ::ffff:a.b.c.d — validate the embedded IPv4
122
- val mappedPrefixZero = (0..9).all { bytes[it].toInt() == 0 }
123
- if (mappedPrefixZero && (bytes[10].toInt() and 0xFF) == 0xFF && (bytes[11].toInt() and 0xFF) == 0xFF) {
124
- return isForbiddenIpv4(
125
- listOf(
126
- bytes[12].toInt() and 0xFF,
127
- bytes[13].toInt() and 0xFF,
128
- bytes[14].toInt() and 0xFF,
129
- bytes[15].toInt() and 0xFF,
130
- )
131
- )
275
+ if (isIpv4Mapped(bytes)) {
276
+ return isForbiddenIpv4(embeddedIpv4(bytes, 12))
132
277
  }
133
278
  return false
134
279
  }
135
280
 
281
+ private fun u(byte: Byte): Int = byte.toInt() and 0xFF
282
+
283
+ private fun embeddedIpv4(bytes: ByteArray, offset: Int): List<Int> =
284
+ listOf(u(bytes[offset]), u(bytes[offset + 1]), u(bytes[offset + 2]), u(bytes[offset + 3]))
285
+
286
+ private fun isIpv4Mapped(bytes: ByteArray): Boolean =
287
+ (0..9).all { u(bytes[it]) == 0 } && u(bytes[10]) == 0xFF && u(bytes[11]) == 0xFF
288
+
289
+ private fun isIpv4Compatible(bytes: ByteArray): Boolean =
290
+ (0..11).all { u(bytes[it]) == 0 }
291
+
292
+ private fun isNat64WellKnown(bytes: ByteArray): Boolean =
293
+ u(bytes[0]) == 0x00 &&
294
+ u(bytes[1]) == 0x64 &&
295
+ u(bytes[2]) == 0xFF &&
296
+ u(bytes[3]) == 0x9B &&
297
+ (4..11).all { u(bytes[it]) == 0 }
298
+
299
+ private fun isNat64LocalUse(bytes: ByteArray): Boolean =
300
+ u(bytes[0]) == 0x00 &&
301
+ u(bytes[1]) == 0x64 &&
302
+ u(bytes[2]) == 0xFF &&
303
+ u(bytes[3]) == 0x9B &&
304
+ u(bytes[4]) == 0x00 &&
305
+ u(bytes[5]) == 0x01
306
+
136
307
  /** Parse a validated IPv4/IPv6 literal into an InetAddress without DNS resolution. */
137
308
  fun literalToInetAddress(ip: String): InetAddress {
138
309
  val octets = IPV4_REGEX.matchEntire(ip)?.groupValues?.drop(1)?.map { it.toInt().toByte() }
@@ -142,3 +313,78 @@ internal object SniConnectValidation {
142
313
  return InetAddress.getByName(ip) // safe: already validated as an IPv6 literal
143
314
  }
144
315
  }
316
+
317
+ internal class SniConnectRequestLimiter(
318
+ private val maxActiveRequests: Int = SniConnectValidation.MAX_ACTIVE_REQUESTS,
319
+ private val maxActiveRequestsPerPair: Int = SniConnectValidation.MAX_ACTIVE_REQUESTS_PER_PAIR,
320
+ ) {
321
+ private val lock = Any()
322
+ private var activeRequests = 0
323
+ private val activeRequestsByPair = mutableMapOf<String, Int>()
324
+
325
+ fun acquire(hostname: String, ip: String): Token {
326
+ val key = pairKey(hostname, ip)
327
+ synchronized(lock) {
328
+ if (activeRequests >= maxActiveRequests) {
329
+ SniConnectLogger.warn(
330
+ SniConnectLogger.event(
331
+ "sni_resource_limit",
332
+ "activeCount" to activeRequests,
333
+ "pairCount" to (activeRequestsByPair[key] ?: 0),
334
+ "limit" to maxActiveRequests,
335
+ "reason" to "max_active_requests",
336
+ "hostname" to hostname.lowercase(Locale.US),
337
+ "ipHash" to SniConnectLogger.shortHash(ip),
338
+ ),
339
+ )
340
+ throw SniConnectValidation.ValidationException("Too many active SNI requests")
341
+ }
342
+ val pairCount = activeRequestsByPair[key] ?: 0
343
+ if (pairCount >= maxActiveRequestsPerPair) {
344
+ SniConnectLogger.warn(
345
+ SniConnectLogger.event(
346
+ "sni_resource_limit",
347
+ "activeCount" to activeRequests,
348
+ "pairCount" to pairCount,
349
+ "limit" to maxActiveRequestsPerPair,
350
+ "reason" to "max_active_requests_per_pair",
351
+ "hostname" to hostname.lowercase(Locale.US),
352
+ "ipHash" to SniConnectLogger.shortHash(ip),
353
+ ),
354
+ )
355
+ throw SniConnectValidation.ValidationException("Too many active SNI requests for destination")
356
+ }
357
+ activeRequests += 1
358
+ activeRequestsByPair[key] = pairCount + 1
359
+ }
360
+ return Token(this, key)
361
+ }
362
+
363
+ private fun release(key: String) {
364
+ synchronized(lock) {
365
+ activeRequests = (activeRequests - 1).coerceAtLeast(0)
366
+ val pairCount = activeRequestsByPair[key] ?: return
367
+ if (pairCount <= 1) {
368
+ activeRequestsByPair.remove(key)
369
+ } else {
370
+ activeRequestsByPair[key] = pairCount - 1
371
+ }
372
+ }
373
+ }
374
+
375
+ private fun pairKey(hostname: String, ip: String): String =
376
+ "${hostname.lowercase(Locale.US)}|$ip"
377
+
378
+ class Token internal constructor(
379
+ private val limiter: SniConnectRequestLimiter,
380
+ private val key: String,
381
+ ) {
382
+ private val released = AtomicBoolean(false)
383
+
384
+ fun release() {
385
+ if (released.compareAndSet(false, true)) {
386
+ limiter.release(key)
387
+ }
388
+ }
389
+ }
390
+ }
@@ -0,0 +1,257 @@
1
+ package com.sniconnect
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertFalse
5
+ import org.junit.Assert.assertTrue
6
+ import org.junit.Test
7
+ import java.io.IOException
8
+ import java.io.InterruptedIOException
9
+ import java.net.SocketTimeoutException
10
+ import java.net.UnknownHostException
11
+ import java.security.cert.CertificateException
12
+ import javax.net.ssl.SSLHandshakeException
13
+ import javax.net.ssl.SSLPeerUnverifiedException
14
+
15
+ class SniConnectValidationTest {
16
+
17
+ @Test
18
+ fun acceptsValidRequestBoundaryValues() {
19
+ SniConnectValidation.validateRequestId("req-1")
20
+ SniConnectValidation.validateTimeout(120_000)
21
+ SniConnectValidation.validateBody("a".repeat(1024 * 1024))
22
+ SniConnectValidation.validatePublicIp("93.184.216.34")
23
+ SniConnectValidation.validatePublicIp("2001:4860:4860::8888")
24
+ SniConnectValidation.validateHostname("api.example.com")
25
+
26
+ assertEquals("GET", SniConnectValidation.normalizeMethod(" get "))
27
+ assertEquals("/", SniConnectValidation.normalizePath(""))
28
+ assertEquals("/v1?q=1", SniConnectValidation.normalizePath("v1?q=1"))
29
+ }
30
+
31
+ @Test
32
+ fun rejectsIpLiteralHostnames() {
33
+ assertValidationFails { SniConnectValidation.validateHostname("93.184.216.34") }
34
+ assertValidationFails { SniConnectValidation.validateHostname("2001:4860:4860::8888") }
35
+ }
36
+
37
+ @Test
38
+ fun rejectsMalformedHostnames() {
39
+ listOf(
40
+ "",
41
+ "-example.com",
42
+ "example-.com",
43
+ "example..com",
44
+ "bad_host.example",
45
+ "https://example.com",
46
+ "example.com:443",
47
+ "${"a".repeat(64)}.example.com",
48
+ "${"a".repeat(250)}.com",
49
+ ).forEach { hostname ->
50
+ assertValidationFails { SniConnectValidation.validateHostname(hostname) }
51
+ }
52
+ }
53
+
54
+ @Test
55
+ fun rejectsUnsafeIpv4Destinations() {
56
+ listOf(
57
+ "example.com",
58
+ "93.184.216.34:443",
59
+ " 93.184.216.34",
60
+ "10.0.0.1",
61
+ "127.0.0.1",
62
+ "100.64.0.1",
63
+ "169.254.169.254",
64
+ "172.16.0.1",
65
+ "192.168.1.1",
66
+ "192.0.2.1",
67
+ "198.18.0.1",
68
+ "198.51.100.1",
69
+ "203.0.113.1",
70
+ "224.0.0.1",
71
+ "255.255.255.255",
72
+ ).forEach { ip ->
73
+ assertValidationFails { SniConnectValidation.validatePublicIp(ip) }
74
+ }
75
+ }
76
+
77
+ @Test
78
+ fun rejectsUnsafeIpv6DestinationsAndTransitionForms() {
79
+ listOf(
80
+ "::",
81
+ "::1",
82
+ "fe80::1",
83
+ "fc00::1",
84
+ "ff00::1",
85
+ "100::1",
86
+ "2001::1",
87
+ "2001:2::1",
88
+ "2001:db8::1",
89
+ "2002:0a00:0001::1",
90
+ "::ffff:10.0.0.1",
91
+ "64:ff9b::10.0.0.1",
92
+ "64:ff9b:1::1",
93
+ "2001:4860:4860::8888%en0",
94
+ "[2001:4860:4860::8888]",
95
+ ).forEach { ip ->
96
+ assertValidationFails { SniConnectValidation.validatePublicIp(ip) }
97
+ }
98
+ }
99
+
100
+ @Test
101
+ fun rejectsUnsupportedMethodsAndUnsafePaths() {
102
+ listOf("TRACE", "CONNECT", "", "GET\n").forEach { method ->
103
+ assertValidationFails { SniConnectValidation.normalizeMethod(method) }
104
+ }
105
+
106
+ listOf(
107
+ "https://example.com",
108
+ "http://example.com",
109
+ "//example.com/path",
110
+ "javascript:alert(1)",
111
+ "/path\nInjected: yes",
112
+ "/${"a".repeat(8192)}",
113
+ ).forEach { path ->
114
+ assertValidationFails { SniConnectValidation.normalizePath(path) }
115
+ }
116
+ }
117
+
118
+ @Test
119
+ fun enforcesRequestIdTimeoutAndBodyLimits() {
120
+ assertValidationFails { SniConnectValidation.validateRequestId("") }
121
+ assertValidationFails { SniConnectValidation.validateRequestId("x".repeat(129)) }
122
+ assertValidationFails { SniConnectValidation.validateRequestId("req\n1") }
123
+ assertValidationFails { SniConnectValidation.validateTimeout(0) }
124
+ assertValidationFails { SniConnectValidation.validateTimeout(120_001) }
125
+ assertEquals(1L, SniConnectValidation.parseTimeoutMillis(1.0))
126
+ assertEquals(120_000L, SniConnectValidation.parseTimeoutMillis(120_000.0))
127
+ assertValidationFails { SniConnectValidation.parseTimeoutMillis(Double.NaN) }
128
+ assertValidationFails { SniConnectValidation.parseTimeoutMillis(Double.POSITIVE_INFINITY) }
129
+ assertValidationFails { SniConnectValidation.parseTimeoutMillis(Double.NEGATIVE_INFINITY) }
130
+ assertValidationFails { SniConnectValidation.parseTimeoutMillis(0.0) }
131
+ assertValidationFails { SniConnectValidation.parseTimeoutMillis(0.5) }
132
+ assertValidationFails { SniConnectValidation.parseTimeoutMillis(120_000.1) }
133
+ assertValidationFails { SniConnectValidation.validateBody("a".repeat(1024 * 1024 + 1)) }
134
+ }
135
+
136
+ @Test
137
+ fun filtersModuleOwnedHeadersAndRejectsUnsafeHeaders() {
138
+ val normalized = SniConnectValidation.normalizeHeaders(
139
+ mapOf(
140
+ "Host" to "evil.example",
141
+ "Content-Length" to "9999",
142
+ "Accept-Encoding" to "gzip",
143
+ "x-emascurl-config-id" to "evil",
144
+ "X-Test" to "ok",
145
+ )
146
+ )
147
+
148
+ assertFalse(normalized.keys.any { it.equals("host", ignoreCase = true) })
149
+ assertFalse(normalized.keys.any { it.equals("content-length", ignoreCase = true) })
150
+ assertFalse(normalized.keys.any { it.equals("accept-encoding", ignoreCase = true) })
151
+ assertEquals("ok", normalized["X-Test"])
152
+
153
+ listOf(
154
+ mapOf("Connection" to "close"),
155
+ mapOf("Proxy-Authorization" to "secret"),
156
+ mapOf("Transfer-Encoding" to "chunked"),
157
+ mapOf("Expect" to "100-continue"),
158
+ mapOf(":authority" to "evil.example"),
159
+ mapOf("Bad Header" to "x"),
160
+ mapOf("X-Test" to "line\nbreak"),
161
+ mapOf("X-Test" to "x".repeat(8 * 1024 + 1)),
162
+ ).forEach { headers ->
163
+ assertValidationFails { SniConnectValidation.normalizeHeaders(headers) }
164
+ }
165
+
166
+ assertValidationFails {
167
+ SniConnectValidation.normalizeHeaders((0..64).associate { "X-$it" to "v" })
168
+ }
169
+ assertValidationFails {
170
+ SniConnectValidation.normalizeHeaders((0..4).associate { "X-$it" to "x".repeat(7 * 1024) })
171
+ }
172
+ }
173
+
174
+ @Test
175
+ fun rejectsAmbiguousMethodBodyCombinations() {
176
+ assertValidationFails { SniConnectValidation.validateMethodBody("GET", "") }
177
+ assertValidationFails { SniConnectValidation.validateMethodBody("HEAD", "payload") }
178
+ assertValidationFails { SniConnectValidation.validateMethodBody("POST", null) }
179
+ assertValidationFails { SniConnectValidation.validateMethodBody("PUT", null) }
180
+ assertValidationFails { SniConnectValidation.validateMethodBody("PATCH", null) }
181
+
182
+ SniConnectValidation.validateMethodBody("POST", "")
183
+ SniConnectValidation.validateMethodBody("DELETE", null)
184
+ SniConnectValidation.validateMethodBody("OPTIONS", null)
185
+ }
186
+
187
+ @Test
188
+ fun requestLimiterEnforcesGlobalAndPerDestinationLimits() {
189
+ val limiter = SniConnectRequestLimiter(
190
+ maxActiveRequests = 2,
191
+ maxActiveRequestsPerPair = 1,
192
+ )
193
+
194
+ val firstToken = limiter.acquire("Example.com", "93.184.216.34")
195
+ assertValidationFails {
196
+ limiter.acquire("example.com", "93.184.216.34")
197
+ }
198
+
199
+ val secondToken = limiter.acquire("example.com", "93.184.216.35")
200
+ assertValidationFails {
201
+ limiter.acquire("example.net", "93.184.216.36")
202
+ }
203
+
204
+ firstToken.release()
205
+ val replacementToken = limiter.acquire("example.com", "93.184.216.34")
206
+ firstToken.release()
207
+ secondToken.release()
208
+ replacementToken.release()
209
+
210
+ assertTrue(true)
211
+ }
212
+
213
+ @Test
214
+ fun classifiesSecurityFailuresAsFailClosedErrorCodes() {
215
+ assertEquals(
216
+ "SNI_CERT_FAILED",
217
+ classifySniFailureCode(SSLPeerUnverifiedException("hostname mismatch")),
218
+ )
219
+ assertEquals(
220
+ "SNI_CERT_FAILED",
221
+ classifySniFailureCode(SSLHandshakeException("bad cert").apply {
222
+ initCause(CertificateException("expired"))
223
+ }),
224
+ )
225
+ assertEquals(
226
+ "SNI_TLS_FAILED",
227
+ classifySniFailureCode(SSLHandshakeException("handshake failed")),
228
+ )
229
+ assertEquals(
230
+ "SNI_SECURITY_POLICY_FAILED",
231
+ classifySniFailureCode(UnknownHostException("Unexpected host for pinned SNI request")),
232
+ )
233
+ assertEquals(
234
+ "SNI_REQUEST_TIMEOUT",
235
+ classifySniFailureCode(SocketTimeoutException("timeout")),
236
+ )
237
+ assertEquals(
238
+ "SNI_REQUEST_TIMEOUT",
239
+ classifySniFailureCode(IOException("call timeout").apply {
240
+ initCause(InterruptedIOException("timeout"))
241
+ }),
242
+ )
243
+ assertEquals(
244
+ "SNI_REQUEST_FAILED",
245
+ classifySniFailureCode(IOException("connection reset")),
246
+ )
247
+ }
248
+
249
+ private fun assertValidationFails(block: () -> Unit) {
250
+ try {
251
+ block()
252
+ } catch (_: SniConnectValidation.ValidationException) {
253
+ return
254
+ }
255
+ throw AssertionError("Expected SNI validation failure")
256
+ }
257
+ }
package/ios/SniConnect.mm CHANGED
@@ -18,6 +18,9 @@
18
18
  reject:(RCTPromiseRejectBlock)reject;
19
19
  - (void)clearDNSCache:(RCTPromiseResolveBlock)resolve
20
20
  reject:(RCTPromiseRejectBlock)reject;
21
+ - (void)isProxyActiveForUrl:(NSString *)url
22
+ resolve:(RCTPromiseResolveBlock)resolve
23
+ reject:(RCTPromiseRejectBlock)reject;
21
24
  @end
22
25
 
23
26
  @interface SniConnect : NSObject
@@ -86,6 +89,12 @@ RCT_EXPORT_MODULE(SniConnect)
86
89
  [_implementation clearDNSCache:resolve reject:reject];
87
90
  }
88
91
 
92
+ - (void)isProxyActiveForUrl:(NSString *)url
93
+ resolve:(RCTPromiseResolveBlock)resolve
94
+ reject:(RCTPromiseRejectBlock)reject {
95
+ [_implementation isProxyActiveForUrl:url resolve:resolve reject:reject];
96
+ }
97
+
89
98
  - (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
90
99
  (const facebook::react::ObjCTurboModule::InitParams &)params
91
100
  {
@@ -114,6 +123,12 @@ RCT_EXPORT_METHOD(clearDNSCache:(RCTPromiseResolveBlock)resolver
114
123
  rejecter:(RCTPromiseRejectBlock)rejecter) {
115
124
  [_implementation clearDNSCache:resolver reject:rejecter];
116
125
  }
126
+
127
+ RCT_EXPORT_METHOD(isProxyActiveForUrl:(NSString *)url
128
+ resolver:(RCTPromiseResolveBlock)resolver
129
+ rejecter:(RCTPromiseRejectBlock)rejecter) {
130
+ [_implementation isProxyActiveForUrl:url resolve:resolver reject:rejecter];
131
+ }
117
132
  #endif
118
133
 
119
134
  @end