@onekeyfe/react-native-sni-connect 6.7.0-alpha.362 → 6.7.0-alpha.364
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 +8 -0
- package/android/src/main/java/com/sniconnect/SniConnectModule.kt +96 -9
- package/android/src/main/java/com/sniconnect/SniConnectValidation.kt +68 -0
- package/android/src/main/java/com/sniconnect/SniPinnedTransport.kt +43 -0
- package/android/src/test/java/com/sniconnect/SniConnectValidationTest.kt +124 -0
- package/ios/SniConnect.mm +27 -0
- package/ios/SniConnect.swift +63 -0
- package/ios/SniConnectClient.swift +27 -4
- package/ios/SniConnectValidation.swift +71 -0
- package/ios/Tests/SniConnectValidationTests/SniConnectValidationTests.swift +113 -0
- package/lib/module/NativeSniConnect.js +6 -0
- package/lib/module/index.js +11 -0
- package/lib/typescript/src/NativeSniConnect.d.ts +15 -0
- package/lib/typescript/src/index.d.ts +4 -2
- package/package.json +1 -1
- package/src/NativeSniConnect.ts +20 -0
- package/src/index.tsx +18 -0
package/README.md
CHANGED
|
@@ -40,6 +40,8 @@ import {
|
|
|
40
40
|
clearDNSCache,
|
|
41
41
|
getDebugSnapshot,
|
|
42
42
|
isProxyActiveForUrl,
|
|
43
|
+
uploadFile,
|
|
44
|
+
isUploadFileSupported,
|
|
43
45
|
} from '@onekeyfe/react-native-sni-connect';
|
|
44
46
|
|
|
45
47
|
const proxyActive = await isProxyActiveForUrl('https://example.com/api/v1/ping');
|
|
@@ -82,6 +84,9 @@ collapsed by the transport before this adapter can observe them. Full duplicate
|
|
|
82
84
|
header preservation on iOS requires an EMASCurl version that exposes the raw
|
|
83
85
|
response header list.
|
|
84
86
|
|
|
87
|
+
Check `isUploadFileSupported()` before uploading when JS can run with older native
|
|
88
|
+
binaries. `uploadFile()` rejects with `code: SNI_UNSUPPORTED` when unavailable.
|
|
89
|
+
|
|
85
90
|
## Specification
|
|
86
91
|
|
|
87
92
|
The behavior of the SNI connect module is governed by a normative,
|
|
@@ -90,6 +95,9 @@ shared JS adapters, and any future platform) must conform to:
|
|
|
90
95
|
|
|
91
96
|
**-> [OneKey SNI Connect Standard (OSCS)](https://github.com/OneKeyHQ/app-modules/blob/main/native-modules/react-native-sni-connect/SPEC.md)**
|
|
92
97
|
|
|
98
|
+
The [native upload contract](docs/SPEC.md) defines capability detection and
|
|
99
|
+
consumption-time file bounds.
|
|
100
|
+
|
|
93
101
|
Any change to request validation, destination pinning, TLS validation, redirect
|
|
94
102
|
handling, cancellation, response shape, or cache behavior must be checked
|
|
95
103
|
against OSCS.
|
|
@@ -21,6 +21,7 @@ import okhttp3.RequestBody.Companion.toRequestBody
|
|
|
21
21
|
import okhttp3.Response
|
|
22
22
|
import okhttp3.ResponseBody
|
|
23
23
|
import okio.Buffer
|
|
24
|
+
import java.io.File
|
|
24
25
|
import java.io.IOException
|
|
25
26
|
import java.io.InterruptedIOException
|
|
26
27
|
import java.net.Proxy
|
|
@@ -191,6 +192,24 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
191
192
|
}
|
|
192
193
|
}
|
|
193
194
|
|
|
195
|
+
override fun uploadFile(config: ReadableMap, promise: Promise) {
|
|
196
|
+
try {
|
|
197
|
+
val requestConfig = config.toUploadFileConfig()
|
|
198
|
+
performRequest(requestConfig, promise)
|
|
199
|
+
} catch (error: Exception) {
|
|
200
|
+
SniConnectLogger.error(
|
|
201
|
+
SniConnectLogger.event(
|
|
202
|
+
"sni_request_result",
|
|
203
|
+
"result" to "error",
|
|
204
|
+
"code" to "SNI_INVALID_CONFIG",
|
|
205
|
+
"nativeErrorClass" to error.javaClass.simpleName,
|
|
206
|
+
"stage" to "parse_upload_config",
|
|
207
|
+
),
|
|
208
|
+
)
|
|
209
|
+
promise.reject("SNI_INVALID_CONFIG", error.message, error)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
194
213
|
@ReactMethod
|
|
195
214
|
override fun cancelRequest(requestId: String, promise: Promise) {
|
|
196
215
|
val request = synchronized(activeCallsLock) {
|
|
@@ -301,7 +320,8 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
301
320
|
"method" to config.method,
|
|
302
321
|
"timeoutMs" to config.timeoutMillis,
|
|
303
322
|
"headerCount" to config.headers.size,
|
|
304
|
-
"bodyBytes" to
|
|
323
|
+
"bodyBytes" to config.bodyBytes(),
|
|
324
|
+
"bodySource" to if (config.bodyFile != null) "file" else "string",
|
|
305
325
|
),
|
|
306
326
|
)
|
|
307
327
|
|
|
@@ -349,6 +369,7 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
349
369
|
"ipFamily" to SniConnectLogger.ipFamily(config.ip),
|
|
350
370
|
"method" to config.method,
|
|
351
371
|
"timeoutMs" to config.timeoutMillis,
|
|
372
|
+
"bodyBytes" to config.bodyBytes(),
|
|
352
373
|
"responseBytes" to bodyString.toByteArray(StandardCharsets.UTF_8).size,
|
|
353
374
|
"elapsedMs" to SniConnectLogger.elapsedMs(startedAtMs),
|
|
354
375
|
)
|
|
@@ -581,13 +602,13 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
581
602
|
builder.header("Accept-Encoding", "identity")
|
|
582
603
|
|
|
583
604
|
val method = config.method
|
|
584
|
-
val
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
605
|
+
val mediaType = config.headers.entries
|
|
606
|
+
.firstOrNull { it.key.equals("Content-Type", ignoreCase = true) }
|
|
607
|
+
?.value
|
|
608
|
+
?.toMediaTypeOrNull()
|
|
609
|
+
// Enforce the body bound when OkHttp consumes the file, after admission.
|
|
610
|
+
val requestBody = config.bodyFile?.let { SniUploadFileRequestBody(it, mediaType) }
|
|
611
|
+
?: config.body?.toRequestBody(mediaType)
|
|
591
612
|
|
|
592
613
|
when (method) {
|
|
593
614
|
"GET" -> builder.get()
|
|
@@ -713,6 +734,67 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
713
734
|
)
|
|
714
735
|
}
|
|
715
736
|
|
|
737
|
+
/** Uploads may only read files from this app's own storage. */
|
|
738
|
+
private fun uploadFileAllowedRoots(): List<File> {
|
|
739
|
+
val context = reactApplicationContext
|
|
740
|
+
return listOfNotNull(
|
|
741
|
+
File(context.applicationInfo.dataDir),
|
|
742
|
+
context.filesDir,
|
|
743
|
+
context.cacheDir,
|
|
744
|
+
context.externalCacheDir,
|
|
745
|
+
context.getExternalFilesDir(null),
|
|
746
|
+
)
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
private fun ReadableMap.toUploadFileConfig(): RequestConfig {
|
|
750
|
+
if (hasKey("body") && !isNull("body")) {
|
|
751
|
+
throw IllegalArgumentException("uploadFile does not accept body")
|
|
752
|
+
}
|
|
753
|
+
val filePath = if (hasKey("filePath") && !isNull("filePath")) getString("filePath") else null
|
|
754
|
+
if (filePath.isNullOrEmpty()) {
|
|
755
|
+
throw IllegalArgumentException("filePath is required")
|
|
756
|
+
}
|
|
757
|
+
val rawHeadersMap = if (hasKey("headers") && !isNull("headers")) {
|
|
758
|
+
getMap("headers")?.toHashMap()
|
|
759
|
+
?.mapValues { (_, value) -> value?.toString() ?: "" }
|
|
760
|
+
?: emptyMap()
|
|
761
|
+
} else {
|
|
762
|
+
emptyMap()
|
|
763
|
+
}
|
|
764
|
+
val timeoutMillis = if (hasKey("timeout") && !isNull("timeout")) {
|
|
765
|
+
SniConnectValidation.parseTimeoutMillis(getDouble("timeout"))
|
|
766
|
+
} else {
|
|
767
|
+
30_000L
|
|
768
|
+
}
|
|
769
|
+
val requestId = if (hasKey("requestId") && !isNull("requestId")) getString("requestId") else null
|
|
770
|
+
val rawIp = getString("ip") ?: throw IllegalArgumentException("ip is required")
|
|
771
|
+
val hostname = getString("hostname") ?: throw IllegalArgumentException("hostname is required")
|
|
772
|
+
val method = getString("method") ?: "POST"
|
|
773
|
+
val path = getString("path") ?: "/"
|
|
774
|
+
|
|
775
|
+
SniConnectValidation.validateRequestId(requestId)
|
|
776
|
+
val ip = SniConnectValidation.canonicalizePublicIp(rawIp)
|
|
777
|
+
SniConnectValidation.validateHostname(hostname)
|
|
778
|
+
val headersMap = SniConnectValidation.normalizeHeaders(rawHeadersMap)
|
|
779
|
+
val normalizedMethod = SniConnectValidation.normalizeMethod(method)
|
|
780
|
+
SniConnectValidation.validateUploadMethod(normalizedMethod)
|
|
781
|
+
val normalizedPath = SniConnectValidation.normalizePath(path)
|
|
782
|
+
SniConnectValidation.validateTimeout(timeoutMillis)
|
|
783
|
+
val bodyFile = SniConnectValidation.resolveUploadFile(filePath, uploadFileAllowedRoots())
|
|
784
|
+
|
|
785
|
+
return RequestConfig(
|
|
786
|
+
requestId = requestId,
|
|
787
|
+
ip = ip,
|
|
788
|
+
hostname = hostname,
|
|
789
|
+
method = normalizedMethod,
|
|
790
|
+
path = normalizedPath,
|
|
791
|
+
headers = headersMap,
|
|
792
|
+
body = null,
|
|
793
|
+
timeoutMillis = timeoutMillis,
|
|
794
|
+
bodyFile = bodyFile,
|
|
795
|
+
)
|
|
796
|
+
}
|
|
797
|
+
|
|
716
798
|
private data class RequestConfig(
|
|
717
799
|
val requestId: String?,
|
|
718
800
|
val ip: String,
|
|
@@ -722,7 +804,12 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
722
804
|
val headers: Map<String, String>,
|
|
723
805
|
val body: String?,
|
|
724
806
|
val timeoutMillis: Long,
|
|
725
|
-
|
|
807
|
+
// Set only by uploadFile(); already resolved inside the app sandbox.
|
|
808
|
+
val bodyFile: File? = null,
|
|
809
|
+
) {
|
|
810
|
+
fun bodyBytes(): Long =
|
|
811
|
+
bodyFile?.length() ?: (body?.toByteArray(StandardCharsets.UTF_8)?.size?.toLong() ?: 0L)
|
|
812
|
+
}
|
|
726
813
|
|
|
727
814
|
private data class HeaderMaps(
|
|
728
815
|
val singleValueHeaders: Map<String, String>,
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
package com.sniconnect
|
|
2
2
|
|
|
3
|
+
import java.io.File
|
|
4
|
+
import java.io.IOException
|
|
3
5
|
import java.net.Inet6Address
|
|
6
|
+
import java.net.URI
|
|
4
7
|
import java.net.InetAddress
|
|
5
8
|
import java.nio.charset.StandardCharsets
|
|
6
9
|
import java.util.Locale
|
|
@@ -22,6 +25,7 @@ internal object SniConnectValidation {
|
|
|
22
25
|
const val MAX_PATH_BYTES = 8 * 1024
|
|
23
26
|
const val MAX_REQUEST_BODY_BYTES = 1024 * 1024
|
|
24
27
|
const val MAX_RESPONSE_BODY_BYTES = 10 * 1024 * 1024L
|
|
28
|
+
const val MAX_UPLOAD_FILE_BYTES = 20 * 1024 * 1024L
|
|
25
29
|
const val MAX_HEADER_COUNT = 64
|
|
26
30
|
const val MAX_HEADER_NAME_BYTES = 128
|
|
27
31
|
const val MAX_HEADER_VALUE_BYTES = 8 * 1024
|
|
@@ -94,6 +98,70 @@ internal object SniConnectValidation {
|
|
|
94
98
|
}
|
|
95
99
|
}
|
|
96
100
|
|
|
101
|
+
/** File uploads always carry a body, so only body-bearing write methods apply. */
|
|
102
|
+
fun validateUploadMethod(method: String) {
|
|
103
|
+
if (method != "POST" && method != "PUT") {
|
|
104
|
+
throw ValidationException("Invalid upload method: $method")
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Resolve a caller-supplied upload path to a regular file inside one of
|
|
110
|
+
* [allowedRoots] (the app's own storage). Canonicalization resolves symlinks
|
|
111
|
+
* before the containment check so a link cannot point the upload elsewhere.
|
|
112
|
+
*/
|
|
113
|
+
fun resolveUploadFile(rawPath: String, allowedRoots: List<File>): File {
|
|
114
|
+
if (rawPath.isEmpty() || containsControlChars(rawPath)) {
|
|
115
|
+
throw ValidationException("Invalid file path")
|
|
116
|
+
}
|
|
117
|
+
val file = when {
|
|
118
|
+
rawPath.startsWith("file:") -> {
|
|
119
|
+
val uri = try {
|
|
120
|
+
URI(rawPath)
|
|
121
|
+
} catch (error: Exception) {
|
|
122
|
+
throw ValidationException("Invalid file URL")
|
|
123
|
+
}
|
|
124
|
+
val path = uri.path
|
|
125
|
+
if (uri.scheme != "file" || path.isNullOrEmpty()) {
|
|
126
|
+
throw ValidationException("Invalid file URL")
|
|
127
|
+
}
|
|
128
|
+
File(path)
|
|
129
|
+
}
|
|
130
|
+
rawPath.startsWith("/") -> File(rawPath)
|
|
131
|
+
else -> throw ValidationException("File path must be absolute")
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
val canonicalFile = try {
|
|
135
|
+
file.canonicalFile
|
|
136
|
+
} catch (error: IOException) {
|
|
137
|
+
throw ValidationException("File path cannot be resolved")
|
|
138
|
+
}
|
|
139
|
+
val canonicalPath = canonicalFile.path
|
|
140
|
+
val isInsideAllowedRoot = allowedRoots.any { root ->
|
|
141
|
+
val rootPath = try {
|
|
142
|
+
root.canonicalPath
|
|
143
|
+
} catch (error: IOException) {
|
|
144
|
+
return@any false
|
|
145
|
+
}
|
|
146
|
+
val prefix = if (rootPath.endsWith(File.separator)) rootPath else rootPath + File.separator
|
|
147
|
+
canonicalPath.startsWith(prefix)
|
|
148
|
+
}
|
|
149
|
+
if (!isInsideAllowedRoot) {
|
|
150
|
+
throw ValidationException("File is outside the app sandbox")
|
|
151
|
+
}
|
|
152
|
+
if (!canonicalFile.isFile || !canonicalFile.canRead()) {
|
|
153
|
+
throw ValidationException("Not a readable regular file")
|
|
154
|
+
}
|
|
155
|
+
val sizeBytes = canonicalFile.length()
|
|
156
|
+
if (sizeBytes <= 0L) {
|
|
157
|
+
throw ValidationException("File is empty")
|
|
158
|
+
}
|
|
159
|
+
if (sizeBytes > MAX_UPLOAD_FILE_BYTES) {
|
|
160
|
+
throw ValidationException("File is too large")
|
|
161
|
+
}
|
|
162
|
+
return canonicalFile
|
|
163
|
+
}
|
|
164
|
+
|
|
97
165
|
fun normalizeMethod(method: String): String {
|
|
98
166
|
if (containsControlChars(method)) {
|
|
99
167
|
throw ValidationException("Invalid method: $method")
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
package com.sniconnect
|
|
2
2
|
|
|
3
|
+
import java.io.File
|
|
4
|
+
import java.io.IOException
|
|
3
5
|
import java.net.InetAddress
|
|
4
6
|
import java.net.Proxy
|
|
5
7
|
import java.net.UnknownHostException
|
|
@@ -8,8 +10,13 @@ import java.util.concurrent.TimeUnit
|
|
|
8
10
|
import okhttp3.ConnectionPool
|
|
9
11
|
import okhttp3.Dispatcher
|
|
10
12
|
import okhttp3.Dns
|
|
13
|
+
import okhttp3.MediaType
|
|
11
14
|
import okhttp3.OkHttpClient
|
|
12
15
|
import okhttp3.Protocol
|
|
16
|
+
import okhttp3.RequestBody
|
|
17
|
+
import okio.Buffer
|
|
18
|
+
import okio.BufferedSink
|
|
19
|
+
import okio.source
|
|
13
20
|
import javax.net.ssl.HttpsURLConnection
|
|
14
21
|
|
|
15
22
|
object SniPinnedTransport {
|
|
@@ -56,3 +63,39 @@ object SniPinnedTransport {
|
|
|
56
63
|
}
|
|
57
64
|
}
|
|
58
65
|
}
|
|
66
|
+
|
|
67
|
+
/** Reads from disk only during transmission, with a fixed, bounded body length. */
|
|
68
|
+
internal class SniUploadFileRequestBody(
|
|
69
|
+
private val file: File,
|
|
70
|
+
private val mediaType: MediaType?,
|
|
71
|
+
) : RequestBody() {
|
|
72
|
+
private val length = file.length().also {
|
|
73
|
+
if (it <= 0 || it > SniConnectValidation.MAX_UPLOAD_FILE_BYTES) {
|
|
74
|
+
throw IOException("Upload file size is out of range")
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
override fun contentType(): MediaType? = mediaType
|
|
79
|
+
|
|
80
|
+
override fun contentLength(): Long = length
|
|
81
|
+
|
|
82
|
+
override fun writeTo(sink: BufferedSink) {
|
|
83
|
+
file.source().use { source ->
|
|
84
|
+
val buffer = Buffer()
|
|
85
|
+
var totalBytes = 0L
|
|
86
|
+
while (true) {
|
|
87
|
+
// One extra byte detects growth without ever writing beyond the bound.
|
|
88
|
+
val read = source.read(buffer, minOf(8 * 1024L, length - totalBytes + 1))
|
|
89
|
+
if (read == -1L) break
|
|
90
|
+
if (totalBytes + read > length) {
|
|
91
|
+
throw IOException("Upload file grew during transmission")
|
|
92
|
+
}
|
|
93
|
+
sink.write(buffer, read)
|
|
94
|
+
totalBytes += read
|
|
95
|
+
}
|
|
96
|
+
if (totalBytes != length) {
|
|
97
|
+
throw IOException("Upload file was truncated during transmission")
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -3,8 +3,17 @@ package com.sniconnect
|
|
|
3
3
|
import org.junit.Assert.assertEquals
|
|
4
4
|
import org.junit.Assert.assertFalse
|
|
5
5
|
import org.junit.Assert.assertTrue
|
|
6
|
+
import org.junit.Assert.assertThrows
|
|
7
|
+
import okio.Buffer
|
|
8
|
+
import okio.ForwardingSink
|
|
9
|
+
import okio.buffer
|
|
10
|
+
import org.junit.Rule
|
|
6
11
|
import org.junit.Test
|
|
12
|
+
import org.junit.rules.TemporaryFolder
|
|
13
|
+
import java.io.File
|
|
7
14
|
import java.io.IOException
|
|
15
|
+
import java.io.RandomAccessFile
|
|
16
|
+
import java.nio.file.Files
|
|
8
17
|
import java.io.InterruptedIOException
|
|
9
18
|
import java.net.SocketTimeoutException
|
|
10
19
|
import java.net.UnknownHostException
|
|
@@ -14,6 +23,121 @@ import javax.net.ssl.SSLPeerUnverifiedException
|
|
|
14
23
|
|
|
15
24
|
class SniConnectValidationTest {
|
|
16
25
|
|
|
26
|
+
@get:Rule
|
|
27
|
+
val tempFolder = TemporaryFolder()
|
|
28
|
+
|
|
29
|
+
@Test
|
|
30
|
+
fun uploadMethodAllowsOnlyPostAndPut() {
|
|
31
|
+
SniConnectValidation.validateUploadMethod("POST")
|
|
32
|
+
SniConnectValidation.validateUploadMethod("PUT")
|
|
33
|
+
listOf("GET", "HEAD", "PATCH", "DELETE", "OPTIONS").forEach { method ->
|
|
34
|
+
assertValidationFails { SniConnectValidation.validateUploadMethod(method) }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
@Test
|
|
39
|
+
fun resolveUploadFileAcceptsSandboxFileByPathAndFileUrl() {
|
|
40
|
+
val root = tempFolder.newFolder("sandbox")
|
|
41
|
+
val file = File(root, "logs.zip").apply { writeBytes(byteArrayOf(0x50, 0x4B, 3, 4, -1)) }
|
|
42
|
+
|
|
43
|
+
val byPath = SniConnectValidation.resolveUploadFile(file.path, listOf(root))
|
|
44
|
+
assertEquals(file.canonicalPath, byPath.path)
|
|
45
|
+
val byUrl = SniConnectValidation.resolveUploadFile(file.toURI().toString(), listOf(root))
|
|
46
|
+
assertEquals(file.canonicalPath, byUrl.path)
|
|
47
|
+
val byTripleSlashUrl = SniConnectValidation.resolveUploadFile("file://${file.path}", listOf(root))
|
|
48
|
+
assertEquals(file.canonicalPath, byTripleSlashUrl.path)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@Test
|
|
52
|
+
fun resolveUploadFileRejectsInvalidOrOutOfSandboxFiles() {
|
|
53
|
+
val root = tempFolder.newFolder("sandbox")
|
|
54
|
+
val outside = tempFolder.newFolder("outside")
|
|
55
|
+
val outsideFile = File(outside, "secret.bin").apply { writeBytes(byteArrayOf(1, 2, 3)) }
|
|
56
|
+
val emptyFile = File(root, "empty.zip").apply { writeBytes(ByteArray(0)) }
|
|
57
|
+
val link = File(root, "link.zip")
|
|
58
|
+
Files.createSymbolicLink(link.toPath(), outsideFile.toPath())
|
|
59
|
+
|
|
60
|
+
listOf(
|
|
61
|
+
"",
|
|
62
|
+
"relative/logs.zip",
|
|
63
|
+
"https://example.com/logs.zip",
|
|
64
|
+
outsideFile.path,
|
|
65
|
+
"${root.path}/../outside/secret.bin",
|
|
66
|
+
link.path,
|
|
67
|
+
emptyFile.path,
|
|
68
|
+
root.path,
|
|
69
|
+
File(root, "missing.zip").path,
|
|
70
|
+
"${root.path}/logs\n.zip",
|
|
71
|
+
).forEach { path ->
|
|
72
|
+
assertValidationFails { SniConnectValidation.resolveUploadFile(path, listOf(root)) }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@Test
|
|
77
|
+
fun resolveUploadFileRejectsFilesOverTheSizeLimit() {
|
|
78
|
+
val root = tempFolder.newFolder("sandbox")
|
|
79
|
+
val file = File(root, "big.zip")
|
|
80
|
+
RandomAccessFile(file, "rw").use { it.setLength(SniConnectValidation.MAX_UPLOAD_FILE_BYTES + 1) }
|
|
81
|
+
|
|
82
|
+
assertValidationFails { SniConnectValidation.resolveUploadFile(file.path, listOf(root)) }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
@Test
|
|
86
|
+
fun uploadBodyStreamsExactLimitBinaryData() {
|
|
87
|
+
val file = tempFolder.newFile("exact.zip")
|
|
88
|
+
val expected = ByteArray(SniConnectValidation.MAX_UPLOAD_FILE_BYTES.toInt()) { 0xFF.toByte() }
|
|
89
|
+
file.writeBytes(expected)
|
|
90
|
+
val body = SniUploadFileRequestBody(file, null)
|
|
91
|
+
val sink = Buffer()
|
|
92
|
+
body.writeTo(sink)
|
|
93
|
+
assertEquals(expected.size.toLong(), body.contentLength())
|
|
94
|
+
assertTrue(expected.contentEquals(sink.readByteArray()))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@Test
|
|
98
|
+
fun uploadBodyRejectsGrowthAfterValidation() {
|
|
99
|
+
val root = tempFolder.newFolder("sandbox")
|
|
100
|
+
val file = File(root, "growing.zip").apply { writeBytes(byteArrayOf(1)) }
|
|
101
|
+
val validated = SniConnectValidation.resolveUploadFile(file.path, listOf(root))
|
|
102
|
+
val body = SniUploadFileRequestBody(validated, null)
|
|
103
|
+
RandomAccessFile(file, "rw").use { it.setLength(SniConnectValidation.MAX_UPLOAD_FILE_BYTES + 1) }
|
|
104
|
+
val sink = Buffer()
|
|
105
|
+
assertThrows(IOException::class.java) { body.writeTo(sink) }
|
|
106
|
+
assertTrue(sink.size <= body.contentLength())
|
|
107
|
+
assertThrows(IOException::class.java) { SniUploadFileRequestBody(validated, null) }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
@Test
|
|
111
|
+
fun uploadBodyRejectsGrowthDuringTransmission() {
|
|
112
|
+
val file = tempFolder.newFile("growing-during-write.zip")
|
|
113
|
+
file.writeBytes(ByteArray(16 * 1024) { 1 })
|
|
114
|
+
val body = SniUploadFileRequestBody(file, null)
|
|
115
|
+
val output = Buffer()
|
|
116
|
+
var appended = false
|
|
117
|
+
val sink = object : ForwardingSink(output) {
|
|
118
|
+
override fun write(source: Buffer, byteCount: Long) {
|
|
119
|
+
super.write(source, byteCount)
|
|
120
|
+
if (!appended) {
|
|
121
|
+
file.appendBytes(byteArrayOf(2))
|
|
122
|
+
appended = true
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}.buffer()
|
|
126
|
+
assertThrows(IOException::class.java) { body.writeTo(sink) }
|
|
127
|
+
assertTrue(appended)
|
|
128
|
+
assertTrue(output.size <= body.contentLength())
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
@Test
|
|
132
|
+
fun uploadBodyRejectsTruncationAfterValidation() {
|
|
133
|
+
val file = tempFolder.newFile("truncated.zip").apply { writeBytes(byteArrayOf(1, 2)) }
|
|
134
|
+
val body = SniUploadFileRequestBody(file, null)
|
|
135
|
+
file.writeBytes(byteArrayOf(1))
|
|
136
|
+
assertThrows(IOException::class.java) { body.writeTo(Buffer()) }
|
|
137
|
+
file.writeBytes(byteArrayOf())
|
|
138
|
+
assertThrows(IOException::class.java) { body.writeTo(Buffer()) }
|
|
139
|
+
}
|
|
140
|
+
|
|
17
141
|
@Test
|
|
18
142
|
fun acceptsValidRequestBoundaryValues() {
|
|
19
143
|
SniConnectValidation.validateRequestId("req-1")
|
package/ios/SniConnect.mm
CHANGED
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
- (void)request:(NSDictionary *)config
|
|
14
14
|
resolve:(RCTPromiseResolveBlock)resolve
|
|
15
15
|
reject:(RCTPromiseRejectBlock)reject;
|
|
16
|
+
- (void)uploadFile:(NSDictionary *)config
|
|
17
|
+
resolve:(RCTPromiseResolveBlock)resolve
|
|
18
|
+
reject:(RCTPromiseRejectBlock)reject;
|
|
16
19
|
- (void)cancelRequest:(NSString *)requestId
|
|
17
20
|
resolve:(RCTPromiseResolveBlock)resolve
|
|
18
21
|
reject:(RCTPromiseRejectBlock)reject;
|
|
@@ -82,6 +85,24 @@ RCT_EXPORT_MODULE(SniConnect)
|
|
|
82
85
|
[_implementation request:configDict resolve:resolve reject:reject];
|
|
83
86
|
}
|
|
84
87
|
|
|
88
|
+
- (void)uploadFile:(JS::NativeSniConnect::NativeSniConnectUploadFileRequest &)config
|
|
89
|
+
resolve:(RCTPromiseResolveBlock)resolve
|
|
90
|
+
reject:(RCTPromiseRejectBlock)reject {
|
|
91
|
+
NSMutableDictionary *configDict = [NSMutableDictionary dictionary];
|
|
92
|
+
configDict[@"ip"] = config.ip() ?: @"";
|
|
93
|
+
configDict[@"hostname"] = config.hostname() ?: @"";
|
|
94
|
+
configDict[@"method"] = config.method() ?: @"POST";
|
|
95
|
+
configDict[@"path"] = config.path() ?: @"/";
|
|
96
|
+
configDict[@"headers"] = config.headers() ?: @{};
|
|
97
|
+
configDict[@"timeout"] = @(config.timeout());
|
|
98
|
+
configDict[@"filePath"] = config.filePath() ?: @"";
|
|
99
|
+
if (config.requestId()) {
|
|
100
|
+
configDict[@"requestId"] = config.requestId();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
[_implementation uploadFile:configDict resolve:resolve reject:reject];
|
|
104
|
+
}
|
|
105
|
+
|
|
85
106
|
- (void)cancelRequest:(NSString *)requestId
|
|
86
107
|
resolve:(RCTPromiseResolveBlock)resolve
|
|
87
108
|
reject:(RCTPromiseRejectBlock)reject {
|
|
@@ -127,6 +148,12 @@ RCT_EXPORT_METHOD(request:(NSDictionary *)config
|
|
|
127
148
|
[_implementation request:config resolve:resolve reject:reject];
|
|
128
149
|
}
|
|
129
150
|
|
|
151
|
+
RCT_EXPORT_METHOD(uploadFile:(NSDictionary *)config
|
|
152
|
+
resolver:(RCTPromiseResolveBlock)resolve
|
|
153
|
+
rejecter:(RCTPromiseRejectBlock)reject) {
|
|
154
|
+
[_implementation uploadFile:config resolve:resolve reject:reject];
|
|
155
|
+
}
|
|
156
|
+
|
|
130
157
|
RCT_EXPORT_METHOD(cancelRequest:(NSString *)requestId
|
|
131
158
|
resolver:(RCTPromiseResolveBlock)resolve
|
|
132
159
|
rejecter:(RCTPromiseRejectBlock)reject) {
|
package/ios/SniConnect.swift
CHANGED
|
@@ -41,6 +41,26 @@ final class SniConnectImpl: NSObject {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
@objc
|
|
45
|
+
public func uploadFile(
|
|
46
|
+
_ config: NSDictionary,
|
|
47
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
48
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
49
|
+
) {
|
|
50
|
+
do {
|
|
51
|
+
let parsed = try Self.parseUploadFileDictionary(config)
|
|
52
|
+
handleRequest(config: parsed, resolve: resolve, reject: reject)
|
|
53
|
+
} catch {
|
|
54
|
+
SniConnectLog.error(SniConnectLog.event("sni_request_result", [
|
|
55
|
+
("result", "error"),
|
|
56
|
+
("code", "SNI_INVALID_CONFIG"),
|
|
57
|
+
("nativeErrorClass", String(describing: type(of: error))),
|
|
58
|
+
("stage", "parse_upload_config"),
|
|
59
|
+
]))
|
|
60
|
+
reject("SNI_INVALID_CONFIG", "\(error)", error)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
44
64
|
private func handleRequest(
|
|
45
65
|
config: SniConnectClient.RequestConfig,
|
|
46
66
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
@@ -213,6 +233,45 @@ final class SniConnectImpl: NSObject {
|
|
|
213
233
|
)
|
|
214
234
|
}
|
|
215
235
|
|
|
236
|
+
/// Uploads may only read files from this app's own container.
|
|
237
|
+
private static var uploadFileAllowedRoots: [URL] {
|
|
238
|
+
[
|
|
239
|
+
URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true),
|
|
240
|
+
URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true),
|
|
241
|
+
]
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private static func parseUploadFileDictionary(
|
|
245
|
+
_ dictionary: NSDictionary
|
|
246
|
+
) throws -> SniConnectClient.RequestConfig {
|
|
247
|
+
guard dictionary["body"] == nil || dictionary["body"] is NSNull else {
|
|
248
|
+
throw SniConnectError.invalidConfig("uploadFile does not accept body")
|
|
249
|
+
}
|
|
250
|
+
guard let filePath = dictionary["filePath"] as? String, !filePath.isEmpty else {
|
|
251
|
+
throw SniConnectError.invalidConfig("Missing filePath")
|
|
252
|
+
}
|
|
253
|
+
let base = try parseDictionary(dictionary)
|
|
254
|
+
let method = try SniConnectValidation.normalizeMethod(base.method)
|
|
255
|
+
try SniConnectValidation.validateUploadMethod(method)
|
|
256
|
+
let resolvedFile = try SniConnectValidation.resolveUploadFile(
|
|
257
|
+
filePath,
|
|
258
|
+
allowedRoots: uploadFileAllowedRoots
|
|
259
|
+
)
|
|
260
|
+
return SniConnectClient.RequestConfig(
|
|
261
|
+
requestId: base.requestId,
|
|
262
|
+
ip: base.ip,
|
|
263
|
+
hostname: base.hostname,
|
|
264
|
+
method: method,
|
|
265
|
+
path: base.path,
|
|
266
|
+
headers: base.headers,
|
|
267
|
+
body: nil,
|
|
268
|
+
timeout: base.timeout,
|
|
269
|
+
bodyFileURL: resolvedFile.url,
|
|
270
|
+
connectTimeout: base.connectTimeout,
|
|
271
|
+
totalTimeout: base.totalTimeout
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
|
|
216
275
|
private static func validate(_ config: SniConnectClient.RequestConfig) throws {
|
|
217
276
|
try SniConnectValidation.validatePublicIP(config.ip)
|
|
218
277
|
try SniConnectValidation.validateHostname(config.hostname)
|
|
@@ -221,6 +280,10 @@ final class SniConnectImpl: NSObject {
|
|
|
221
280
|
_ = try SniConnectValidation.normalizePath(config.path)
|
|
222
281
|
try SniConnectValidation.validateRequestId(config.requestId)
|
|
223
282
|
try SniConnectValidation.validateTimeout(config.effectiveTotalTimeout)
|
|
283
|
+
if config.bodyFileURL != nil {
|
|
284
|
+
try SniConnectValidation.validateUploadMethod(method)
|
|
285
|
+
return
|
|
286
|
+
}
|
|
224
287
|
try SniConnectValidation.validateBody(config.body)
|
|
225
288
|
try SniConnectValidation.validateMethodBody(method: method, body: config.body)
|
|
226
289
|
}
|
|
@@ -259,6 +259,8 @@ final class SniConnectClient {
|
|
|
259
259
|
let headers: [String: String]
|
|
260
260
|
let body: String?
|
|
261
261
|
let timeout: TimeInterval
|
|
262
|
+
// Set only by uploadFile(); already resolved inside the app sandbox.
|
|
263
|
+
var bodyFileURL: URL? = nil
|
|
262
264
|
|
|
263
265
|
// Advanced timeout configurations
|
|
264
266
|
let connectTimeout: TimeInterval? // Connection establishment timeout
|
|
@@ -678,8 +680,15 @@ final class SniConnectClient {
|
|
|
678
680
|
method = try SniConnectValidation.normalizeMethod(config.method)
|
|
679
681
|
normalizedPath = try SniConnectValidation.normalizePath(config.path)
|
|
680
682
|
try SniConnectValidation.validateTimeout(config.effectiveTotalTimeout)
|
|
681
|
-
|
|
682
|
-
|
|
683
|
+
if config.bodyFileURL != nil {
|
|
684
|
+
try SniConnectValidation.validateUploadMethod(method)
|
|
685
|
+
if config.body != nil {
|
|
686
|
+
throw SniConnectValidation.ValidationError.invalidBody
|
|
687
|
+
}
|
|
688
|
+
} else {
|
|
689
|
+
try SniConnectValidation.validateBody(config.body)
|
|
690
|
+
try SniConnectValidation.validateMethodBody(method: method, body: config.body)
|
|
691
|
+
}
|
|
683
692
|
} catch {
|
|
684
693
|
let sniError = SniConnectError.invalidConfig("\(error)")
|
|
685
694
|
SniConnectLog.error(SniConnectLog.event("sni_request_result", [
|
|
@@ -763,8 +772,20 @@ final class SniConnectClient {
|
|
|
763
772
|
mutableRequest.setValue(config.hostname, forHTTPHeaderField: "Host")
|
|
764
773
|
mutableRequest.setValue("identity", forHTTPHeaderField: "Accept-Encoding")
|
|
765
774
|
|
|
766
|
-
|
|
775
|
+
var bodyBytes = 0
|
|
776
|
+
if let bodyFileURL = config.bodyFileURL {
|
|
777
|
+
let fileData: Data
|
|
778
|
+
do {
|
|
779
|
+
// Bound consumption even if the file grew while waiting for admission.
|
|
780
|
+
fileData = try SniConnectValidation.readUploadFile(bodyFileURL)
|
|
781
|
+
} catch {
|
|
782
|
+
throw SniConnectError.invalidConfig("Upload file is not readable")
|
|
783
|
+
}
|
|
784
|
+
mutableRequest.httpBody = fileData
|
|
785
|
+
bodyBytes = fileData.count
|
|
786
|
+
} else if let bodyString = config.body, let bodyData = bodyString.data(using: .utf8) {
|
|
767
787
|
mutableRequest.httpBody = bodyData
|
|
788
|
+
bodyBytes = bodyData.count
|
|
768
789
|
}
|
|
769
790
|
|
|
770
791
|
// Per-request connect timeout (avoids the process-global setter race).
|
|
@@ -781,7 +802,8 @@ final class SniConnectClient {
|
|
|
781
802
|
("connectTimeoutMs", Int(config.effectiveConnectTimeout)),
|
|
782
803
|
("queueWaitMs", queueWaitMilliseconds),
|
|
783
804
|
("headerCount", normalizedHeaders.count),
|
|
784
|
-
("bodyBytes",
|
|
805
|
+
("bodyBytes", bodyBytes),
|
|
806
|
+
("bodySource", config.bodyFileURL != nil ? "file" : "string"),
|
|
785
807
|
]))
|
|
786
808
|
|
|
787
809
|
do {
|
|
@@ -817,6 +839,7 @@ final class SniConnectClient {
|
|
|
817
839
|
("ipFamily", SniConnectLog.ipFamily(config.ip)),
|
|
818
840
|
("method", method),
|
|
819
841
|
("timeoutMs", Int(config.effectiveTotalTimeout)),
|
|
842
|
+
("bodyBytes", bodyBytes),
|
|
820
843
|
("responseBytes", data.count),
|
|
821
844
|
("elapsedMs", SniConnectLog.elapsedMs(since: startedAt)),
|
|
822
845
|
])
|
|
@@ -18,6 +18,7 @@ enum SniConnectValidation {
|
|
|
18
18
|
case invalidRequestId(String)
|
|
19
19
|
case invalidTimeout(Double)
|
|
20
20
|
case invalidBody
|
|
21
|
+
case invalidUploadFile(String)
|
|
21
22
|
case resourceLimit(String)
|
|
22
23
|
}
|
|
23
24
|
|
|
@@ -26,6 +27,7 @@ enum SniConnectValidation {
|
|
|
26
27
|
static let maxPathBytes = 8 * 1024
|
|
27
28
|
static let maxRequestBodyBytes = 1024 * 1024
|
|
28
29
|
static let maxResponseBodyBytes = 10 * 1024 * 1024
|
|
30
|
+
static let maxUploadFileBytes = 20 * 1024 * 1024
|
|
29
31
|
static let maxHeaderCount = 64
|
|
30
32
|
static let maxHeaderNameBytes = 128
|
|
31
33
|
static let maxHeaderValueBytes = 8 * 1024
|
|
@@ -88,6 +90,75 @@ enum SniConnectValidation {
|
|
|
88
90
|
}
|
|
89
91
|
}
|
|
90
92
|
|
|
93
|
+
/// File uploads always carry a body, so only body-bearing write methods apply.
|
|
94
|
+
static func validateUploadMethod(_ method: String) throws {
|
|
95
|
+
if method != "POST" && method != "PUT" {
|
|
96
|
+
throw ValidationError.invalidMethod(method)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/// Resolve a caller-supplied upload path to a regular file inside one of
|
|
101
|
+
/// `allowedRoots` (the app's own sandbox). Symlinks are resolved before the
|
|
102
|
+
/// containment check so a link cannot point the upload at another file.
|
|
103
|
+
static func resolveUploadFile(
|
|
104
|
+
_ rawPath: String,
|
|
105
|
+
allowedRoots: [URL]
|
|
106
|
+
) throws -> (url: URL, sizeBytes: Int) {
|
|
107
|
+
if rawPath.isEmpty || containsControlCharacters(rawPath) {
|
|
108
|
+
throw ValidationError.invalidUploadFile("Invalid file path")
|
|
109
|
+
}
|
|
110
|
+
let fileURL: URL
|
|
111
|
+
if rawPath.hasPrefix("file:") {
|
|
112
|
+
guard let parsed = URL(string: rawPath), parsed.isFileURL else {
|
|
113
|
+
throw ValidationError.invalidUploadFile("Invalid file URL")
|
|
114
|
+
}
|
|
115
|
+
fileURL = parsed
|
|
116
|
+
} else if rawPath.hasPrefix("/") {
|
|
117
|
+
fileURL = URL(fileURLWithPath: rawPath)
|
|
118
|
+
} else {
|
|
119
|
+
throw ValidationError.invalidUploadFile("File path must be absolute")
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let resolvedPath = fileURL.standardizedFileURL.resolvingSymlinksInPath().path
|
|
123
|
+
let isInsideAllowedRoot = allowedRoots.contains { root in
|
|
124
|
+
let rootPath = root.standardizedFileURL.resolvingSymlinksInPath().path
|
|
125
|
+
let prefix = rootPath.hasSuffix("/") ? rootPath : rootPath + "/"
|
|
126
|
+
return resolvedPath.hasPrefix(prefix)
|
|
127
|
+
}
|
|
128
|
+
if !isInsideAllowedRoot {
|
|
129
|
+
throw ValidationError.invalidUploadFile("File is outside the app sandbox")
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let attributes: [FileAttributeKey: Any]
|
|
133
|
+
do {
|
|
134
|
+
attributes = try FileManager.default.attributesOfItem(atPath: resolvedPath)
|
|
135
|
+
} catch {
|
|
136
|
+
throw ValidationError.invalidUploadFile("File is not readable")
|
|
137
|
+
}
|
|
138
|
+
guard (attributes[.type] as? FileAttributeType) == .typeRegular else {
|
|
139
|
+
throw ValidationError.invalidUploadFile("Not a regular file")
|
|
140
|
+
}
|
|
141
|
+
let sizeBytes = (attributes[.size] as? NSNumber)?.intValue ?? 0
|
|
142
|
+
if sizeBytes <= 0 {
|
|
143
|
+
throw ValidationError.invalidUploadFile("File is empty")
|
|
144
|
+
}
|
|
145
|
+
if sizeBytes > maxUploadFileBytes {
|
|
146
|
+
throw ValidationError.invalidUploadFile("File is too large")
|
|
147
|
+
}
|
|
148
|
+
return (URL(fileURLWithPath: resolvedPath), sizeBytes)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/// Bound the actual read as well as the earlier path/metadata validation.
|
|
152
|
+
static func readUploadFile(_ url: URL) throws -> Data {
|
|
153
|
+
let handle = try FileHandle(forReadingFrom: url)
|
|
154
|
+
defer { try? handle.close() }
|
|
155
|
+
let data = try handle.read(upToCount: maxUploadFileBytes + 1) ?? Data()
|
|
156
|
+
guard !data.isEmpty, data.count <= maxUploadFileBytes else {
|
|
157
|
+
throw ValidationError.invalidUploadFile("File size is out of range")
|
|
158
|
+
}
|
|
159
|
+
return data
|
|
160
|
+
}
|
|
161
|
+
|
|
91
162
|
/// Validate and uppercase the HTTP method.
|
|
92
163
|
static func normalizeMethod(_ method: String) throws -> String {
|
|
93
164
|
if containsControlCharacters(method) {
|
|
@@ -550,6 +550,119 @@ final class SniConnectValidationTests: XCTestCase {
|
|
|
550
550
|
])
|
|
551
551
|
}
|
|
552
552
|
|
|
553
|
+
func testUploadMethodAllowsOnlyPostAndPut() {
|
|
554
|
+
XCTAssertNoThrow(try SniConnectValidation.validateUploadMethod("POST"))
|
|
555
|
+
XCTAssertNoThrow(try SniConnectValidation.validateUploadMethod("PUT"))
|
|
556
|
+
["GET", "HEAD", "PATCH", "DELETE", "OPTIONS"].forEach { method in
|
|
557
|
+
assertValidationFails {
|
|
558
|
+
try SniConnectValidation.validateUploadMethod(method)
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
func testResolveUploadFileAcceptsSandboxFileByPathAndFileURL() throws {
|
|
564
|
+
let root = try makeUploadRoot()
|
|
565
|
+
defer { try? FileManager.default.removeItem(at: root) }
|
|
566
|
+
let file = root.appendingPathComponent("logs.zip")
|
|
567
|
+
try Data([0x50, 0x4B, 0x03, 0x04, 0xFF]).write(to: file)
|
|
568
|
+
|
|
569
|
+
let byPath = try SniConnectValidation.resolveUploadFile(file.path, allowedRoots: [root])
|
|
570
|
+
XCTAssertEqual(byPath.sizeBytes, 5)
|
|
571
|
+
XCTAssertEqual(
|
|
572
|
+
byPath.url.path,
|
|
573
|
+
file.standardizedFileURL.resolvingSymlinksInPath().path
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
let byURL = try SniConnectValidation.resolveUploadFile(
|
|
577
|
+
file.absoluteString,
|
|
578
|
+
allowedRoots: [root]
|
|
579
|
+
)
|
|
580
|
+
XCTAssertEqual(byURL.url.path, byPath.url.path)
|
|
581
|
+
|
|
582
|
+
let bySingleSlashURL = try SniConnectValidation.resolveUploadFile(
|
|
583
|
+
"file:" + file.path,
|
|
584
|
+
allowedRoots: [root]
|
|
585
|
+
)
|
|
586
|
+
XCTAssertEqual(bySingleSlashURL.url.path, byPath.url.path)
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
func testResolveUploadFileRejectsInvalidOrOutOfSandboxFiles() throws {
|
|
590
|
+
let root = try makeUploadRoot()
|
|
591
|
+
let outside = try makeUploadRoot()
|
|
592
|
+
defer {
|
|
593
|
+
try? FileManager.default.removeItem(at: root)
|
|
594
|
+
try? FileManager.default.removeItem(at: outside)
|
|
595
|
+
}
|
|
596
|
+
let outsideFile = outside.appendingPathComponent("secret.bin")
|
|
597
|
+
try Data([1, 2, 3]).write(to: outsideFile)
|
|
598
|
+
let emptyFile = root.appendingPathComponent("empty.zip")
|
|
599
|
+
try Data().write(to: emptyFile)
|
|
600
|
+
let link = root.appendingPathComponent("link.zip")
|
|
601
|
+
try FileManager.default.createSymbolicLink(at: link, withDestinationURL: outsideFile)
|
|
602
|
+
|
|
603
|
+
[
|
|
604
|
+
"",
|
|
605
|
+
"relative/logs.zip",
|
|
606
|
+
"https://example.com/logs.zip",
|
|
607
|
+
outsideFile.path,
|
|
608
|
+
root.appendingPathComponent("../\(outside.lastPathComponent)/secret.bin").path,
|
|
609
|
+
link.path,
|
|
610
|
+
emptyFile.path,
|
|
611
|
+
root.path,
|
|
612
|
+
root.appendingPathComponent("missing.zip").path,
|
|
613
|
+
root.path + "/logs\n.zip",
|
|
614
|
+
].forEach { path in
|
|
615
|
+
assertValidationFails {
|
|
616
|
+
_ = try SniConnectValidation.resolveUploadFile(path, allowedRoots: [root])
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
func testResolveUploadFileRejectsFilesOverTheSizeLimit() throws {
|
|
622
|
+
let root = try makeUploadRoot()
|
|
623
|
+
defer { try? FileManager.default.removeItem(at: root) }
|
|
624
|
+
let file = root.appendingPathComponent("big.zip")
|
|
625
|
+
FileManager.default.createFile(atPath: file.path, contents: nil)
|
|
626
|
+
let handle = try FileHandle(forWritingTo: file)
|
|
627
|
+
try handle.truncate(atOffset: UInt64(SniConnectValidation.maxUploadFileBytes + 1))
|
|
628
|
+
try handle.close()
|
|
629
|
+
|
|
630
|
+
assertValidationFails {
|
|
631
|
+
_ = try SniConnectValidation.resolveUploadFile(file.path, allowedRoots: [root])
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
func testReadUploadFileAcceptsExactLimitBinaryData() throws {
|
|
636
|
+
let root = try makeUploadRoot()
|
|
637
|
+
defer { try? FileManager.default.removeItem(at: root) }
|
|
638
|
+
let file = root.appendingPathComponent("exact.zip")
|
|
639
|
+
let expected = Data(repeating: 0xFF, count: SniConnectValidation.maxUploadFileBytes)
|
|
640
|
+
try expected.write(to: file)
|
|
641
|
+
XCTAssertEqual(try SniConnectValidation.readUploadFile(file), expected)
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
func testReadUploadFileRejectsGrowthAndTruncationAfterValidation() throws {
|
|
645
|
+
let root = try makeUploadRoot()
|
|
646
|
+
defer { try? FileManager.default.removeItem(at: root) }
|
|
647
|
+
let file = root.appendingPathComponent("changing.zip")
|
|
648
|
+
try Data([1]).write(to: file)
|
|
649
|
+
let validated = try SniConnectValidation.resolveUploadFile(file.path, allowedRoots: [root])
|
|
650
|
+
let handle = try FileHandle(forWritingTo: file)
|
|
651
|
+
defer { try? handle.close() }
|
|
652
|
+
// A sparse file exercises the consumption bound without a large fixture.
|
|
653
|
+
try handle.truncate(atOffset: UInt64(SniConnectValidation.maxUploadFileBytes * 4))
|
|
654
|
+
XCTAssertThrowsError(try SniConnectValidation.readUploadFile(validated.url))
|
|
655
|
+
try handle.truncate(atOffset: 0)
|
|
656
|
+
XCTAssertThrowsError(try SniConnectValidation.readUploadFile(validated.url))
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
private func makeUploadRoot() throws -> URL {
|
|
660
|
+
let root = FileManager.default.temporaryDirectory
|
|
661
|
+
.appendingPathComponent("sni-upload-\(UUID().uuidString)", isDirectory: true)
|
|
662
|
+
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
|
663
|
+
return root
|
|
664
|
+
}
|
|
665
|
+
|
|
553
666
|
private func assertValidationFails(_ block: () throws -> Void, file: StaticString = #filePath, line: UInt = #line) {
|
|
554
667
|
XCTAssertThrowsError(try block(), file: file, line: line)
|
|
555
668
|
}
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
import { NativeModules, Platform, TurboModuleRegistry } from 'react-native';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Sends the raw bytes of an app-sandbox file as the request body over the same
|
|
7
|
+
* pinned-IP/SNI transport as `request()`. Set `content-type` in `headers`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
4
10
|
const LINKING_ERROR = `The package '@onekeyfe/react-native-sni-connect' doesn't seem to be linked. Make sure:\n\n` + Platform.select({
|
|
5
11
|
ios: "- You have run 'pod install'\n",
|
|
6
12
|
default: ''
|
package/lib/module/index.js
CHANGED
|
@@ -4,6 +4,17 @@ import NativeSniConnect from "./NativeSniConnect.js";
|
|
|
4
4
|
export function request(config) {
|
|
5
5
|
return NativeSniConnect.request(config);
|
|
6
6
|
}
|
|
7
|
+
export function isUploadFileSupported() {
|
|
8
|
+
return typeof NativeSniConnect.uploadFile === 'function';
|
|
9
|
+
}
|
|
10
|
+
export async function uploadFile(config) {
|
|
11
|
+
if (!isUploadFileSupported()) {
|
|
12
|
+
throw Object.assign(new Error('Native SNI file upload is not supported'), {
|
|
13
|
+
code: 'SNI_UNSUPPORTED'
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
return NativeSniConnect.uploadFile(config);
|
|
17
|
+
}
|
|
7
18
|
export function cancelRequest(requestId) {
|
|
8
19
|
return NativeSniConnect.cancelRequest(requestId);
|
|
9
20
|
}
|
|
@@ -18,6 +18,20 @@ type NativeSniConnectRequest = SniConnectRequestBase & {
|
|
|
18
18
|
method: string;
|
|
19
19
|
body?: string | null;
|
|
20
20
|
};
|
|
21
|
+
type NativeSniConnectUploadFileRequest = SniConnectRequestBase & {
|
|
22
|
+
method: string;
|
|
23
|
+
filePath: string;
|
|
24
|
+
};
|
|
25
|
+
export type SniConnectUploadFileMethod = 'POST' | 'PUT';
|
|
26
|
+
/**
|
|
27
|
+
* Sends the raw bytes of an app-sandbox file as the request body over the same
|
|
28
|
+
* pinned-IP/SNI transport as `request()`. Set `content-type` in `headers`.
|
|
29
|
+
*/
|
|
30
|
+
export type SniConnectUploadFileRequest = SniConnectRequestBase & {
|
|
31
|
+
method: SniConnectUploadFileMethod;
|
|
32
|
+
/** Absolute path or `file://` URL inside the app sandbox, at most 20 MiB. */
|
|
33
|
+
filePath: string;
|
|
34
|
+
};
|
|
21
35
|
export type SniConnectBodylessMethod = 'GET' | 'HEAD';
|
|
22
36
|
export type SniConnectRequiredBodyMethod = 'POST' | 'PUT' | 'PATCH';
|
|
23
37
|
export type SniConnectOptionalBodyMethod = 'DELETE' | 'OPTIONS';
|
|
@@ -53,6 +67,7 @@ export type SniConnectDebugSnapshot = {
|
|
|
53
67
|
};
|
|
54
68
|
export interface Spec extends TurboModule {
|
|
55
69
|
request(config: NativeSniConnectRequest): Promise<SniConnectResponse>;
|
|
70
|
+
uploadFile(config: NativeSniConnectUploadFileRequest): Promise<SniConnectResponse>;
|
|
56
71
|
cancelRequest(requestId: string): Promise<{
|
|
57
72
|
success: boolean;
|
|
58
73
|
}>;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { type SniConnectDebugSnapshot, type SniConnectDebugTarget, type SniConnectRequest, type SniConnectResponse } from './NativeSniConnect';
|
|
1
|
+
import { type SniConnectDebugSnapshot, type SniConnectDebugTarget, type SniConnectRequest, type SniConnectResponse, type SniConnectUploadFileRequest } from './NativeSniConnect';
|
|
2
2
|
export declare function request(config: SniConnectRequest): Promise<SniConnectResponse>;
|
|
3
|
+
export declare function isUploadFileSupported(): boolean;
|
|
4
|
+
export declare function uploadFile(config: SniConnectUploadFileRequest): Promise<SniConnectResponse>;
|
|
3
5
|
export declare function cancelRequest(requestId: string): Promise<{
|
|
4
6
|
success: boolean;
|
|
5
7
|
}>;
|
|
@@ -11,5 +13,5 @@ export declare function clearDNSCache(): Promise<{
|
|
|
11
13
|
}>;
|
|
12
14
|
export declare function getDebugSnapshot(target: SniConnectDebugTarget): Promise<SniConnectDebugSnapshot>;
|
|
13
15
|
export declare function isProxyActiveForUrl(url: string): Promise<boolean>;
|
|
14
|
-
export type { SniConnectBodylessMethod, SniConnectDebugSnapshot, SniConnectDebugTarget, SniConnectMethod, SniConnectOptionalBodyMethod, SniConnectRequest, SniConnectRequiredBodyMethod, SniConnectResponse, } from './NativeSniConnect';
|
|
16
|
+
export type { SniConnectBodylessMethod, SniConnectDebugSnapshot, SniConnectDebugTarget, SniConnectMethod, SniConnectOptionalBodyMethod, SniConnectRequest, SniConnectRequiredBodyMethod, SniConnectResponse, SniConnectUploadFileMethod, SniConnectUploadFileRequest, } from './NativeSniConnect';
|
|
15
17
|
//# sourceMappingURL=index.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onekeyfe/react-native-sni-connect",
|
|
3
|
-
"version": "6.7.0-alpha.
|
|
3
|
+
"version": "6.7.0-alpha.364",
|
|
4
4
|
"description": "A React Native library for SNI-based HTTP requests with DNS caching and request management",
|
|
5
5
|
"main": "./lib/module/index.js",
|
|
6
6
|
"types": "./lib/typescript/src/index.d.ts",
|
package/src/NativeSniConnect.ts
CHANGED
|
@@ -28,6 +28,23 @@ type NativeSniConnectRequest = SniConnectRequestBase & {
|
|
|
28
28
|
body?: string | null;
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
+
type NativeSniConnectUploadFileRequest = SniConnectRequestBase & {
|
|
32
|
+
method: string;
|
|
33
|
+
filePath: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type SniConnectUploadFileMethod = 'POST' | 'PUT';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Sends the raw bytes of an app-sandbox file as the request body over the same
|
|
40
|
+
* pinned-IP/SNI transport as `request()`. Set `content-type` in `headers`.
|
|
41
|
+
*/
|
|
42
|
+
export type SniConnectUploadFileRequest = SniConnectRequestBase & {
|
|
43
|
+
method: SniConnectUploadFileMethod;
|
|
44
|
+
/** Absolute path or `file://` URL inside the app sandbox, at most 20 MiB. */
|
|
45
|
+
filePath: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
31
48
|
export type SniConnectBodylessMethod = 'GET' | 'HEAD';
|
|
32
49
|
export type SniConnectRequiredBodyMethod = 'POST' | 'PUT' | 'PATCH';
|
|
33
50
|
export type SniConnectOptionalBodyMethod = 'DELETE' | 'OPTIONS';
|
|
@@ -74,6 +91,9 @@ export type SniConnectDebugSnapshot = {
|
|
|
74
91
|
|
|
75
92
|
export interface Spec extends TurboModule {
|
|
76
93
|
request(config: NativeSniConnectRequest): Promise<SniConnectResponse>;
|
|
94
|
+
uploadFile(
|
|
95
|
+
config: NativeSniConnectUploadFileRequest
|
|
96
|
+
): Promise<SniConnectResponse>;
|
|
77
97
|
cancelRequest(requestId: string): Promise<{ success: boolean }>;
|
|
78
98
|
cancelAllRequests(): Promise<{ success: boolean }>;
|
|
79
99
|
clearDNSCache(): Promise<{ success: boolean }>;
|
package/src/index.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import NativeSniConnect, {
|
|
|
3
3
|
type SniConnectDebugTarget,
|
|
4
4
|
type SniConnectRequest,
|
|
5
5
|
type SniConnectResponse,
|
|
6
|
+
type SniConnectUploadFileRequest,
|
|
6
7
|
} from './NativeSniConnect';
|
|
7
8
|
|
|
8
9
|
export function request(
|
|
@@ -11,6 +12,21 @@ export function request(
|
|
|
11
12
|
return NativeSniConnect.request(config);
|
|
12
13
|
}
|
|
13
14
|
|
|
15
|
+
export function isUploadFileSupported(): boolean {
|
|
16
|
+
return typeof NativeSniConnect.uploadFile === 'function';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function uploadFile(
|
|
20
|
+
config: SniConnectUploadFileRequest
|
|
21
|
+
): Promise<SniConnectResponse> {
|
|
22
|
+
if (!isUploadFileSupported()) {
|
|
23
|
+
throw Object.assign(new Error('Native SNI file upload is not supported'), {
|
|
24
|
+
code: 'SNI_UNSUPPORTED',
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
return NativeSniConnect.uploadFile(config);
|
|
28
|
+
}
|
|
29
|
+
|
|
14
30
|
export function cancelRequest(
|
|
15
31
|
requestId: string
|
|
16
32
|
): Promise<{ success: boolean }> {
|
|
@@ -44,4 +60,6 @@ export type {
|
|
|
44
60
|
SniConnectRequest,
|
|
45
61
|
SniConnectRequiredBodyMethod,
|
|
46
62
|
SniConnectResponse,
|
|
63
|
+
SniConnectUploadFileMethod,
|
|
64
|
+
SniConnectUploadFileRequest,
|
|
47
65
|
} from './NativeSniConnect';
|