@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.
- package/README.md +44 -1
- package/SniConnect.podspec +2 -3
- package/android/build.gradle +1 -0
- package/android/src/main/java/com/sniconnect/SniConnectLogger.kt +32 -1
- package/android/src/main/java/com/sniconnect/SniConnectModule.kt +508 -68
- package/android/src/main/java/com/sniconnect/SniConnectValidation.kt +259 -13
- package/android/src/test/java/com/sniconnect/SniConnectValidationTest.kt +257 -0
- package/ios/SniConnect.mm +15 -0
- package/ios/SniConnect.swift +119 -22
- package/ios/SniConnectClient.swift +512 -175
- package/ios/SniConnectCore.swift +228 -0
- package/ios/SniConnectLog.swift +39 -0
- package/ios/SniConnectValidation.swift +243 -1
- package/ios/Tests/SniConnectValidationTests/SniConnectValidationTests.swift +325 -0
- package/lib/module/index.js +3 -0
- package/lib/typescript/src/NativeSniConnect.d.ts +25 -4
- package/lib/typescript/src/index.d.ts +2 -1
- package/package.json +9 -1
- package/src/NativeSniConnect.ts +35 -4
- package/src/index.tsx +12 -1
|
@@ -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 (
|
|
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
|
-
|
|
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]
|
|
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
|
-
|
|
123
|
-
|
|
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
|