@umutcansu/react-native-pinvault 0.0.0-stage → 2.4.0

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/LICENSE +21 -0
  3. package/README.md +437 -2
  4. package/RNPinVault.podspec +43 -0
  5. package/android/build.gradle +68 -0
  6. package/android/consumer-rules.pro +25 -0
  7. package/android/src/main/AndroidManifest.xml +16 -0
  8. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/ConfigParser.kt +396 -0
  9. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/JsEnvironmentGuard.kt +53 -0
  10. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/NativeSecurity.kt +354 -0
  11. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultModule.kt +419 -0
  12. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultNetworking.kt +379 -0
  13. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultPackage.kt +25 -0
  14. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinnedFetch.kt +131 -0
  15. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/ResultMapper.kt +188 -0
  16. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/StrictJson.kt +225 -0
  17. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/VaultTokenStore.kt +63 -0
  18. package/ios/Core/ConfigParser.swift +437 -0
  19. package/ios/Core/JSEnvironmentGuard.swift +129 -0
  20. package/ios/Core/NativeSecurity.swift +303 -0
  21. package/ios/Core/PinnedFetch.swift +119 -0
  22. package/ios/Core/ReactNetworking.swift +172 -0
  23. package/ios/Core/ResultMapper.swift +163 -0
  24. package/ios/Core/StrictJSON.swift +214 -0
  25. package/ios/Core/VaultTokenStore.swift +66 -0
  26. package/ios/PinVaultBridge.swift +424 -0
  27. package/ios/PinVaultReactNetworking.swift +47 -0
  28. package/ios/RNPinVault.h +7 -0
  29. package/ios/RNPinVault.mm +293 -0
  30. package/ios/RNPinVaultURLRequestHandler.mm +92 -0
  31. package/lib/module/NativePinVault.js +13 -0
  32. package/lib/module/NativePinVault.js.map +1 -0
  33. package/lib/module/index.js +331 -0
  34. package/lib/module/index.js.map +1 -0
  35. package/lib/module/package.json +1 -0
  36. package/lib/module/types.js +2 -0
  37. package/lib/module/types.js.map +1 -0
  38. package/lib/typescript/package.json +1 -0
  39. package/lib/typescript/src/NativePinVault.d.ts +50 -0
  40. package/lib/typescript/src/NativePinVault.d.ts.map +1 -0
  41. package/lib/typescript/src/index.d.ts +150 -0
  42. package/lib/typescript/src/index.d.ts.map +1 -0
  43. package/lib/typescript/src/types.d.ts +359 -0
  44. package/lib/typescript/src/types.d.ts.map +1 -0
  45. package/package.json +128 -4
  46. package/src/NativePinVault.ts +72 -0
  47. package/src/index.ts +398 -0
  48. package/src/types.ts +362 -0
