@onekeyfe/react-native-sni-connect 3.0.81-alpha.7 → 3.0.81
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.
- package/README.md +7 -0
- package/android/src/main/java/com/sniconnect/SniConnectModule.kt +213 -61
- package/android/src/main/java/com/sniconnect/SniConnectRequestAdmission.kt +276 -0
- package/android/src/main/java/com/sniconnect/SniConnectValidation.kt +6 -76
- package/android/src/test/java/com/sniconnect/SniConnectRequestAdmissionTest.kt +299 -0
- package/android/src/test/java/com/sniconnect/SniConnectValidationTest.kt +26 -26
- package/ios/SniConnect.mm +19 -0
- package/ios/SniConnect.swift +29 -0
- package/ios/SniConnectClient.swift +75 -84
- package/ios/SniConnectValidation.swift +214 -41
- package/ios/Tests/SniConnectValidationTests/SniConnectValidationTests.swift +206 -8
- package/lib/module/index.js +3 -0
- package/lib/typescript/src/NativeSniConnect.d.ts +13 -0
- package/lib/typescript/src/index.d.ts +3 -2
- package/package.json +2 -3
- package/src/NativeSniConnect.ts +17 -0
- package/src/index.tsx +10 -0
- package/android/src/main/java/com/sniconnect/SniPinnedTransport.kt +0 -58
- package/ios/SniConnectPinnedTransport.swift +0 -92
package/README.md
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
cancelRequest,
|
|
39
39
|
cancelAllRequests,
|
|
40
40
|
clearDNSCache,
|
|
41
|
+
getDebugSnapshot,
|
|
41
42
|
isProxyActiveForUrl,
|
|
42
43
|
} from '@onekeyfe/react-native-sni-connect';
|
|
43
44
|
|
|
@@ -66,6 +67,12 @@ await cancelAllRequests();
|
|
|
66
67
|
|
|
67
68
|
// Drop pinned-IP connections / cached clients
|
|
68
69
|
await clearDNSCache();
|
|
70
|
+
|
|
71
|
+
// Diagnostics for one validated hostname/IP pair
|
|
72
|
+
const snapshot = await getDebugSnapshot({
|
|
73
|
+
hostname: 'example.com',
|
|
74
|
+
ip: '93.184.216.34',
|
|
75
|
+
});
|
|
69
76
|
```
|
|
70
77
|
|
|
71
78
|
`multiValueHeaders` preserves repeated response headers when the native transport
|
|
@@ -13,9 +13,11 @@ import okhttp3.Call
|
|
|
13
13
|
import okhttp3.Callback
|
|
14
14
|
import okhttp3.ConnectionPool
|
|
15
15
|
import okhttp3.Dispatcher
|
|
16
|
+
import okhttp3.Dns
|
|
16
17
|
import okhttp3.Headers
|
|
17
18
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
|
18
19
|
import okhttp3.OkHttpClient
|
|
20
|
+
import okhttp3.Protocol
|
|
19
21
|
import okhttp3.Request
|
|
20
22
|
import okhttp3.RequestBody.Companion.toRequestBody
|
|
21
23
|
import okhttp3.Response
|
|
@@ -23,6 +25,7 @@ import okhttp3.ResponseBody
|
|
|
23
25
|
import okio.Buffer
|
|
24
26
|
import java.io.IOException
|
|
25
27
|
import java.io.InterruptedIOException
|
|
28
|
+
import java.net.InetAddress
|
|
26
29
|
import java.net.Proxy
|
|
27
30
|
import java.net.ProxySelector
|
|
28
31
|
import java.net.URI
|
|
@@ -34,6 +37,7 @@ import java.util.Locale
|
|
|
34
37
|
import java.util.concurrent.ConcurrentHashMap
|
|
35
38
|
import java.util.concurrent.TimeUnit
|
|
36
39
|
import java.util.concurrent.atomic.AtomicBoolean
|
|
40
|
+
import javax.net.ssl.HttpsURLConnection
|
|
37
41
|
import javax.net.ssl.SSLException
|
|
38
42
|
import javax.net.ssl.SSLPeerUnverifiedException
|
|
39
43
|
|
|
@@ -57,6 +61,15 @@ internal fun classifySniFailureCode(error: Throwable): String {
|
|
|
57
61
|
return "SNI_REQUEST_FAILED"
|
|
58
62
|
}
|
|
59
63
|
|
|
64
|
+
internal fun classifySniResponseFailureCode(
|
|
65
|
+
error: Throwable,
|
|
66
|
+
explicitlyCancelled: Boolean,
|
|
67
|
+
): String = when {
|
|
68
|
+
explicitlyCancelled -> "SNI_CANCELLED"
|
|
69
|
+
hasCause(error, InterruptedIOException::class.java) -> "SNI_REQUEST_TIMEOUT"
|
|
70
|
+
else -> "SNI_RESPONSE_FAILED"
|
|
71
|
+
}
|
|
72
|
+
|
|
60
73
|
private fun hasCause(error: Throwable, type: Class<out Throwable>): Boolean {
|
|
61
74
|
var current: Throwable? = error
|
|
62
75
|
while (current != null) {
|
|
@@ -77,12 +90,12 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
77
90
|
// from JS-controlled host/IP pairs (e.g. speed-testing many endpoints).
|
|
78
91
|
private const val MAX_CLIENTS = 32
|
|
79
92
|
|
|
80
|
-
//
|
|
81
|
-
// don't spawn a thread pool / connection pool per (hostname, ip) pair.
|
|
93
|
+
// Native resources are process-shared across the main and background RN runtimes.
|
|
82
94
|
private val sharedDispatcher = Dispatcher().apply {
|
|
83
|
-
maxRequests =
|
|
84
|
-
maxRequestsPerHost =
|
|
95
|
+
maxRequests = SniConnectValidation.MAX_ACTIVE_REQUESTS
|
|
96
|
+
maxRequestsPerHost = SniConnectValidation.MAX_ACTIVE_REQUESTS
|
|
85
97
|
}
|
|
98
|
+
private val sharedAdmission = SniConnectRequestAdmission()
|
|
86
99
|
private val sharedConnectionPool = ConnectionPool()
|
|
87
100
|
}
|
|
88
101
|
|
|
@@ -120,10 +133,33 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
120
133
|
}
|
|
121
134
|
}
|
|
122
135
|
|
|
123
|
-
private
|
|
124
|
-
|
|
136
|
+
private class ManagedRequest(
|
|
137
|
+
val call: Call,
|
|
138
|
+
val settled: AtomicBoolean,
|
|
139
|
+
) {
|
|
140
|
+
lateinit var admissionTicket: SniConnectRequestAdmission.Ticket
|
|
141
|
+
private val explicitlyCancelled = AtomicBoolean(false)
|
|
142
|
+
|
|
143
|
+
fun cancel() {
|
|
144
|
+
explicitlyCancelled.set(true)
|
|
145
|
+
if (!admissionTicket.cancelPending()) {
|
|
146
|
+
call.cancel()
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
fun release() {
|
|
151
|
+
admissionTicket.release()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
fun wasExplicitlyCancelled(): Boolean = explicitlyCancelled.get()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Cancellation ownership is per module, so cancelAllRequests only affects its RN runtime.
|
|
158
|
+
private val activeCalls = ConcurrentHashMap<String, ManagedRequest>()
|
|
159
|
+
private val allActiveCalls = Collections.newSetFromMap(
|
|
160
|
+
ConcurrentHashMap<ManagedRequest, Boolean>(),
|
|
161
|
+
)
|
|
125
162
|
private val activeCallsLock = Any()
|
|
126
|
-
private val requestLimiter = SniConnectRequestLimiter()
|
|
127
163
|
|
|
128
164
|
override fun getName(): String = NAME
|
|
129
165
|
|
|
@@ -147,11 +183,11 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
147
183
|
|
|
148
184
|
@ReactMethod
|
|
149
185
|
override fun cancelRequest(requestId: String, promise: Promise) {
|
|
150
|
-
val
|
|
186
|
+
val request = synchronized(activeCallsLock) {
|
|
151
187
|
activeCalls.remove(requestId)
|
|
152
188
|
}
|
|
153
|
-
if (
|
|
154
|
-
|
|
189
|
+
if (request != null) {
|
|
190
|
+
request.cancel()
|
|
155
191
|
SniConnectLogger.info(
|
|
156
192
|
SniConnectLogger.event(
|
|
157
193
|
"sni_cancel",
|
|
@@ -174,17 +210,17 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
174
210
|
|
|
175
211
|
@ReactMethod
|
|
176
212
|
override fun cancelAllRequests(promise: Promise) {
|
|
177
|
-
val
|
|
213
|
+
val requests = synchronized(activeCallsLock) {
|
|
178
214
|
val snapshot = allActiveCalls.toList()
|
|
179
215
|
activeCalls.clear()
|
|
180
216
|
allActiveCalls.clear()
|
|
181
217
|
snapshot
|
|
182
218
|
}
|
|
183
|
-
|
|
219
|
+
requests.forEach { request -> request.cancel() }
|
|
184
220
|
SniConnectLogger.info(
|
|
185
221
|
SniConnectLogger.event(
|
|
186
222
|
"sni_cancel_all",
|
|
187
|
-
"cancelledCount" to
|
|
223
|
+
"cancelledCount" to requests.size,
|
|
188
224
|
"success" to true,
|
|
189
225
|
),
|
|
190
226
|
)
|
|
@@ -208,6 +244,29 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
208
244
|
promise.resolve(Arguments.createMap().apply { putBoolean("success", true) })
|
|
209
245
|
}
|
|
210
246
|
|
|
247
|
+
@ReactMethod
|
|
248
|
+
override fun getDebugSnapshot(target: ReadableMap, promise: Promise) {
|
|
249
|
+
try {
|
|
250
|
+
val ip = target.getString("ip") ?: throw IllegalArgumentException("ip is required")
|
|
251
|
+
val hostname = target.getString("hostname")
|
|
252
|
+
?: throw IllegalArgumentException("hostname is required")
|
|
253
|
+
val canonicalIp = SniConnectValidation.canonicalizePublicIp(ip)
|
|
254
|
+
SniConnectValidation.validateHostname(hostname)
|
|
255
|
+
val snapshot = sharedAdmission.snapshot(hostname, canonicalIp)
|
|
256
|
+
|
|
257
|
+
promise.resolve(Arguments.createMap().apply {
|
|
258
|
+
putInt("activeRequests", snapshot.activeRequests)
|
|
259
|
+
putInt("activeRequestsForPair", snapshot.activeRequestsForPair)
|
|
260
|
+
putInt("pendingRequests", snapshot.pendingRequests)
|
|
261
|
+
putInt("pendingRequestsForPair", snapshot.pendingRequestsForPair)
|
|
262
|
+
putArray("activeRequestIdsForPair", snapshot.activeRequestIdsForPair.toWritableArray())
|
|
263
|
+
putArray("pendingRequestIdsForPair", snapshot.pendingRequestIdsForPair.toWritableArray())
|
|
264
|
+
})
|
|
265
|
+
} catch (error: Exception) {
|
|
266
|
+
promise.reject("SNI_INVALID_CONFIG", error.message, error)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
211
270
|
@ReactMethod
|
|
212
271
|
override fun isProxyActiveForUrl(url: String, promise: Promise) {
|
|
213
272
|
try {
|
|
@@ -219,19 +278,14 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
219
278
|
|
|
220
279
|
private fun performRequest(config: RequestConfig, promise: Promise) {
|
|
221
280
|
val startedAtMs = android.os.SystemClock.elapsedRealtime()
|
|
222
|
-
|
|
223
|
-
var
|
|
281
|
+
val startedAtNanos = System.nanoTime()
|
|
282
|
+
var registeredRequest: ManagedRequest? = null
|
|
224
283
|
try {
|
|
225
|
-
requestSlot = requestLimiter.acquire(config.hostname, config.ip)
|
|
226
284
|
val client = getOrCreateClient(config)
|
|
227
285
|
val request = buildRequest(config)
|
|
228
286
|
val call = client.newCall(request)
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
call.timeout().timeout(config.timeoutMillis, TimeUnit.MILLISECONDS)
|
|
232
|
-
|
|
233
|
-
registerCall(config.requestId, call)
|
|
234
|
-
registeredCall = call
|
|
287
|
+
val settled = AtomicBoolean(false)
|
|
288
|
+
lateinit var managedRequest: ManagedRequest
|
|
235
289
|
|
|
236
290
|
SniConnectLogger.info(
|
|
237
291
|
SniConnectLogger.event(
|
|
@@ -247,16 +301,13 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
247
301
|
),
|
|
248
302
|
)
|
|
249
303
|
|
|
250
|
-
|
|
251
|
-
val settled = AtomicBoolean(false)
|
|
252
|
-
|
|
253
|
-
call.enqueue(object : Callback {
|
|
304
|
+
val callback = object : Callback {
|
|
254
305
|
override fun onFailure(call: Call, e: IOException) {
|
|
255
|
-
|
|
256
|
-
|
|
306
|
+
unregisterRequest(config.requestId, managedRequest)
|
|
307
|
+
managedRequest.release()
|
|
257
308
|
if (!settled.compareAndSet(false, true)) return
|
|
258
309
|
|
|
259
|
-
if (
|
|
310
|
+
if (managedRequest.wasExplicitlyCancelled()) {
|
|
260
311
|
promise.reject("SNI_CANCELLED", "Request cancelled", null)
|
|
261
312
|
} else {
|
|
262
313
|
val code = classifySniFailureCode(e)
|
|
@@ -310,17 +361,29 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
310
361
|
SniConnectLogger.info(resultLog)
|
|
311
362
|
}
|
|
312
363
|
if (settled.compareAndSet(false, true)) {
|
|
313
|
-
|
|
364
|
+
if (managedRequest.wasExplicitlyCancelled()) {
|
|
365
|
+
promise.reject("SNI_CANCELLED", "Request cancelled", null)
|
|
366
|
+
} else {
|
|
367
|
+
promise.resolve(result)
|
|
368
|
+
}
|
|
314
369
|
}
|
|
315
370
|
}
|
|
316
371
|
}
|
|
317
372
|
} catch (error: Exception) {
|
|
318
373
|
if (!settled.compareAndSet(false, true)) return
|
|
374
|
+
val code = classifySniResponseFailureCode(
|
|
375
|
+
error,
|
|
376
|
+
managedRequest.wasExplicitlyCancelled(),
|
|
377
|
+
)
|
|
378
|
+
if (code == "SNI_CANCELLED") {
|
|
379
|
+
promise.reject(code, "Request cancelled", null)
|
|
380
|
+
return
|
|
381
|
+
}
|
|
319
382
|
SniConnectLogger.error(
|
|
320
383
|
SniConnectLogger.event(
|
|
321
384
|
"sni_request_result",
|
|
322
385
|
"result" to "error",
|
|
323
|
-
"code" to
|
|
386
|
+
"code" to code,
|
|
324
387
|
"nativeErrorClass" to error.javaClass.simpleName,
|
|
325
388
|
"requestIdHash" to SniConnectLogger.shortHash(config.requestId),
|
|
326
389
|
"hostname" to config.hostname.lowercase(Locale.US),
|
|
@@ -331,22 +394,57 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
331
394
|
"elapsedMs" to SniConnectLogger.elapsedMs(startedAtMs),
|
|
332
395
|
),
|
|
333
396
|
)
|
|
334
|
-
promise.reject(
|
|
397
|
+
promise.reject(code, error.message, error)
|
|
335
398
|
} finally {
|
|
336
|
-
|
|
337
|
-
|
|
399
|
+
unregisterRequest(config.requestId, managedRequest)
|
|
400
|
+
managedRequest.release()
|
|
338
401
|
}
|
|
339
402
|
}
|
|
340
|
-
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
val timeoutBeforeAdmission = remainingTimeoutMillis(
|
|
406
|
+
config.timeoutMillis,
|
|
407
|
+
startedAtNanos,
|
|
408
|
+
)
|
|
409
|
+
val admissionTicket = sharedAdmission.createTicket(
|
|
410
|
+
hostname = config.hostname,
|
|
411
|
+
ip = config.ip,
|
|
412
|
+
requestId = config.requestId,
|
|
413
|
+
timeoutMillis = timeoutBeforeAdmission,
|
|
414
|
+
onAdmitted = { remainingTimeoutMillis ->
|
|
415
|
+
try {
|
|
416
|
+
call.timeout().timeout(remainingTimeoutMillis, TimeUnit.MILLISECONDS)
|
|
417
|
+
call.enqueue(callback)
|
|
418
|
+
} catch (error: Exception) {
|
|
419
|
+
unregisterRequest(config.requestId, managedRequest)
|
|
420
|
+
managedRequest.release()
|
|
421
|
+
if (settled.compareAndSet(false, true)) {
|
|
422
|
+
promise.reject("SNI_REQUEST_FAILED", error.message, error)
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
},
|
|
426
|
+
onPendingFailure = { code, message ->
|
|
427
|
+
unregisterRequest(config.requestId, managedRequest)
|
|
428
|
+
if (settled.compareAndSet(false, true)) {
|
|
429
|
+
promise.reject(code, message, null)
|
|
430
|
+
}
|
|
431
|
+
},
|
|
432
|
+
)
|
|
433
|
+
managedRequest = ManagedRequest(call, settled).apply {
|
|
434
|
+
this.admissionTicket = admissionTicket
|
|
435
|
+
}
|
|
436
|
+
registerRequest(config.requestId, managedRequest)
|
|
437
|
+
registeredRequest = managedRequest
|
|
438
|
+
admissionTicket.submit()
|
|
341
439
|
} catch (error: Exception) {
|
|
342
|
-
|
|
343
|
-
|
|
440
|
+
registeredRequest?.let { request ->
|
|
441
|
+
unregisterRequest(config.requestId, request)
|
|
442
|
+
request.release()
|
|
344
443
|
}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
"
|
|
348
|
-
|
|
349
|
-
"SNI_REQUEST_FAILED"
|
|
444
|
+
val code = when {
|
|
445
|
+
error is SniConnectValidation.ValidationException -> "SNI_RESOURCE_LIMIT"
|
|
446
|
+
hasCause(error, InterruptedIOException::class.java) -> "SNI_REQUEST_TIMEOUT"
|
|
447
|
+
else -> "SNI_REQUEST_FAILED"
|
|
350
448
|
}
|
|
351
449
|
SniConnectLogger.error(
|
|
352
450
|
SniConnectLogger.event(
|
|
@@ -363,18 +461,21 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
363
461
|
"elapsedMs" to SniConnectLogger.elapsedMs(startedAtMs),
|
|
364
462
|
),
|
|
365
463
|
)
|
|
366
|
-
|
|
464
|
+
val shouldReject = registeredRequest?.settled?.compareAndSet(false, true) ?: true
|
|
465
|
+
if (shouldReject) {
|
|
466
|
+
promise.reject(code, error.message, error)
|
|
467
|
+
}
|
|
367
468
|
}
|
|
368
469
|
}
|
|
369
470
|
|
|
370
|
-
private fun
|
|
371
|
-
val
|
|
372
|
-
val previous = requestId?.let { activeCalls.put(it,
|
|
373
|
-
allActiveCalls.add(
|
|
471
|
+
private fun registerRequest(requestId: String?, request: ManagedRequest) {
|
|
472
|
+
val previousRequest = synchronized(activeCallsLock) {
|
|
473
|
+
val previous = requestId?.let { activeCalls.put(it, request) }
|
|
474
|
+
allActiveCalls.add(request)
|
|
374
475
|
previous
|
|
375
476
|
}
|
|
376
|
-
if (
|
|
377
|
-
|
|
477
|
+
if (previousRequest != null && previousRequest != request) {
|
|
478
|
+
previousRequest.cancel()
|
|
378
479
|
SniConnectLogger.warn(
|
|
379
480
|
SniConnectLogger.event(
|
|
380
481
|
"sni_duplicate_request_id",
|
|
@@ -385,28 +486,52 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
385
486
|
}
|
|
386
487
|
}
|
|
387
488
|
|
|
388
|
-
private fun
|
|
489
|
+
private fun unregisterRequest(requestId: String?, request: ManagedRequest) {
|
|
389
490
|
synchronized(activeCallsLock) {
|
|
390
491
|
if (requestId != null) {
|
|
391
|
-
activeCalls.remove(requestId,
|
|
492
|
+
activeCalls.remove(requestId, request)
|
|
392
493
|
}
|
|
393
|
-
allActiveCalls.remove(
|
|
494
|
+
allActiveCalls.remove(request)
|
|
394
495
|
}
|
|
395
496
|
}
|
|
396
497
|
|
|
498
|
+
private fun remainingTimeoutMillis(totalTimeoutMillis: Long, startedAtNanos: Long): Long {
|
|
499
|
+
val elapsedNanos = (System.nanoTime() - startedAtNanos).coerceAtLeast(0L)
|
|
500
|
+
val remainingNanos = TimeUnit.MILLISECONDS.toNanos(totalTimeoutMillis) - elapsedNanos
|
|
501
|
+
if (remainingNanos <= 0L) {
|
|
502
|
+
throw java.net.SocketTimeoutException("Request timed out before admission")
|
|
503
|
+
}
|
|
504
|
+
return ((remainingNanos + 999_999L) / 1_000_000L).coerceAtLeast(1L)
|
|
505
|
+
}
|
|
506
|
+
|
|
397
507
|
private fun getOrCreateClient(config: RequestConfig): OkHttpClient {
|
|
398
508
|
val normalizedHost = config.hostname.lowercase(Locale.US)
|
|
399
|
-
val key = ClientKey(
|
|
509
|
+
val key = ClientKey(
|
|
510
|
+
normalizedHost,
|
|
511
|
+
SniConnectValidation.canonicalizePublicIp(config.ip),
|
|
512
|
+
)
|
|
400
513
|
|
|
401
514
|
synchronized(clientCache) {
|
|
402
515
|
clientCache[key]?.let { return it }
|
|
403
516
|
|
|
404
|
-
val client =
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
517
|
+
val client = OkHttpClient.Builder()
|
|
518
|
+
.dispatcher(sharedDispatcher)
|
|
519
|
+
.connectionPool(sharedConnectionPool)
|
|
520
|
+
.proxy(Proxy.NO_PROXY)
|
|
521
|
+
.protocols(listOf(Protocol.HTTP_1_1))
|
|
522
|
+
.connectTimeout(0, TimeUnit.MILLISECONDS)
|
|
523
|
+
.readTimeout(0, TimeUnit.MILLISECONDS)
|
|
524
|
+
.writeTimeout(0, TimeUnit.MILLISECONDS)
|
|
525
|
+
.callTimeout(0, TimeUnit.MILLISECONDS)
|
|
526
|
+
.followRedirects(false)
|
|
527
|
+
.followSslRedirects(false)
|
|
528
|
+
// TLS is validated normally: cert chain via the default trust manager and
|
|
529
|
+
// hostname verification against the REAL hostname (not the pinned IP).
|
|
530
|
+
.hostnameVerifier { _, session ->
|
|
531
|
+
HttpsURLConnection.getDefaultHostnameVerifier().verify(config.hostname, session)
|
|
532
|
+
}
|
|
533
|
+
.dns(createPinnedDns(key.ip, config.hostname))
|
|
534
|
+
.build()
|
|
410
535
|
|
|
411
536
|
clientCache[key] = client
|
|
412
537
|
SniConnectLogger.info(
|
|
@@ -426,6 +551,29 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
426
551
|
}
|
|
427
552
|
}
|
|
428
553
|
|
|
554
|
+
private fun createPinnedDns(ip: String, hostname: String): Dns =
|
|
555
|
+
object : Dns {
|
|
556
|
+
private val expectedHost = hostname.lowercase(Locale.US)
|
|
557
|
+
// Resolve the literal IP once up front (validated; never triggers DNS).
|
|
558
|
+
private val pinnedAddress: InetAddress = SniConnectValidation.literalToInetAddress(ip)
|
|
559
|
+
|
|
560
|
+
override fun lookup(requestedHost: String): List<InetAddress> {
|
|
561
|
+
return if (requestedHost.lowercase(Locale.US) == expectedHost) {
|
|
562
|
+
listOf(pinnedAddress)
|
|
563
|
+
} else {
|
|
564
|
+
SniConnectLogger.warn(
|
|
565
|
+
SniConnectLogger.event(
|
|
566
|
+
"sni_pinned_dns_unexpected_host",
|
|
567
|
+
"expectedHost" to expectedHost,
|
|
568
|
+
"requestedHostHash" to SniConnectLogger.shortHash(requestedHost.lowercase(Locale.US)),
|
|
569
|
+
"result" to "fail_closed",
|
|
570
|
+
),
|
|
571
|
+
)
|
|
572
|
+
throw UnknownHostException("Unexpected host for pinned SNI request: $requestedHost")
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
429
577
|
/**
|
|
430
578
|
* Build the request. Always `https://<hostname><path>` on the implicit port 443 —
|
|
431
579
|
* `path` has been validated as relative, so scheme/host/port cannot be overridden.
|
|
@@ -523,6 +671,10 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
523
671
|
}
|
|
524
672
|
}
|
|
525
673
|
|
|
674
|
+
private fun List<String>.toWritableArray() = Arguments.createArray().apply {
|
|
675
|
+
forEach { value -> pushString(value) }
|
|
676
|
+
}
|
|
677
|
+
|
|
526
678
|
private fun ReadableMap.toRequestConfig(): RequestConfig {
|
|
527
679
|
val rawHeadersMap = if (hasKey("headers") && !isNull("headers")) {
|
|
528
680
|
getMap("headers")?.toHashMap()
|
|
@@ -540,7 +692,7 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
540
692
|
|
|
541
693
|
val requestId = if (hasKey("requestId") && !isNull("requestId")) getString("requestId") else null
|
|
542
694
|
|
|
543
|
-
val
|
|
695
|
+
val rawIp = getString("ip") ?: throw IllegalArgumentException("ip is required")
|
|
544
696
|
val hostname = getString("hostname") ?: throw IllegalArgumentException("hostname is required")
|
|
545
697
|
val method = getString("method") ?: "GET"
|
|
546
698
|
val path = getString("path") ?: "/"
|
|
@@ -548,7 +700,7 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
548
700
|
|
|
549
701
|
// Validate every caller-controlled field at the boundary.
|
|
550
702
|
SniConnectValidation.validateRequestId(requestId)
|
|
551
|
-
SniConnectValidation.
|
|
703
|
+
val ip = SniConnectValidation.canonicalizePublicIp(rawIp)
|
|
552
704
|
SniConnectValidation.validateHostname(hostname)
|
|
553
705
|
val headersMap = SniConnectValidation.normalizeHeaders(rawHeadersMap)
|
|
554
706
|
val normalizedMethod = SniConnectValidation.normalizeMethod(method)
|