@@ -0,0 +1,379 @@
1
+ package io.github.umutcansu.pinvault.reactnative
2
+
3
+ import android.content.ContentProvider
4
+ import android.content.ContentValues
5
+ import android.content.Context
6
+ import android.database.Cursor
7
+ import android.net.Uri
8
+ import android.util.Log
9
+ import com.facebook.react.modules.network.CustomClientBuilder
10
+ import com.facebook.react.modules.network.NetworkingModule
11
+ import com.facebook.react.modules.network.OkHttpClientFactory
12
+ import com.facebook.react.modules.network.OkHttpClientProvider
13
+ import io.github.umutcansu.pinvault.PinVault
14
+ import okhttp3.Cache
15
+ import okhttp3.CookieJar
16
+ import okhttp3.Interceptor
17
+ import okhttp3.OkHttpClient
18
+ import java.io.File
19
+ import java.io.IOException
20
+ import java.net.InetAddress
21
+ import java.net.Socket
22
+ import java.security.cert.X509Certificate
23
+ import javax.net.ssl.SSLHandshakeException
24
+ import javax.net.ssl.SSLSocketFactory
25
+ import javax.net.ssl.X509TrustManager
26
+
27
+ /**
28
+ * Pins React Native's own networking (global `fetch` / `XMLHttpRequest`,
29
+ * `WebSocket`, and `<Image>` through Fresco) with `PinVault.applyTo(builder)` —
30
+ * Android only.
31
+ *
32
+ * Installed **automatically before the app's code runs**: the plugin's manifest
33
+ * declares [PinVaultNetworkingInitializer], a `ContentProvider`, and Android
34
+ * creates content providers before `Application.onCreate` (the AndroidX
35
+ * Startup pattern, without the dependency). [install] stays as an explicit
36
+ * alternative for apps that remove the provider.
37
+ *
38
+ * Two hooks, both verified against React Native 0.87's sources:
39
+ *
40
+ * - `NetworkingModule.setCustomClientBuilder`: RN calls it for **every**
41
+ * fetch / XHR request on `client.newBuilder()`. Each request gets the
42
+ * pinned socket factory and PinVault's interceptors (pin check of pooled
43
+ * connections, `PinVault-Token`, pin-mismatch recovery) of one client that
44
+ * `PinVault.applyTo` configured after `start()` — one per start, so pooled
45
+ * connections are reused — no disk HTTP cache and no cookie jar (unless
46
+ * `android.keepReactNativeHttpCache` / `keepReactNativeCookies`), and no
47
+ * `https` → `http` redirects.
48
+ * - `OkHttpClientProvider.setOkHttpClientFactory`: the clients RN builds once
49
+ * and keeps (the networking base client, the WebSocket / dev-support
50
+ * singleton, Fresco's image client) get a forwarding socket factory: every
51
+ * TLS handshake uses the pinned factory of the current start. No disk
52
+ * cache, no `https` → `http` redirects.
53
+ *
54
+ * Before `start()` — and after a start that failed — every `https` request
55
+ * through RN's networking fails (`IOException: PinVault has not started`),
56
+ * the same fail-closed rule as the library's own `getClient()` / `applyTo`,
57
+ * which refuse before `init`. Plain `http` is not TLS and is left to the
58
+ * app's network security config (Metro in debug builds uses it). Hosts
59
+ * without a pin entry are refused by PinVault's trust manager like
60
+ * everywhere else.
61
+ *
62
+ * Another library that calls `setOkHttpClientFactory` or
63
+ * `setCustomClientBuilder` after PinVault replaces these hooks: [status]
64
+ * tells, `start()` and every plugin `fetch` check it and log a warning, and
65
+ * `requirePinnedReactNativeNetworking: true` makes `start()` fail.
66
+ */
67
+ object PinVaultNetworking {
68
+
69
+ private const val TAG = "PinVault"
70
+
71
+ @Volatile private var installed = false
72
+ @Volatile private var app: Context? = null
73
+
74
+ /** The two hooks this object installed; compared by identity in [status]. */
75
+ private val factory = OkHttpClientFactory {
76
+ pinning.track(pinning.configureLongLived(OkHttpClientProvider.createClientBuilder()).build())
77
+ }
78
+ private val clientBuilder = CustomClientBuilder { builder -> pinning.configurePerRequest(builder) }
79
+
80
+ internal val pinning = NetworkPinning(
81
+ applier = { builder -> PinVault.applyTo(builder) },
82
+ httpCache = { app?.let { ReactHttpCache.get(it) } },
83
+ hooksPinned = { status().pinned },
84
+ )
85
+
86
+ /** True when [install] ran only from `start()`: React Native may have built clients before it. */
87
+ @Volatile private var installedLate = false
88
+
89
+ /**
90
+ * Installs both hooks (once per process). The plugin's content provider
91
+ * calls it before `Application.onCreate`; call it yourself in
92
+ * `MainApplication.onCreate`, before `loadReactNative`, only when the app
93
+ * removed that provider.
94
+ */
95
+ @JvmStatic
96
+ @Synchronized
97
+ fun install(context: Context) = install(context, late = false)
98
+
99
+ /** [late]: called from `start()`, after React Native may already have built its clients. */
100
+ @Synchronized
101
+ internal fun install(context: Context, late: Boolean) {
102
+ if (installed) return
103
+ app = context.applicationContext
104
+ OkHttpClientProvider.setOkHttpClientFactory(factory)
105
+ NetworkingModule.setCustomClientBuilder(clientBuilder)
106
+ installed = true
107
+ installedLate = late
108
+ }
109
+
110
+ @JvmStatic
111
+ val isInstalled: Boolean get() = installed
112
+
113
+ /** Whether React Native's two networking hooks are still the ones [install] set. */
114
+ @JvmStatic
115
+ fun status(): HookStatus = HookStatus.read(installed, factory, clientBuilder, installedLate)
116
+
117
+ private val warned = java.util.concurrent.atomic.AtomicReference<HookStatus?>(null)
118
+
119
+ /** Logs a warning when the hooks are not PinVault's, once per distinct state. */
120
+ internal fun warnIfNotPinned(where: String): HookStatus {
121
+ val s = status()
122
+ if (!s.pinned && warned.getAndSet(s) != s) Log.w(TAG, "$where: ${s.describe()}")
123
+ if (s.pinned) warned.set(null)
124
+ return s
125
+ }
126
+ }
127
+
128
+ /**
129
+ * What React Native's networking hooks are now. `null` = could not be read
130
+ * (a React Native version whose internals moved): treated as not pinned.
131
+ */
132
+ data class HookStatus(
133
+ val installed: Boolean,
134
+ /** `OkHttpClientProvider`'s factory is PinVault's. */
135
+ val factoryIsPinVault: Boolean?,
136
+ /** `NetworkingModule`'s custom client builder is PinVault's. */
137
+ val clientBuilderIsPinVault: Boolean?,
138
+ /** Installed only by `start()`: clients React Native built before (Fresco's, among them) keep the system's trust. */
139
+ val installedLate: Boolean = false,
140
+ ) {
141
+ val pinned: Boolean get() = installed && !installedLate && factoryIsPinVault == true && clientBuilderIsPinVault == true
142
+
143
+ fun describe(): String = when {
144
+ pinned -> "React Native's networking is pinned by PinVault"
145
+ installed && installedLate -> "React Native's networking hooks were installed only by start() (the plugin's content provider " +
146
+ "was removed and PinVaultNetworking.install was not called before React Native started): clients it built " +
147
+ "before, such as the image client, are NOT pinned"
148
+ !installed -> "React Native's networking hooks are not installed (the plugin's content provider was removed " +
149
+ "and PinVaultNetworking.install was not called, or android.pinGlobalNetworking is false): " +
150
+ "RN fetch / XHR / WebSocket / images are NOT pinned"
151
+ else -> buildList {
152
+ if (factoryIsPinVault != true) add("OkHttpClientProvider's client factory " + if (factoryIsPinVault == null) "could not be read" else "was replaced by another library")
153
+ if (clientBuilderIsPinVault != true) add("NetworkingModule's custom client builder " + if (clientBuilderIsPinVault == null) "could not be read" else "was replaced by another library")
154
+ }.joinToString("; ") + ": React Native's own networking may NOT be pinned"
155
+ }
156
+
157
+ internal companion object {
158
+ fun read(installed: Boolean, ourFactory: Any, ourBuilder: Any, installedLate: Boolean = false): HookStatus = HookStatus(
159
+ installed,
160
+ isOurs(OkHttpClientProvider::class.java, "factory", ourFactory),
161
+ isOurs(NetworkingModule::class.java, "customClientBuilder", ourBuilder),
162
+ installedLate,
163
+ )
164
+
165
+ /**
166
+ * Whether RN's private static field [name] holds [ours]; null when it
167
+ * cannot be read. The plugin's consumer R8 rules keep both fields' names.
168
+ */
169
+ private fun isOurs(owner: Class<*>, name: String, ours: Any): Boolean? = try {
170
+ owner.getDeclaredField(name).apply { isAccessible = true }.get(null) === ours
171
+ } catch (_: ReflectiveOperationException) {
172
+ null
173
+ } catch (_: RuntimeException) {
174
+ null
175
+ }
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Installs [PinVaultNetworking] when the app process starts, before
181
+ * `Application.onCreate`. Opt out in the app's manifest:
182
+ *
183
+ * ```xml
184
+ * <provider android:name="io.github.umutcansu.pinvault.reactnative.PinVaultNetworkingInitializer"
185
+ * android:authorities="${applicationId}.pinvault-networking" tools:node="remove" />
186
+ * ```
187
+ */
188
+ class PinVaultNetworkingInitializer : ContentProvider() {
189
+ override fun onCreate(): Boolean {
190
+ context?.let { PinVaultNetworking.install(it) }
191
+ return true
192
+ }
193
+
194
+ override fun query(uri: Uri, projection: Array<out String>?, selection: String?, selectionArgs: Array<out String>?, sortOrder: String?): Cursor? = null
195
+ override fun getType(uri: Uri): String? = null
196
+ override fun insert(uri: Uri, values: ContentValues?): Uri? = null
197
+ override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
198
+ override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int = 0
199
+ }
200
+
201
+ /** RN's own 10 MiB disk cache (`OkHttpClientProvider.createClientBuilder(context)`), only with `keepReactNativeHttpCache`. */
202
+ internal object ReactHttpCache {
203
+ @Volatile private var cache: Cache? = null
204
+
205
+ fun get(context: Context): Cache = cache ?: synchronized(this) {
206
+ cache ?: Cache(File(context.cacheDir, "http-cache"), 10L * 1024 * 1024).also { cache = it }
207
+ }
208
+ }
209
+
210
+ /**
211
+ * The mechanics of [PinVaultNetworking], with the `applyTo` call injected so
212
+ * that JVM tests can run it without a device.
213
+ */
214
+ internal class NetworkPinning(
215
+ private val applier: (OkHttpClient.Builder) -> Unit,
216
+ private val httpCache: () -> Cache? = { null },
217
+ /** Whether React Native's hooks are still PinVault's (``PinVaultNetworking.status``). */
218
+ private val hooksPinned: () -> Boolean = { true },
219
+ ) {
220
+
221
+ /** The long-lived clients React Native built through the factory: their pools are emptied on every change. */
222
+ private val longLived = java.util.Collections.synchronizedList(mutableListOf<java.lang.ref.WeakReference<OkHttpClient>>())
223
+
224
+ /** Last answer of [hooksPinned] and when it was read (reflection: not on every request). */
225
+ @Volatile private var hooksCheckedAt = 0L
226
+ @Volatile private var hooksWerePinned = true
227
+
228
+ fun track(client: OkHttpClient): OkHttpClient {
229
+ longLived.add(java.lang.ref.WeakReference(client))
230
+ return client
231
+ }
232
+
233
+ /** Closes the idle pooled connections of every long-lived client: the next request makes a fresh, pinned handshake. */
234
+ private fun evictLongLived() {
235
+ synchronized(longLived) {
236
+ longLived.removeAll { it.get() == null }
237
+ longLived.forEach { it.get()?.connectionPool?.evictAll() }
238
+ }
239
+ }
240
+
241
+ /** A client `PinVault.applyTo` configured after the last `start()`; null = not started. */
242
+ @Volatile private var template: OkHttpClient? = null
243
+ @Volatile var options: NetworkingOptions = NetworkingOptions()
244
+
245
+ val isActive: Boolean get() = template != null
246
+
247
+ /** After `start()`: one pinned template per start (its socket factory keys the connection pool). */
248
+ @Synchronized
249
+ fun activate(options: NetworkingOptions = this.options) {
250
+ this.options = options
251
+ template = OkHttpClient.Builder().also(applier).build()
252
+ evictLongLived()
253
+ }
254
+
255
+ @Synchronized
256
+ fun deactivate() {
257
+ template = null
258
+ evictLongLived()
259
+ }
260
+
261
+ /** NetworkingModule's per-request builder (fetch / XHR). */
262
+ fun configurePerRequest(builder: OkHttpClient.Builder) {
263
+ // Never from https into plain http; not even before start (the gate below refuses https anyway).
264
+ builder.followSslRedirects(false)
265
+ val o = options
266
+ builder.cache(if (o.keepHttpCache) httpCache() else null)
267
+ // RN puts its persistent cookie jar (Android's CookieManager) on its base client;
268
+ // NO_COOKIES = nothing is sent or stored. RN itself sets NO_COOKIES for withCredentials: false.
269
+ if (!o.keepCookies) builder.cookieJar(CookieJar.NO_COOKIES)
270
+ val pinned = template
271
+ if (pinned == null) {
272
+ if (httpsGate !in builder.interceptors()) builder.addInterceptor(httpsGate)
273
+ return
274
+ }
275
+ builder.sslSocketFactory(pinned.sslSocketFactory, requireNotNull(pinned.x509TrustManager))
276
+ builder.hostnameVerifier(pinned.hostnameVerifier)
277
+ builder.certificatePinner(pinned.certificatePinner)
278
+ // A builder derived from a client this method already configured would
279
+ // carry the interceptors twice; RN derives every request from its base client.
280
+ pinned.interceptors.forEach { if (it !in builder.interceptors()) builder.addInterceptor(it) }
281
+ pinned.networkInterceptors.forEach { if (it !in builder.networkInterceptors()) builder.addNetworkInterceptor(it) }
282
+ }
283
+
284
+ /** Clients RN builds once and keeps: TLS goes through the pinned factory of the current start. */
285
+ fun configureLongLived(builder: OkHttpClient.Builder): OkHttpClient.Builder {
286
+ val forwarding = ForwardingSocketFactory { template?.sslSocketFactory }
287
+ builder.sslSocketFactory(forwarding, ForwardingTrustManager { template?.x509TrustManager })
288
+ builder.addInterceptor(httpsGate)
289
+ // The current start's network interceptors (the per-request pin check of a pooled
290
+ // or coalesced connection, among them) run for these clients too.
291
+ builder.addNetworkInterceptor(forwardingNetwork)
292
+ builder.cache(null)
293
+ builder.followSslRedirects(false)
294
+ return builder
295
+ }
296
+
297
+ /**
298
+ * Refuses `https` while PinVault has not started, and — with
299
+ * `requirePinnedReactNativeNetworking` — once another library has replaced
300
+ * React Native's hooks after start (checked at most every few seconds).
301
+ */
302
+ private val httpsGate = Interceptor { chain ->
303
+ if (chain.request().isHttps) {
304
+ if (template == null) throw IOException("PinVault has not started: https requests are refused until start() returns")
305
+ if (options.requirePinned && !hooksStillPinned()) {
306
+ throw IOException("requirePinnedReactNativeNetworking: React Native's networking hooks are no longer PinVault's")
307
+ }
308
+ }
309
+ chain.proceed(chain.request())
310
+ }
311
+
312
+ private fun hooksStillPinned(): Boolean {
313
+ val now = android.os.SystemClock.elapsedRealtime()
314
+ if (now - hooksCheckedAt > HOOK_CHECK_MS) {
315
+ hooksWerePinned = hooksPinned()
316
+ hooksCheckedAt = now
317
+ }
318
+ return hooksWerePinned
319
+ }
320
+
321
+ /** Runs the current start's network interceptors in order, then the request. */
322
+ private val forwardingNetwork = Interceptor { chain ->
323
+ val interceptors = template?.networkInterceptors ?: emptyList()
324
+ ForwardingChain(chain, interceptors, 0).proceed(chain.request())
325
+ }
326
+
327
+ companion object {
328
+ const val HOOK_CHECK_MS = 5_000L
329
+ }
330
+ }
331
+
332
+ /** A chain that hands the request to `interceptors[index]`, …, and finally to the real chain. */
333
+ internal class ForwardingChain(
334
+ private val base: Interceptor.Chain,
335
+ private val interceptors: List<Interceptor>,
336
+ private val index: Int,
337
+ ) : Interceptor.Chain by base {
338
+ override fun proceed(request: okhttp3.Request): okhttp3.Response =
339
+ if (index >= interceptors.size) base.proceed(request)
340
+ else interceptors[index].intercept(ForwardingChain(base, interceptors, index + 1).withRequest(request))
341
+
342
+ private var current: okhttp3.Request = base.request()
343
+ private fun withRequest(request: okhttp3.Request) = also { current = request }
344
+ override fun request(): okhttp3.Request = current
345
+ }
346
+
347
+ /** Hands every socket to the pinned factory of the current start; none before `start()`. */
348
+ internal class ForwardingSocketFactory(private val current: () -> SSLSocketFactory?) : SSLSocketFactory() {
349
+
350
+ private fun delegate(): SSLSocketFactory =
351
+ current() ?: throw SSLHandshakeException("PinVault has not started: TLS is refused until start() returns")
352
+
353
+ override fun getDefaultCipherSuites(): Array<String> = current()?.defaultCipherSuites ?: emptyArray()
354
+ override fun getSupportedCipherSuites(): Array<String> = current()?.supportedCipherSuites ?: emptyArray()
355
+ override fun createSocket(s: Socket?, host: String?, port: Int, autoClose: Boolean): Socket =
356
+ delegate().createSocket(s, host, port, autoClose)
357
+ override fun createSocket(host: String?, port: Int): Socket = delegate().createSocket(host, port)
358
+ override fun createSocket(host: String?, port: Int, localHost: InetAddress?, localPort: Int): Socket =
359
+ delegate().createSocket(host, port, localHost, localPort)
360
+ override fun createSocket(host: InetAddress?, port: Int): Socket = delegate().createSocket(host, port)
361
+ override fun createSocket(address: InetAddress?, port: Int, localAddress: InetAddress?, localPort: Int): Socket =
362
+ delegate().createSocket(address, port, localAddress, localPort)
363
+ override fun createSocket(): Socket = delegate().createSocket()
364
+ }
365
+
366
+ /**
367
+ * OkHttp uses this trust manager only to clean certificate chains; the
368
+ * handshake itself is checked inside the pinned socket factory.
369
+ */
370
+ internal class ForwardingTrustManager(private val current: () -> X509TrustManager?) : X509TrustManager {
371
+ private fun delegate(): X509TrustManager =
372
+ current() ?: throw java.security.cert.CertificateException("PinVault has not started")
373
+
374
+ override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) =
375
+ delegate().checkClientTrusted(chain, authType)
376
+ override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) =
377
+ delegate().checkServerTrusted(chain, authType)
378
+ override fun getAcceptedIssuers(): Array<X509Certificate> = current()?.acceptedIssuers ?: emptyArray()
379
+ }
@@ -0,0 +1,25 @@
1
+ package io.github.umutcansu.pinvault.reactnative
2
+
3
+ import com.facebook.react.BaseReactPackage
4
+ import com.facebook.react.bridge.NativeModule
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.module.model.ReactModuleInfo
7
+ import com.facebook.react.module.model.ReactModuleInfoProvider
8
+
9
+ class PinVaultPackage : BaseReactPackage() {
10
+ override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? =
11
+ if (name == PinVaultModule.NAME) PinVaultModule(reactContext) else null
12
+
13
+ override fun getReactModuleInfoProvider() = ReactModuleInfoProvider {
14
+ mapOf(
15
+ PinVaultModule.NAME to ReactModuleInfo(
16
+ name = PinVaultModule.NAME,
17
+ className = PinVaultModule.NAME,
18
+ canOverrideExistingModule = false,
19
+ needsEagerInit = false,
20
+ isCxxModule = false,
21
+ isTurboModule = true,
22
+ )
23
+ )
24
+ }
25
+ }
@@ -0,0 +1,131 @@
1
+ package io.github.umutcansu.pinvault.reactnative
2
+
3
+ import io.github.umutcansu.pinvault.model.HttpConnectionSettings
4
+ import okhttp3.Headers
5
+ import okhttp3.HttpUrl
6
+ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
7
+ import okhttp3.MediaType.Companion.toMediaTypeOrNull
8
+ import okhttp3.OkHttpClient
9
+ import okhttp3.Request
10
+ import okhttp3.RequestBody.Companion.toRequestBody
11
+ import okio.Buffer
12
+ import okio.ByteString.Companion.decodeBase64
13
+ import java.io.IOException
14
+ import java.util.concurrent.TimeUnit
15
+
16
+ /** A `fetch(url, init)` from JS, checked. */
17
+ internal class FetchRequest(
18
+ val url: HttpUrl,
19
+ val method: String,
20
+ val headers: Headers,
21
+ val body: ByteArray?,
22
+ val responseEncoding: String,
23
+ val timeoutMs: Long?,
24
+ val maxResponseBytes: Long,
25
+ val settings: HttpConnectionSettings?,
26
+ )
27
+
28
+ /** The response was larger than the request allowed. */
29
+ internal class ResponseTooLargeException(limit: Long) : IOException("Response larger than $limit bytes")
30
+
31
+ /**
32
+ * The plugin's own `fetch`: parsed strictly, sent through the pinned client
33
+ * the library hands out (`PinVault.getClient()` or `getClient(settings)`),
34
+ * HTTPS only, bounded in both directions. A redirect from `https` into plain
35
+ * `http` is not followed (the 3xx comes back), as on iOS.
36
+ */
37
+ internal object PinnedFetch {
38
+
39
+ const val MAX_REQUEST_BYTES = 10L * 1024 * 1024
40
+ const val DEFAULT_MAX_RESPONSE_BYTES = 10L * 1024 * 1024
41
+ const val MAX_RESPONSE_BYTES = 50L * 1024 * 1024
42
+ private val METHODS = setOf("GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
43
+ private val TOKEN = Regex("[!#$%&'*+.^_`|~0-9A-Za-z-]{1,256}")
44
+
45
+ fun parse(json: String): FetchRequest {
46
+ // Base64 inflates by 4/3; leave room for the other fields.
47
+ val f = StrictJson.parseObject(json, "request", maxChars = (MAX_REQUEST_BYTES * 4 / 3 + 64 * 1024).toInt())
48
+ val urlText = f.requireString("url", 8192)
49
+ val url = urlText.toHttpUrlOrNull() ?: throw BridgeInputException("request.url: not a valid URL")
50
+ if (!url.isHttps) throw BridgeInputException("request.url: only https:// URLs go through the pinned client")
51
+ val method = (f.string("method", 16) ?: "GET").uppercase()
52
+ if (method !in METHODS) throw BridgeInputException("request.method: '$method' is not supported")
53
+ val headers = Headers.Builder()
54
+ f.stringMap("headers", 128, 256, 8192)?.forEach { (name, value) ->
55
+ if (!TOKEN.matches(name)) throw BridgeInputException("request.headers: '${name.take(40)}' is not a header name")
56
+ if (value.any { it == '\r' || it == '\n' || it.code == 0 }) {
57
+ throw BridgeInputException("request.headers.$name: line breaks are not allowed")
58
+ }
59
+ headers.add(name, value)
60
+ }
61
+ val bodyText = f.text("body", (MAX_REQUEST_BYTES * 4 / 3 + 4).toInt())
62
+ val bodyEncoding = ResultMapper.checkEncoding(f.string("bodyEncoding", 16) ?: ResultMapper.UTF8)
63
+ val body = bodyText?.let {
64
+ if (bodyEncoding == ResultMapper.BASE64) {
65
+ it.decodeBase64()?.toByteArray() ?: throw BridgeInputException("request.body: not valid Base64")
66
+ } else {
67
+ it.toByteArray(Charsets.UTF_8)
68
+ }
69
+ }
70
+ if (body != null && body.size > MAX_REQUEST_BYTES) throw BridgeInputException("request.body: larger than $MAX_REQUEST_BYTES bytes")
71
+ if (body != null && (method == "GET" || method == "HEAD")) throw BridgeInputException("request.body: $method has no body")
72
+ val responseEncoding = ResultMapper.checkEncoding(f.string("responseEncoding", 16) ?: ResultMapper.UTF8)
73
+ val timeoutMs = f.long("timeoutMs", 1, 10 * 60 * 1000L)
74
+ val maxResponse = f.long("maxResponseBytes", 1, MAX_RESPONSE_BYTES) ?: DEFAULT_MAX_RESPONSE_BYTES
75
+ val settings = f.obj("settings")?.let { s ->
76
+ val d = HttpConnectionSettings()
77
+ val parsed = HttpConnectionSettings(
78
+ connectTimeout = s.long("connectTimeout", 0, 600) ?: d.connectTimeout,
79
+ readTimeout = s.long("readTimeout", 0, 600) ?: d.readTimeout,
80
+ writeTimeout = s.long("writeTimeout", 0, 600) ?: d.writeTimeout,
81
+ callTimeout = s.long("callTimeout", 0, 600) ?: d.callTimeout,
82
+ )
83
+ s.finish()
84
+ parsed
85
+ }
86
+ f.finish()
87
+ return FetchRequest(url, method, headers.build(), body, responseEncoding, timeoutMs, maxResponse, settings)
88
+ }
89
+
90
+ /** Runs [request] on [client] (a pinned client from the library); blocking. */
91
+ fun execute(client: OkHttpClient, request: FetchRequest): Map<String, Any?> {
92
+ // newBuilder shares the pool, the pinned socket factory and the
93
+ // library's interceptors; it only stops https → http redirects.
94
+ val callClient = client.newBuilder().followSslRedirects(false).build()
95
+ val contentType = request.headers["Content-Type"]?.toMediaTypeOrNull()
96
+ val body = when {
97
+ request.body != null -> request.body.toRequestBody(contentType)
98
+ request.method in setOf("POST", "PUT", "PATCH") -> ByteArray(0).toRequestBody(contentType)
99
+ else -> null
100
+ }
101
+ val okRequest = Request.Builder().url(request.url).headers(request.headers).method(request.method, body).build()
102
+ val call = callClient.newCall(okRequest)
103
+ request.timeoutMs?.let { call.timeout().timeout(it, TimeUnit.MILLISECONDS) }
104
+ call.execute().use { response ->
105
+ val responseBody = response.body
106
+ val declared = responseBody?.contentLength() ?: -1L
107
+ if (declared > request.maxResponseBytes) throw ResponseTooLargeException(request.maxResponseBytes)
108
+ val buffer = Buffer()
109
+ if (responseBody != null) {
110
+ val source = responseBody.source()
111
+ while (true) {
112
+ val read = source.read(buffer, 64 * 1024L)
113
+ if (read == -1L) break
114
+ if (buffer.size > request.maxResponseBytes) throw ResponseTooLargeException(request.maxResponseBytes)
115
+ }
116
+ }
117
+ val bytes = buffer.readByteArray()
118
+ val headers = LinkedHashMap<String, String>()
119
+ response.headers.names().forEach { name ->
120
+ headers[name.lowercase()] = response.headers.values(name).joinToString(", ")
121
+ }
122
+ return mapOf(
123
+ "status" to response.code,
124
+ "url" to response.request.url.toString(),
125
+ "headers" to headers,
126
+ "body" to ResultMapper.encode(bytes, request.responseEncoding),
127
+ "bodyEncoding" to request.responseEncoding,
128
+ )
129
+ }
130
+ }
131
+ }