From ce271de7a9e5fcc22ae4ed79eb9a232f3c7ab7c2 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Fri, 12 Jun 2026 23:58:45 +0800 Subject: [PATCH 1/7] feat: add @onekeyfe/react-native-sni-connect module (3.0.66) Sync react-native-sni-connect from OneKeyHQ/react-native-sni-connect into native-modules, aligned to the shared 3.0.66 version. Bumps the EMASCurl pod dependency to 1.5.5 (previously applied as an app-monorepo patch). --- .../react-native-sni-connect/README.md | 37 ++ .../SniConnect.podspec | 36 ++ .../android/build.gradle | 89 ++++ .../android/gradle.properties | 5 + .../android/src/main/AndroidManifest.xml | 2 + .../java/com/sniconnect/SniConnectModule.kt | 360 +++++++++++++ .../java/com/sniconnect/SniConnectPackage.kt | 31 ++ .../react-native-sni-connect/babel.config.js | 12 + .../ios/SniConnect-Bridging-Header.h | 13 + .../ios/SniConnect.mm | 141 +++++ .../ios/SniConnect.swift | 185 +++++++ .../ios/SniConnectClient.swift | 497 ++++++++++++++++++ .../react-native-sni-connect/package.json | 157 ++++++ .../scripts/block-ips.sh | 44 ++ .../scripts/sni-request.js | 74 +++ .../scripts/verify-sni.sh | 84 +++ .../src/@types/react-native-codegen.d.ts | 8 + .../src/NativeSniConnect.ts | 70 +++ .../src/__tests__/index.test.ts | 435 +++++++++++++++ .../react-native-sni-connect/src/index.tsx | 46 ++ .../tsconfig.build.json | 4 + .../react-native-sni-connect/tsconfig.json | 30 ++ .../react-native-sni-connect/turbo.json | 42 ++ yarn.lock | 33 ++ 24 files changed, 2435 insertions(+) create mode 100644 native-modules/react-native-sni-connect/README.md create mode 100644 native-modules/react-native-sni-connect/SniConnect.podspec create mode 100644 native-modules/react-native-sni-connect/android/build.gradle create mode 100644 native-modules/react-native-sni-connect/android/gradle.properties create mode 100644 native-modules/react-native-sni-connect/android/src/main/AndroidManifest.xml create mode 100644 native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectModule.kt create mode 100644 native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectPackage.kt create mode 100644 native-modules/react-native-sni-connect/babel.config.js create mode 100644 native-modules/react-native-sni-connect/ios/SniConnect-Bridging-Header.h create mode 100644 native-modules/react-native-sni-connect/ios/SniConnect.mm create mode 100644 native-modules/react-native-sni-connect/ios/SniConnect.swift create mode 100644 native-modules/react-native-sni-connect/ios/SniConnectClient.swift create mode 100644 native-modules/react-native-sni-connect/package.json create mode 100755 native-modules/react-native-sni-connect/scripts/block-ips.sh create mode 100644 native-modules/react-native-sni-connect/scripts/sni-request.js create mode 100755 native-modules/react-native-sni-connect/scripts/verify-sni.sh create mode 100644 native-modules/react-native-sni-connect/src/@types/react-native-codegen.d.ts create mode 100644 native-modules/react-native-sni-connect/src/NativeSniConnect.ts create mode 100644 native-modules/react-native-sni-connect/src/__tests__/index.test.ts create mode 100644 native-modules/react-native-sni-connect/src/index.tsx create mode 100644 native-modules/react-native-sni-connect/tsconfig.build.json create mode 100644 native-modules/react-native-sni-connect/tsconfig.json create mode 100644 native-modules/react-native-sni-connect/turbo.json diff --git a/native-modules/react-native-sni-connect/README.md b/native-modules/react-native-sni-connect/README.md new file mode 100644 index 00000000..ae6ef919 --- /dev/null +++ b/native-modules/react-native-sni-connect/README.md @@ -0,0 +1,37 @@ +# react-native-sni-connect + +onekey sni http client + +## Installation + + +```sh +npm install react-native-sni-connect +``` + + +## Usage + + +```js +import { multiply } from 'react-native-sni-connect'; + +// ... + +const result = multiply(3, 7); +``` + + +## Contributing + +- [Development workflow](CONTRIBUTING.md#development-workflow) +- [Sending a pull request](CONTRIBUTING.md#sending-a-pull-request) +- [Code of conduct](CODE_OF_CONDUCT.md) + +## License + +MIT + +--- + +Made with [create-react-native-library](https://github.com/callstack/react-native-builder-bob) diff --git a/native-modules/react-native-sni-connect/SniConnect.podspec b/native-modules/react-native-sni-connect/SniConnect.podspec new file mode 100644 index 00000000..cd05d30f --- /dev/null +++ b/native-modules/react-native-sni-connect/SniConnect.podspec @@ -0,0 +1,36 @@ +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) + +Pod::Spec.new do |s| + s.name = "SniConnect" + s.version = package["version"] + s.summary = package["description"] + s.homepage = package["homepage"] + s.license = package["license"] + s.authors = package["author"] + + s.platforms = { :ios => min_ios_version_supported } + s.source = { :git => "https://github.com/OneKeyHQ/react-native-sni-connect.git", :tag => "#{s.version}" } + + s.static_framework = true + + s.source_files = [ + "ios/**/*.{swift}", + "ios/**/*.{m,mm}" + ] + + s.dependency 'React-Core' + s.dependency 'React-jsi' + s.dependency 'React-callinvoker' + s.dependency 'React-Codegen' + s.dependency 'EMASCurl', '1.5.5' + + s.pod_target_xcconfig = { + "HEADER_SEARCH_PATHS" => "\"$(PODS_ROOT)/Headers/Private/React-Core\"", + "DEFINES_MODULE" => "YES" + } + + + install_modules_dependencies(s) +end diff --git a/native-modules/react-native-sni-connect/android/build.gradle b/native-modules/react-native-sni-connect/android/build.gradle new file mode 100644 index 00000000..90287b90 --- /dev/null +++ b/native-modules/react-native-sni-connect/android/build.gradle @@ -0,0 +1,89 @@ +buildscript { + ext.getExtOrDefault = {name -> + return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties['SniConnect_' + name] + } + + repositories { + google() + mavenCentral() + } + + dependencies { + classpath "com.android.tools.build:gradle:8.7.2" + // noinspection DifferentKotlinGradleVersion + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${getExtOrDefault('kotlinVersion')}" + } +} + + +apply plugin: "com.android.library" +apply plugin: "kotlin-android" +apply plugin: "com.facebook.react" + +def getExtOrIntegerDefault(name) { + return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["SniConnect_" + name]).toInteger() +} + +android { + namespace "com.sniconnect" + + compileSdkVersion getExtOrIntegerDefault("compileSdkVersion") + + defaultConfig { + minSdkVersion getExtOrIntegerDefault("minSdkVersion") + targetSdkVersion getExtOrIntegerDefault("targetSdkVersion") + } + + packagingOptions { + excludes = [ + "META-INF", + "META-INF/**", + "**/libc++_shared.so", + "**/libfbjni.so", + "**/libjsi.so", + "**/libfolly_json.so", + "**/libfolly_runtime.so", + "**/libglog.so", + "**/libhermes.so", + "**/libhermes-executor-debug.so", + "**/libhermes_executor.so", + "**/libreactnative.so", + "**/libreactnativejni.so", + "**/libturbomodulejsijni.so", + "**/libreact_nativemodule_core.so", + "**/libjscexecutor.so" + ] + } + + buildFeatures { + buildConfig true + } + + buildTypes { + release { + minifyEnabled false + } + } + + lintOptions { + disable "GradleCompatible" + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } +} + +repositories { + mavenCentral() + google() +} + +def kotlin_version = getExtOrDefault("kotlinVersion") + +dependencies { + implementation "com.facebook.react:react-android" + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation "com.squareup.okhttp3:okhttp:4.12.0" +} diff --git a/native-modules/react-native-sni-connect/android/gradle.properties b/native-modules/react-native-sni-connect/android/gradle.properties new file mode 100644 index 00000000..6480e036 --- /dev/null +++ b/native-modules/react-native-sni-connect/android/gradle.properties @@ -0,0 +1,5 @@ +SniConnect_kotlinVersion=2.0.21 +SniConnect_minSdkVersion=24 +SniConnect_targetSdkVersion=34 +SniConnect_compileSdkVersion=35 +SniConnect_ndkVersion=27.1.12297006 diff --git a/native-modules/react-native-sni-connect/android/src/main/AndroidManifest.xml b/native-modules/react-native-sni-connect/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a2f47b60 --- /dev/null +++ b/native-modules/react-native-sni-connect/android/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectModule.kt b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectModule.kt new file mode 100644 index 00000000..284f64f9 --- /dev/null +++ b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectModule.kt @@ -0,0 +1,360 @@ +package com.sniconnect + +import android.util.Log +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.WritableMap +import com.facebook.react.modules.core.DeviceEventManagerModule +import com.facebook.react.module.annotations.ReactModule +import okhttp3.Call +import okhttp3.Callback +import okhttp3.Dns +import okhttp3.Headers +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okhttp3.ResponseBody +import java.io.IOException +import java.net.InetAddress +import java.net.UnknownHostException +import java.util.Locale +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit +import javax.net.ssl.HttpsURLConnection + +private const val TAG = "SniConnect" + +@ReactModule(name = SniConnectModule.NAME) +class SniConnectModule(reactContext: ReactApplicationContext) : + NativeSniConnectSpec(reactContext) { + + companion object { + const val NAME = "SniConnect" + private const val EVENT_NAME = "SniConnectLog" + } + + /** + * Cache key for OkHttpClient instances + * Uses hostname:IP combination to ensure: + * - Different IPs for the same hostname are isolated (accurate speed testing) + * - Same hostname+IP combination can reuse connections (performance optimization) + * Note: timeout is NOT part of the key to allow connection reuse across different timeout values + */ + private data class ClientKey( + val hostname: String, + val ip: String, + ) + + private val clientCache = ConcurrentHashMap() + private val activeCalls = ConcurrentHashMap() + private var listenerCount = 0 + + override fun getName(): String = NAME + + // Event emitter methods required for NativeEventEmitter + @ReactMethod + override fun addListener(eventType: String) { + listenerCount += 1 + } + + @ReactMethod + override fun removeListeners(count: Double) { + listenerCount -= count.toInt() + if (listenerCount < 0) { + listenerCount = 0 + } + } + + private fun sendLogEvent(level: String, message: String) { + if (listenerCount > 0) { + val logData = Arguments.createMap().apply { + putString("level", level) + putString("message", message) + putDouble("timestamp", System.currentTimeMillis().toDouble()) + } + + reactApplicationContext + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + ?.emit(EVENT_NAME, logData) + } + } + + override fun request(config: ReadableMap, promise: Promise) { + try { + val requestConfig = config.toRequestConfig() + // Log module initialization + sendLogEvent("info", "SniConnect module initialized successfully") + performRequest(requestConfig, promise) + } catch (error: Exception) { + Log.e(TAG, "[SniConnect] Request failed", error) + sendLogEvent("error", "Config parsing failed: ${error.message}") + promise.reject("SNI_REQUEST_FAILED", error.message, error) + } + } + + @ReactMethod + override fun cancelRequest(requestId: String, promise: Promise) { + val call = activeCalls.remove(requestId) + if (call != null) { + call.cancel() + sendLogEvent("info", "Cancelled request: $requestId") + promise.resolve(Arguments.createMap().apply { + putBoolean("success", true) + }) + } else { + sendLogEvent("info", "Request not found: $requestId") + promise.resolve(Arguments.createMap().apply { + putBoolean("success", false) + }) + } + } + + @ReactMethod + override fun cancelAllRequests(promise: Promise) { + val count = activeCalls.size + activeCalls.forEach { (_, call) -> + call.cancel() + } + activeCalls.clear() + sendLogEvent("info", "Cancelled $count active requests") + promise.resolve(Arguments.createMap().apply { + putBoolean("success", true) + }) + } + + @ReactMethod + override fun clearDNSCache(promise: Promise) { + clientCache.clear() + sendLogEvent("info", "DNS cache cleared") + promise.resolve(Arguments.createMap().apply { + putBoolean("success", true) + }) + } + + private fun performRequest(config: RequestConfig, promise: Promise) { + try { + val client = getOrCreateClient(config) + val request = buildRequest(config) + val call = client.newCall(request) + + // Apply per-request timeout + call.timeout().timeout(config.timeoutMillis, TimeUnit.MILLISECONDS) + + // Register the call if requestId is provided + config.requestId?.let { requestId -> + activeCalls[requestId] = call + sendLogEvent("info", "Registered request: $requestId, total active: ${activeCalls.size}") + } + + call.enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + // Unregister the call + config.requestId?.let { activeCalls.remove(it) } + + if (call.isCanceled()) { + sendLogEvent("info", "Request cancelled") + promise.reject("SNI_CANCELLED", "Request cancelled", null) + } else { + Log.e(TAG, "[SniConnect] Request failed", e) + sendLogEvent("error", "Request failed: ${e.message}") + promise.reject("SNI_REQUEST_FAILED", e.message, e) + } + } + + override fun onResponse(call: Call, response: Response) { + // Unregister the call + config.requestId?.let { activeCalls.remove(it) } + + try { + response.use { + val bodyString = response.body.safeString() + val headerMap = headersToMap(response.headers) + + val result: WritableMap = Arguments.createMap().apply { + putString("data", bodyString) + putInt("status", response.code) + putString("statusText", response.message) + putMap("headers", headerMap.toWritableMap()) + } + + promise.resolve(result) + } + } catch (error: Exception) { + Log.e(TAG, "[SniConnect] Response processing failed", error) + sendLogEvent("error", "Response processing failed: ${error.message}") + promise.reject("SNI_RESPONSE_FAILED", error.message, error) + } + } + }) + } catch (error: Exception) { + Log.e(TAG, "[SniConnect] Request setup failed", error) + sendLogEvent("error", "Request setup failed: ${error.message}") + promise.reject("SNI_REQUEST_FAILED", error.message, error) + } + } + + private fun getOrCreateClient(config: RequestConfig): OkHttpClient { + val normalizedHost = config.hostname.lowercase(Locale.US) + val key = ClientKey(normalizedHost, config.ip) + + return clientCache.getOrPut(key) { + // Note: timeout is not part of the cache key to allow connection reuse + // Use reasonable default timeouts for the client + // Per-request timeout is applied via call.timeout() in performRequest + val defaultTimeout = 60_000L // 60 seconds + + OkHttpClient.Builder() + .connectTimeout(defaultTimeout, TimeUnit.MILLISECONDS) + .readTimeout(defaultTimeout, TimeUnit.MILLISECONDS) + .writeTimeout(defaultTimeout, TimeUnit.MILLISECONDS) + .callTimeout(0, TimeUnit.MILLISECONDS) // Disable client-level call timeout + .hostnameVerifier { _, session -> + HttpsURLConnection.getDefaultHostnameVerifier().verify(config.hostname, session) + } + .dns(createPinnedDns(config.ip, config.hostname)) + .build() + } + } + + private fun createPinnedDns(ip: String, hostname: String): Dns = + object : Dns { + private val expectedHost = hostname.lowercase(Locale.US) + + override fun lookup(requestedHost: String): List { + return if (requestedHost.lowercase(Locale.US) == expectedHost) { + listOf(resolveIp(ip)) + } else { + Dns.SYSTEM.lookup(requestedHost) + } + } + } + + private fun buildRequest(config: RequestConfig): Request { + val normalizedPath = if (config.path.startsWith("http")) { + config.path + } else { + val prefix = if (config.path.startsWith("/")) "" else "/" + "https://${config.hostname}$prefix${config.path}" + } + + val builder = Request.Builder().url(normalizedPath) + + config.headers.forEach { (key, value) -> + if (!key.equals("host", ignoreCase = true)) { + builder.addHeader(key, value) + } + } + builder.header("Host", config.hostname) + + val method = config.method.uppercase(Locale.US) + val bodyContent = config.body ?: "" + val mediaType = config.headers.entries + .firstOrNull { it.key.equals("Content-Type", ignoreCase = true) } + ?.value + ?.toMediaTypeOrNull() + ?: "application/json; charset=utf-8".toMediaTypeOrNull() + + when (method) { + "GET" -> builder.get() + "HEAD" -> builder.head() + else -> { + val requestBody = bodyContent.toRequestBody(mediaType) + when (method) { + "POST" -> builder.post(requestBody) + "PUT" -> builder.put(requestBody) + "PATCH" -> builder.patch(requestBody) + "DELETE" -> builder.delete(requestBody) + else -> builder.method(method, requestBody) + } + } + } + + return builder.build() + } + + private fun ResponseBody?.safeString(): String { + if (this == null) { + return "" + } + return try { + this.string() + } catch (error: IOException) { + Log.e(TAG, "[SniConnect] Failed to read response body", error) + throw IOException("Failed to read response body", error) + } + } + + private fun headersToMap(headers: Headers): Map { + val map = mutableMapOf() + for (name in headers.names()) { + map[name] = headers[name] ?: "" + } + return map + } + + private fun resolveIp(ip: String): InetAddress { + return try { + InetAddress.getByName(ip) + } catch (error: UnknownHostException) { + throw IOException("Invalid IP address: $ip", error) + } + } + + private fun Map.toWritableMap(): WritableMap { + return Arguments.createMap().apply { + forEach { (key, value) -> + putString(key, value) + } + } + } + + private fun ReadableMap.toRequestConfig(): RequestConfig { + val headersMap = if (hasKey("headers") && !isNull("headers")) { + val headersReadable = getMap("headers") + headersReadable?.toHashMap() + ?.mapValues { (_, value) -> value?.toString() ?: "" } + ?: emptyMap() + } else { + emptyMap() + } + + val timeoutMillis = if (hasKey("timeout")) { + (getDouble("timeout") * 1.0).toLong() + } else { + 30_000L + } + + val requestId = if (hasKey("requestId") && !isNull("requestId")) { + getString("requestId") + } else { + null + } + + return RequestConfig( + requestId = requestId, + ip = getString("ip") ?: throw IllegalArgumentException("ip is required"), + hostname = getString("hostname") ?: throw IllegalArgumentException("hostname is required"), + method = getString("method") ?: "GET", + path = getString("path") ?: "/", + headers = headersMap, + body = if (hasKey("body") && !isNull("body")) getString("body") else null, + timeoutMillis = timeoutMillis, + ) + } + + private data class RequestConfig( + val requestId: String?, + val ip: String, + val hostname: String, + val method: String, + val path: String, + val headers: Map, + val body: String?, + val timeoutMillis: Long, + ) +} diff --git a/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectPackage.kt b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectPackage.kt new file mode 100644 index 00000000..7ab45e26 --- /dev/null +++ b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectPackage.kt @@ -0,0 +1,31 @@ +package com.sniconnect + +import com.facebook.react.TurboReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.model.ReactModuleInfo +import com.facebook.react.module.model.ReactModuleInfoProvider +import com.facebook.react.uimanager.ViewManager + +class SniConnectPackage : TurboReactPackage() { + override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? = + if (name == SniConnectModule.NAME) SniConnectModule(reactContext) else null + + override fun getReactModuleInfoProvider(): ReactModuleInfoProvider = + ReactModuleInfoProvider { + mapOf( + SniConnectModule.NAME to ReactModuleInfo( + SniConnectModule.NAME, + SniConnectModule.NAME, + false, + false, + true, + false, + true + ) + ) + } + + override fun createViewManagers(reactContext: ReactApplicationContext): List> = + emptyList() +} diff --git a/native-modules/react-native-sni-connect/babel.config.js b/native-modules/react-native-sni-connect/babel.config.js new file mode 100644 index 00000000..0c05fd69 --- /dev/null +++ b/native-modules/react-native-sni-connect/babel.config.js @@ -0,0 +1,12 @@ +module.exports = { + overrides: [ + { + exclude: /\/node_modules\//, + presets: ['module:react-native-builder-bob/babel-preset'], + }, + { + include: /\/node_modules\//, + presets: ['module:@react-native/babel-preset'], + }, + ], +}; diff --git a/native-modules/react-native-sni-connect/ios/SniConnect-Bridging-Header.h b/native-modules/react-native-sni-connect/ios/SniConnect-Bridging-Header.h new file mode 100644 index 00000000..0a583fa9 --- /dev/null +++ b/native-modules/react-native-sni-connect/ios/SniConnect-Bridging-Header.h @@ -0,0 +1,13 @@ +// +// SniConnect-Bridging-Header.h +// SniConnect +// +// Used to import Objective-C headers that should be visible to Swift. +// + +#import +#import + +#ifdef RCT_NEW_ARCH_ENABLED +#import +#endif diff --git a/native-modules/react-native-sni-connect/ios/SniConnect.mm b/native-modules/react-native-sni-connect/ios/SniConnect.mm new file mode 100644 index 00000000..336c6545 --- /dev/null +++ b/native-modules/react-native-sni-connect/ios/SniConnect.mm @@ -0,0 +1,141 @@ +#import +#import +#import + +#ifdef RCT_NEW_ARCH_ENABLED +#import +#endif + +// Forward declaration of the Swift implementation +@interface SniConnectImpl : NSObject +- (instancetype)initWithEventSender:(id)eventSender; +- (void)request:(NSDictionary *)config + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; +- (void)cancelRequest:(NSString *)requestId + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; +- (void)cancelAllRequests:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; +- (void)clearDNSCache:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; +@end + +@interface SniConnect : RCTEventEmitter +#ifdef RCT_NEW_ARCH_ENABLED + +#else + +#endif +@end + +@implementation SniConnect { + SniConnectImpl *_implementation; + BOOL _hasListeners; +} + +RCT_EXPORT_MODULE(SniConnect) + +// Expose hasListeners as a property for Swift access +- (BOOL)hasListeners { + return _hasListeners; +} + ++ (BOOL)requiresMainQueueSetup { + return NO; +} + +- (instancetype)init { + if (self = [super init]) { + _implementation = [[SniConnectImpl alloc] initWithEventSender:self]; + _hasListeners = NO; + } + return self; +} + +// Event emitter methods +- (NSArray *)supportedEvents { + return @[@"SniConnectLog"]; +} + +- (void)startObserving { + _hasListeners = YES; +} + +- (void)stopObserving { + _hasListeners = NO; +} + +// Method to send log event to JS +- (void)sendLogEvent:(NSDictionary *)logData { + if (_hasListeners) { + [self sendEventWithName:@"SniConnectLog" body:logData]; + } +} + +#ifdef RCT_NEW_ARCH_ENABLED +// TurboModule interface implementation +- (void)request:(JS::NativeSniConnect::SniConnectRequest &)config + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { + // Convert Codegen struct to NSDictionary for Swift implementation + NSDictionary *configDict = @{ + @"ip": config.ip(), + @"hostname": config.hostname(), + @"method": config.method(), + @"path": config.path(), + @"headers": config.headers(), + @"body": config.body() ?: [NSNull null], + @"timeout": @(config.timeout()) + }; + + [_implementation request:configDict resolve:resolve reject:reject]; +} + +- (void)cancelRequest:(NSString *)requestId + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { + [_implementation cancelRequest:requestId resolve:resolve reject:reject]; +} + +- (void)cancelAllRequests:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { + [_implementation cancelAllRequests:resolve reject:reject]; +} + +- (void)clearDNSCache:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { + [_implementation clearDNSCache:resolve reject:reject]; +} + +- (std::shared_ptr)getTurboModule: + (const facebook::react::ObjCTurboModule::InitParams &)params +{ + return std::make_shared(params); +} +#else +// Bridge module interface implementation +RCT_EXPORT_METHOD(request:(NSDictionary *)config + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) { + [_implementation request:config resolve:resolve reject:reject]; +} + +RCT_EXPORT_METHOD(cancelRequest:(NSString *)requestId + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) { + [_implementation cancelRequest:requestId resolve:resolve reject:reject]; +} + +RCT_EXPORT_METHOD(cancelAllRequests:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) { + [_implementation cancelAllRequests:resolver reject:rejecter]; +} + +RCT_EXPORT_METHOD(clearDNSCache:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) { + [_implementation clearDNSCache:resolver reject:rejecter]; +} +#endif + +@end diff --git a/native-modules/react-native-sni-connect/ios/SniConnect.swift b/native-modules/react-native-sni-connect/ios/SniConnect.swift new file mode 100644 index 00000000..24df23df --- /dev/null +++ b/native-modules/react-native-sni-connect/ios/SniConnect.swift @@ -0,0 +1,185 @@ +import Foundation +import React + +private enum SniConnectError: Error { + case invalidConfig(String) +} + +@objc(SniConnectImpl) +final class SniConnectImpl: NSObject { + private let client: SniConnectClient + private weak var eventSender: AnyObject? + + @objc + init(eventSender: AnyObject) { + self.eventSender = eventSender + self.client = SniConnectClient() + super.init() + + // Set up logging closure for the client + self.client.onLog = { [weak self] level, message in + self?.sendLogEvent(level: level, message: message) + } + } + + private func sendLogEvent(level: String, message: String) { + // Send log event to the Objective-C bridge + guard let eventSender = eventSender else { + return + } + + let logData: [String: Any] = [ + "level": level, + "message": message, + "timestamp": Int(Date().timeIntervalSince1970 * 1000) + ] + + // Call the sendLogEvent method on the bridge + let selector = NSSelectorFromString("sendLogEvent:") + if eventSender.responds(to: selector) { + eventSender.perform(selector, with: logData) + } + } + + @objc + public func request( + _ config: NSDictionary, + resolve resolve: @escaping RCTPromiseResolveBlock, + reject reject: @escaping RCTPromiseRejectBlock + ) { + do { + let parsed = try Self.parseDictionary(config) + handleRequest(config: parsed, resolve: resolve, reject: reject) + } catch { + NSLog("[SniConnect] ❌ Config parsing failed: \(error)") + sendLogEvent(level: "error", message: "Config parsing failed: \(error)") + reject("SNI_REQUEST_FAILED", error.localizedDescription, error) + } + } + + private func handleRequest( + config: SniConnectClient.RequestConfig, + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock + ) { + // Create task and register it synchronously before it starts executing + let task = Task { () -> SniConnectClient.Response in + return try await client.performRequest(config: config) + } + + // Register task synchronously if requestId is provided + if let requestId = config.requestId { + client.registerTask(task, for: requestId) + } + + // Handle the task result asynchronously + Task { + do { + let result = try await task.value + let responseBody = Self.serializeResponseData(result.data) + + resolve([ + "data": responseBody, + "status": result.status, + "statusText": result.statusText, + "headers": result.headers, + "multiValueHeaders": result.multiValueHeaders, + ]) + } catch let error as SniConnectClient.SniConnectError { + NSLog("[SniConnect] ❌ [\(error.code)] \(error.message)") + sendLogEvent(level: "error", message: "[\(error.code)] \(error.message)") + reject(error.code, error.message, error) + } catch is CancellationError { + NSLog("[SniConnect] ❌ Request cancelled") + sendLogEvent(level: "info", message: "Request cancelled") + reject("SNI_CANCELLED", "Request cancelled", nil) + } catch { + NSLog("[SniConnect] ❌ Request failed: \(error.localizedDescription)") + sendLogEvent(level: "error", message: "Request failed: \(error.localizedDescription)") + reject("SNI_UNKNOWN_ERROR", error.localizedDescription, error) + } + } + } + + @objc + public func cancelRequest( + _ requestId: String, + resolve resolve: @escaping RCTPromiseResolveBlock, + reject reject: @escaping RCTPromiseRejectBlock + ) { + client.cancelRequest(requestId: requestId) + resolve(["success": true]) + } + + @objc + public func cancelAllRequests( + _ resolve: @escaping RCTPromiseResolveBlock, + reject reject: @escaping RCTPromiseRejectBlock + ) { + client.cancelAllRequests() + resolve(["success": true]) + } + + @objc + public func clearDNSCache( + _ resolve: @escaping RCTPromiseResolveBlock, + reject reject: @escaping RCTPromiseRejectBlock + ) { + client.clearDNSCache() + resolve(["success": true]) + } + + private static func parseDictionary(_ dictionary: NSDictionary) throws -> SniConnectClient.RequestConfig { + guard let ip = dictionary["ip"] as? String, !ip.isEmpty else { + throw SniConnectError.invalidConfig("Missing ip") + } + guard let hostname = dictionary["hostname"] as? String, !hostname.isEmpty else { + throw SniConnectError.invalidConfig("Missing hostname") + } + + let requestId = dictionary["requestId"] as? String + let method = (dictionary["method"] as? String)?.uppercased() ?? "GET" + let path = dictionary["path"] as? String ?? "/" + let headers = dictionary["headers"] as? [String: String] ?? [:] + let body = dictionary["body"] as? String + let timeout = dictionary["timeout"] as? NSNumber ?? 30_000 + + // Parse advanced timeout configurations + let connectTimeout = (dictionary["connectTimeout"] as? NSNumber)?.doubleValue + let totalTimeout = (dictionary["totalTimeout"] as? NSNumber)?.doubleValue + + return SniConnectClient.RequestConfig( + requestId: requestId, + ip: ip, + hostname: hostname, + method: method, + path: path, + headers: headers, + body: body, + timeout: timeout.doubleValue, + connectTimeout: connectTimeout, + totalTimeout: totalTimeout + ) + } + + private static func serializeResponseData(_ data: Any) -> String { + if let dict = data as? [String: Any], + let jsonData = try? JSONSerialization.data(withJSONObject: dict, options: []), + let jsonString = String(data: jsonData, encoding: .utf8) { + return jsonString + } + + if let array = data as? [[String: Any]], + let jsonData = try? JSONSerialization.data(withJSONObject: array, options: []), + let jsonString = String(data: jsonData, encoding: .utf8) { + return jsonString + } + + if let stringValue = data as? String { + return stringValue + } + + return String(describing: data) + } +} + diff --git a/native-modules/react-native-sni-connect/ios/SniConnectClient.swift b/native-modules/react-native-sni-connect/ios/SniConnectClient.swift new file mode 100644 index 00000000..5bbe57ca --- /dev/null +++ b/native-modules/react-native-sni-connect/ios/SniConnectClient.swift @@ -0,0 +1,497 @@ +import Foundation +import EMASCurl + +/// Core HTTPS client that enforces IP direct connection with SNI. +final class SniConnectClient { + + // Logging callback + var onLog: ((String, String) -> Void)? + + // Active requests tracking for cancellation support + private var activeTasks: [String: Task] = [:] + private let tasksQueue = DispatchQueue(label: "com.onekey.sni.connect.tasks", attributes: .concurrent) + + init() { + // Register for memory warnings to clean cache + NotificationCenter.default.addObserver( + forName: UIApplication.didReceiveMemoryWarningNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.onLog?("warning", "Memory warning received, cleaning DNS cache") + DNSResolver.cleanExpiredEntries() + } + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + struct RequestConfig { + let requestId: String? // Optional request ID for cancellation + let ip: String + let hostname: String + let method: String + let path: String + let headers: [String: String] + let body: String? + let timeout: TimeInterval + + // Advanced timeout configurations + let connectTimeout: TimeInterval? // Connection establishment timeout + let totalTimeout: TimeInterval? // Total request timeout (overrides `timeout`) + + var effectiveConnectTimeout: TimeInterval { + connectTimeout ?? min(timeout / 3, 10.0) + } + + var effectiveTotalTimeout: TimeInterval { + totalTimeout ?? timeout + } + } + + struct Response { + let data: Any + let status: Int + let statusText: String + let headers: [String: String] // Single-value headers (backward compatible) + let multiValueHeaders: [String: [String]] // Multi-value headers (e.g., Set-Cookie) + } + + enum SniConnectError: Error { + case invalidURL + case invalidConfig(String) + case dnsResolutionFailed(String) + case tlsHandshakeFailed(String) + case certificateValidationFailed(String) + case connectionTimeout + case connectionRefused + case networkUnreachable + case requestTimeout + case httpError(code: Int, message: String) + case cancelled + case unknown(Error) + + var code: String { + switch self { + case .invalidURL: return "SNI_INVALID_URL" + case .invalidConfig: return "SNI_INVALID_CONFIG" + case .dnsResolutionFailed: return "SNI_DNS_FAILED" + case .tlsHandshakeFailed: return "SNI_TLS_FAILED" + case .certificateValidationFailed: return "SNI_CERT_FAILED" + case .connectionTimeout: return "SNI_TIMEOUT" + case .connectionRefused: return "SNI_CONNECTION_REFUSED" + case .networkUnreachable: return "SNI_NETWORK_UNREACHABLE" + case .requestTimeout: return "SNI_REQUEST_TIMEOUT" + case .httpError: return "SNI_HTTP_ERROR" + case .cancelled: return "SNI_CANCELLED" + case .unknown: return "SNI_UNKNOWN_ERROR" + } + } + + var message: String { + switch self { + case .invalidURL: + return "Invalid URL format" + case .invalidConfig(let details): + return "Invalid configuration: \(details)" + case .dnsResolutionFailed(let domain): + return "DNS resolution failed for domain: \(domain)" + case .tlsHandshakeFailed(let details): + return "TLS handshake failed: \(details)" + case .certificateValidationFailed(let details): + return "Certificate validation failed: \(details)" + case .connectionTimeout: + return "Connection timeout" + case .connectionRefused: + return "Connection refused by server" + case .networkUnreachable: + return "Network unreachable" + case .requestTimeout: + return "Request timeout" + case .httpError(let code, let message): + return "HTTP error \(code): \(message)" + case .cancelled: + return "Request cancelled" + case .unknown(let error): + return "Unknown error: \(error.localizedDescription)" + } + } + + /// Convert NSError to SniConnectError with detailed classification + static func from(_ error: Error) -> SniConnectError { + let nsError = error as NSError + + // Check for URL-related errors + if nsError.domain == NSURLErrorDomain { + switch nsError.code { + case NSURLErrorTimedOut: + return .requestTimeout + case NSURLErrorCannotConnectToHost: + return .connectionRefused + case NSURLErrorNotConnectedToInternet, NSURLErrorNetworkConnectionLost: + return .networkUnreachable + case NSURLErrorSecureConnectionFailed: + return .tlsHandshakeFailed(nsError.localizedDescription) + case NSURLErrorServerCertificateUntrusted, NSURLErrorServerCertificateHasBadDate, + NSURLErrorServerCertificateHasUnknownRoot, NSURLErrorServerCertificateNotYetValid: + return .certificateValidationFailed(nsError.localizedDescription) + case NSURLErrorCannotFindHost, NSURLErrorDNSLookupFailed: + return .dnsResolutionFailed(nsError.localizedDescription) + case NSURLErrorCancelled: + return .cancelled + default: + return .unknown(error) + } + } + + return .unknown(error) + } + } + + private static let urlSession: URLSession = { + let configuration = URLSessionConfiguration.default + configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + configuration.urlCache = nil + configuration.httpCookieStorage = nil + configuration.httpShouldSetCookies = false + configuration.shouldUseExtendedBackgroundIdleMode = false + + let curlConfig = EMASCurlConfiguration.default() + curlConfig.httpVersion = .HTTP2 + curlConfig.connectTimeoutInterval = 2.5 + curlConfig.enableBuiltInGzip = true + curlConfig.enableBuiltInRedirection = true + curlConfig.cacheEnabled = false + + // Enable full certificate validation for security + // The certificate will be validated against the SNI hostname, not the IP + curlConfig.certificateValidationEnabled = true + curlConfig.domainNameVerificationEnabled = true + curlConfig.dnsResolver = DNSResolver.self + + EMASCurlProtocol.install(into: configuration, with: curlConfig) + return URLSession(configuration: configuration) + }() + + @objc private final class DNSResolver: NSObject, EMASCurlProtocolDNSResolver { + private static let queue = DispatchQueue(label: "com.onekey.sni.connect.dns", attributes: .concurrent) + private static let cache = DNSCache() + + /// LRU cache for DNS mappings with size limit and TTL support + /// Uses hostname:IP as cache key to ensure different IPs for the same hostname are isolated + private class DNSCache { + private struct CacheEntry { + let hostname: String + let ip: String + let timestamp: TimeInterval + } + + private var storage: [String: CacheEntry] = [:] + private var accessOrder: [String] = [] + + // Mapping from hostname to IP for DNS resolution + // This stores the most recently set IP for each hostname + private var hostnameToIP: [String: String] = [:] + + // Maximum cache entries (100 as specified in requirements) + private let maxSize = 100 + // TTL in seconds (5 minutes default) + private let ttl: TimeInterval = 300 + + /// Generate cache key using hostname:IP format + /// This ensures different IPs for the same hostname are isolated (accurate speed testing) + private func makeCacheKey(hostname: String, ip: String) -> String { + return "\(hostname.lowercased()):\(ip)" + } + + func get(_ domain: String) -> String? { + let normalizedDomain = domain.lowercased() + + // Return the most recently set IP for this hostname + return hostnameToIP[normalizedDomain] + } + + func set(_ ip: String, for domain: String) { + let normalizedDomain = domain.lowercased() + let key = makeCacheKey(hostname: normalizedDomain, ip: ip) + + // Update hostname to IP mapping + hostnameToIP[normalizedDomain] = ip + + // Evict oldest entry if cache is full + if storage.count >= maxSize && storage[key] == nil { + if let oldest = accessOrder.first { + storage.removeValue(forKey: oldest) + accessOrder.removeFirst() + } + } + + // Add or update entry + let entry = CacheEntry(hostname: normalizedDomain, ip: ip, timestamp: Date().timeIntervalSince1970) + storage[key] = entry + + // Update access order + if let index = accessOrder.firstIndex(of: key) { + accessOrder.remove(at: index) + } + accessOrder.append(key) + } + + func remove(_ key: String) { + storage.removeValue(forKey: key) + if let index = accessOrder.firstIndex(of: key) { + accessOrder.remove(at: index) + } + } + + func clear() { + storage.removeAll() + accessOrder.removeAll() + hostnameToIP.removeAll() + } + + func cleanExpired() { + let now = Date().timeIntervalSince1970 + let expiredKeys = storage.filter { now - $0.value.timestamp > ttl }.map { $0.key } + for key in expiredKeys { + remove(key) + // Also remove from hostnameToIP if this was the active mapping + if let entry = storage[key], hostnameToIP[entry.hostname] == entry.ip { + hostnameToIP.removeValue(forKey: entry.hostname) + } + } + } + } + + @objc static func resolveDomain(_ domain: String) -> String? { + var result: String? + queue.sync { + result = cache.get(domain) + } + return result + } + + static func setIP(_ ip: String, for host: String) { + queue.sync(flags: .barrier) { + cache.set(ip, for: host) + } + } + + /// Clear all DNS cache entries + static func clearCache() { + queue.sync(flags: .barrier) { + cache.clear() + } + } + + /// Clean expired DNS cache entries + static func cleanExpiredEntries() { + queue.sync(flags: .barrier) { + cache.cleanExpired() + } + } + } + + /// Clear all DNS cache entries + func clearDNSCache() { + DNSResolver.clearCache() + onLog?("info", "DNS cache cleared") + } + + /// Cancel a request by ID + func cancelRequest(requestId: String) { + tasksQueue.async(flags: .barrier) { [weak self] in + guard let task = self?.activeTasks[requestId] else { + self?.onLog?("warning", "No active request found with ID: \(requestId)") + return + } + task.cancel() + self?.activeTasks.removeValue(forKey: requestId) + self?.onLog?("info", "Request cancelled: \(requestId)") + } + } + + /// Cancel all active requests + func cancelAllRequests() { + tasksQueue.async(flags: .barrier) { [weak self] in + guard let self = self else { return } + let count = self.activeTasks.count + for (_, task) in self.activeTasks { + task.cancel() + } + self.activeTasks.removeAll() + self.onLog?("info", "Cancelled \(count) active requests") + } + } + + /// Register an active task (synchronous for immediate registration) + func registerTask(_ task: Task, for requestId: String) { + tasksQueue.sync(flags: .barrier) { [weak self] in + self?.activeTasks[requestId] = task + self?.onLog?("info", "Registered request: \(requestId), total active: \(self?.activeTasks.count ?? 0)") + } + } + + /// Unregister a completed or failed task + private func unregisterTask(requestId: String?) { + guard let requestId = requestId else { return } + tasksQueue.async(flags: .barrier) { [weak self] in + self?.activeTasks.removeValue(forKey: requestId) + self?.onLog?("info", "Unregistered request: \(requestId)") + } + } + + func performRequest(config: RequestConfig) async throws -> Response { + // Check if task is cancelled + try Task.checkCancellation() + + defer { + unregisterTask(requestId: config.requestId) + } + + DNSResolver.setIP(config.ip, for: config.hostname) + + let url = try Self.buildURL(hostname: config.hostname, path: config.path) + + let mutableRequest = NSMutableURLRequest(url: url) + mutableRequest.httpMethod = config.method.uppercased() + + // Convert milliseconds to seconds for timeout values + let totalTimeoutSeconds = config.effectiveTotalTimeout / 1000.0 + let connectTimeoutSeconds = config.effectiveConnectTimeout / 1000.0 + + // Set total request timeout + mutableRequest.timeoutInterval = totalTimeoutSeconds + mutableRequest.cachePolicy = .reloadIgnoringLocalCacheData + + // Explicitly set Host header for SNI + mutableRequest.setValue(config.hostname, forHTTPHeaderField: "Host") + + for (key, value) in config.headers { + if key.caseInsensitiveCompare("host") == .orderedSame { + // Host header is already set above, skip duplicate + continue + } + mutableRequest.setValue(value, forHTTPHeaderField: key) + } + + if let bodyString = config.body, let bodyData = bodyString.data(using: .utf8) { + mutableRequest.httpBody = bodyData + } + + // Configure connection timeout for EMASCurl + EMASCurlProtocol.setConnectTimeoutInterval(connectTimeoutSeconds) + let request = mutableRequest as URLRequest + + do { + let (data, response) = try await Self.urlSession.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + let errorMsg = "Invalid HTTP response type" + onLog?("error", errorMsg) + throw SniConnectError.invalidConfig(errorMsg) + } + + let status = httpResponse.statusCode + let parsedData = Self.parseResponseData(data) + let (headers, multiValueHeaders) = Self.extractHeaders(from: httpResponse) + let statusText = HTTPURLResponse.localizedString(forStatusCode: status) + + // Check for HTTP errors (4xx, 5xx) + if status >= 400 { + let error = SniConnectError.httpError(code: status, message: statusText) + onLog?("error", "HTTP error: \(error.message)") + // Still return the response for client to handle + } + + return Response( + data: parsedData, + status: status, + statusText: statusText, + headers: headers, + multiValueHeaders: multiValueHeaders + ) + } catch let error as SniConnectError { + onLog?("error", "[\(error.code)] \(error.message)") + throw error + } catch { + // Convert generic errors to specific SniConnectError types + let sniError = SniConnectError.from(error) + onLog?("error", "[\(sniError.code)] \(sniError.message)") + throw sniError + } + } + + private static func buildURL(hostname: String, path: String) throws -> URL { + let trimmedPath = path.trimmingCharacters(in: .whitespacesAndNewlines) + + if let url = URL(string: trimmedPath), url.scheme != nil { + return url + } + + let normalizedPath: String + if trimmedPath.isEmpty { + normalizedPath = "/" + } else if trimmedPath.hasPrefix("/") { + normalizedPath = trimmedPath + } else { + normalizedPath = "/" + trimmedPath + } + + guard let url = URL(string: "https://\(hostname)\(normalizedPath)") else { + throw SniConnectError.invalidURL + } + return url + } + + private static func parseResponseData(_ data: Data) -> Any { + guard !data.isEmpty else { + return [:] + } + + if let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []) { + return jsonObject + } + + if let text = String(data: data, encoding: .utf8) { + return text + } + + return data.base64EncodedString() + } + + /// Extract headers from HTTP response + /// Returns both single-value headers (for backward compatibility) and multi-value headers + private static func extractHeaders(from response: HTTPURLResponse) -> ([String: String], [String: [String]]) { + var singleValueHeaders: [String: String] = [:] + var multiValueHeaders: [String: [String]] = [:] + + // Group headers by normalized key (lowercase) + var headerGroups: [String: [String]] = [:] + + for (key, value) in response.allHeaderFields { + let headerKey = String(describing: key).lowercased() + let headerValue = String(describing: value) + + if headerGroups[headerKey] == nil { + headerGroups[headerKey] = [] + } + headerGroups[headerKey]?.append(headerValue) + } + + // Process grouped headers + for (key, values) in headerGroups { + // For backward compatibility, single-value headers use the last value + singleValueHeaders[key] = values.last + + // Multi-value headers contain all values + if values.count > 1 { + multiValueHeaders[key] = values + } else { + multiValueHeaders[key] = values + } + } + + return (singleValueHeaders, multiValueHeaders) + } +} diff --git a/native-modules/react-native-sni-connect/package.json b/native-modules/react-native-sni-connect/package.json new file mode 100644 index 00000000..f934dfa3 --- /dev/null +++ b/native-modules/react-native-sni-connect/package.json @@ -0,0 +1,157 @@ +{ + "name": "@onekeyfe/react-native-sni-connect", + "version": "3.0.66", + "description": "A React Native library for SNI-based HTTP requests with DNS caching and request management", + "main": "./lib/module/index.js", + "types": "./lib/typescript/src/index.d.ts", + "exports": { + ".": { + "source": "./src/index.tsx", + "types": "./lib/typescript/src/index.d.ts", + "default": "./lib/module/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "src", + "lib", + "android", + "ios", + "*.podspec", + "!ios/build", + "!android/build", + "!android/gradle", + "!android/gradlew", + "!android/gradlew.bat", + "!android/local.properties", + "!**/__tests__", + "!**/__fixtures__", + "!**/__mocks__", + "!**/.*", + "!**/*.map" + ], + "scripts": { + "clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib", + "prepare": "bob build", + "typecheck": "tsc", + "lint": "eslint \"**/*.{js,ts,tsx}\"", + "test": "jest", + "release": "yarn prepare && npm whoami && npm publish --access public" + }, + "keywords": [ + "react-native", + "ios", + "android" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/OneKeyHQ/app-modules/react-native-sni-connect.git" + }, + "author": "@onekeyhq (https://github.com/OneKeyHQ/app-modules)", + "license": "MIT", + "bugs": { + "url": "https://github.com/OneKeyHQ/app-modules/react-native-sni-connect/issues" + }, + "homepage": "https://github.com/OneKeyHQ/app-modules/react-native-sni-connect#readme", + "publishConfig": { + "registry": "https://registry.npmjs.org/" + }, + "devDependencies": { + "@commitlint/config-conventional": "^19.8.1", + "@eslint/compat": "^1.3.2", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "^9.35.0", + "@react-native/babel-preset": "0.83.0", + "@react-native/eslint-config": "0.83.0", + "@release-it/conventional-changelog": "^10.0.1", + "@types/jest": "^29.5.14", + "@types/react": "^19.2.0", + "commitlint": "^19.8.1", + "del-cli": "^6.0.0", + "eslint": "^9.35.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.4", + "jest": "^29.7.0", + "lefthook": "^2.0.3", + "prettier": "^2.8.8", + "react": "19.2.0", + "react-native": "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch", + "react-native-builder-bob": "^0.40.17", + "release-it": "^19.0.4", + "turbo": "^2.5.6", + "typescript": "^5.9.2" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + }, + "react-native-builder-bob": { + "source": "src", + "output": "lib", + "targets": [ + [ + "module", + { + "esm": true + } + ], + [ + "typescript", + { + "project": "tsconfig.build.json" + } + ] + ] + }, + "codegenConfig": { + "name": "SniConnectSpec", + "type": "modules", + "jsSrcsDir": "src", + "android": { + "javaPackageName": "com.sniconnect" + } + }, + "prettier": { + "quoteProps": "consistent", + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "useTabs": false + }, + "jest": { + "preset": "react-native", + "modulePathIgnorePatterns": [ + "/example/node_modules", + "/lib/" + ] + }, + "commitlint": { + "extends": [ + "@commitlint/config-conventional" + ] + }, + "release-it": { + "git": { + "commitMessage": "chore: release ${version}", + "tagName": "v${version}" + }, + "npm": { + "publish": true + }, + "github": { + "release": true + }, + "plugins": { + "@release-it/conventional-changelog": { + "preset": { + "name": "angular" + } + } + } + }, + "create-react-native-library": { + "languages": "kotlin-objc", + "type": "turbo-module", + "version": "0.54.8" + } +} diff --git a/native-modules/react-native-sni-connect/scripts/block-ips.sh b/native-modules/react-native-sni-connect/scripts/block-ips.sh new file mode 100755 index 00000000..e11b2240 --- /dev/null +++ b/native-modules/react-native-sni-connect/scripts/block-ips.sh @@ -0,0 +1,44 @@ +#!/bin/bash + +# 配置要屏蔽的 IP 列表 +BLOCKED_IPS=( + # "216.19.4.106" + "104.18.31.39" + # 在这里添加更多 IP +) + +# pf anchor 文件路径 +PF_ANCHOR_FILE="/etc/pf.anchors/block_ips" +PF_CONF="/etc/pf.conf" + +# 检查是否有 sudo 权限 +if [ "$EUID" -ne 0 ]; then + echo "❌ 需要 sudo 权限运行此脚本" + echo "请使用: sudo $0" + exit 1 +fi + +echo "📝 正在生成 pf 规则..." + +# 生成规则文件 +> "$PF_ANCHOR_FILE" # 清空文件 + +for ip in "${BLOCKED_IPS[@]}"; do + echo "block drop out quick from any to $ip" >> "$PF_ANCHOR_FILE" + echo " ✓ 添加屏蔽规则: $ip" +done + +echo "" +echo "📋 生成的规则文件内容:" +cat "$PF_ANCHOR_FILE" + +echo "" +echo "🔄 重新加载 pf 配置..." +pfctl -f "$PF_CONF" 2>&1 | grep -v "Use of -f option" + +echo "" +echo "✅ 当前生效的规则:" +pfctl -a block_ips -s rules 2>&1 | grep -v "ALTQ" + +echo "" +echo "🎉 完成!已屏蔽 ${#BLOCKED_IPS[@]} 个 IP" diff --git a/native-modules/react-native-sni-connect/scripts/sni-request.js b/native-modules/react-native-sni-connect/scripts/sni-request.js new file mode 100644 index 00000000..21f512c5 --- /dev/null +++ b/native-modules/react-native-sni-connect/scripts/sni-request.js @@ -0,0 +1,74 @@ +/** + * SNI Direct IP Connection Test Script + * + * This script demonstrates how to make HTTPS requests using: + * - Direct IP connection (bypassing DNS) + * - SNI (Server Name Indication) with domain name + * - Custom headers for API authentication + */ + +const https = require('https'); + +const options = { + host: '104.18.31.39', // Direct IP connection + // host: '216.19.4.106', + port: 443, + path: '/wallet/v1/account/validate-address?networkId=btc--0&accountAddress=bc1qezh467l5gwkk72v2dx6yj488hlpad8d34u6z2j', + method: 'GET', + servername: 'wallet.onekeytest.com', // CRITICAL: SNI must use domain name for TLS handshake + headers: { + 'Host': 'wallet.onekeytest.com', + 'X-Onekey-Request-ID': 'cc740bab-7cbb-412f-9d9a-1d7b515f601d', + 'X-Onekey-Request-Currency': 'usd', + 'X-Onekey-Request-Locale': 'zh-cn', + 'X-Onekey-Request-Theme': 'light', + 'X-Onekey-Request-Platform': 'android-apk', + 'X-Onekey-Request-Version': '5.16.0', + 'X-Onekey-Request-Build-Number': '2000000000', + 'X-Onekey-Request-Token': 'eyJhbGciOi...', // Truncated token for security + 'X-Onekey-Request-Currency-Value': '1.0', + 'X-Onekey-Instance-Id': '67848a28-b89c-4e0b-8c0f-b87824480d6a', + 'x-onekey-wallet-type': 'hd', + 'x-onekey-hide-asset-details': 'false', + }, +}; + +console.log('🚀 Starting SNI test...'); +console.log(`📡 Connecting to: ${options.host}:${options.port}`); +console.log(`🔐 SNI servername: ${options.servername}`); +console.log(''); + +const req = https.request(options, (res) => { + console.log('✅ Connection successful!'); + console.log(`📊 Status Code: ${res.statusCode}`); + console.log('📋 Response Headers:', JSON.stringify(res.headers, null, 2)); + console.log(''); + + res.setEncoding('utf8'); + let body = ''; + + res.on('data', (chunk) => { + body += chunk; + }); + + res.on('end', () => { + console.log('📦 Response Body:'); + try { + const parsed = JSON.parse(body); + console.log(JSON.stringify(parsed, null, 2)); + } catch (e) { + console.log(body); + } + }); +}); + +req.on('error', (e) => { + console.error('❌ Request failed:', e.message); + console.error('💡 Possible causes:'); + console.error(' - Network connectivity issues'); + console.error(' - Invalid IP address or port'); + console.error(' - SNI configuration mismatch'); + console.error(' - Certificate validation failure'); +}); + +req.end(); diff --git a/native-modules/react-native-sni-connect/scripts/verify-sni.sh b/native-modules/react-native-sni-connect/scripts/verify-sni.sh new file mode 100755 index 00000000..384f388d --- /dev/null +++ b/native-modules/react-native-sni-connect/scripts/verify-sni.sh @@ -0,0 +1,84 @@ +#!/bin/bash + +# SNI Direct IP Connection Verification Tool +# This script captures network traffic to verify that: +# 1. Direct IP connection is established +# 2. SNI (Server Name Indication) is correctly sent during TLS handshake + +# Configuration +TARGET_IP="104.18.31.39" +TARGET_PORT="443" +SNI_HOSTNAME="wallet.onekeytest.com" + +echo "🔍 SNI Direct IP Connection Verification Tool" +echo "==============================================" +echo "" +echo "Target IP: $TARGET_IP:$TARGET_PORT" +echo "SNI Lookup: $SNI_HOSTNAME" +echo "" +echo "⚠️ Prerequisites:" +echo " 1. Your application is running" +echo " 2. Ready to trigger the 'Direct IP Connection' feature" +echo " 3. sudo permission is required for packet capture" +echo "" + +# Check for sudo permission early +if ! sudo -v &>/dev/null; then + echo "❌ Error: sudo permission is required to run tcpdump" + echo "💡 Please run this script with appropriate permissions" + exit 1 +fi + +echo "Press Enter to start monitoring..." +read + +echo "" +echo "🎯 Packet capture started... (Press Ctrl+C to stop)" +echo "==============================================" +echo "" + +# Packet counter +count=0 + +# Capture and analyze packets +# Using -X for hex+ASCII output to better capture binary TLS data +# Using -s 0 to capture full packets (SNI is in early handshake) +sudo tcpdump -i any -n -X -s 0 "host $TARGET_IP and port $TARGET_PORT" 2>/dev/null | while read -r line; do + # Detect TCP connection initiation (SYN) + if [[ $line == *"Flags [S]"* ]] && [[ $line == *"$TARGET_IP.$TARGET_PORT"* ]]; then + echo "✅ [TCP] Connection initiated to $TARGET_IP:$TARGET_PORT" + echo "" + fi + + # Detect TCP connection response (SYN-ACK) + if [[ $line == *"Flags [S.]"* ]] && [[ $line == *"$TARGET_IP.$TARGET_PORT"* ]]; then + echo "✅ [TCP] Server acknowledged connection" + echo "" + fi + + # Detect SNI field in TLS handshake (both ASCII and hex patterns) + if [[ $line == *"$SNI_HOSTNAME"* ]]; then + count=$((count + 1)) + echo "" + echo "🎉 [TLS] SNI detected (#$count): $SNI_HOSTNAME" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " ✓ Direct IP Connection + SNI VERIFIED!" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + fi + + # Display packet summary with timestamps (reduced verbosity) + if [[ $line =~ ^[0-9]{2}:[0-9]{2}:[0-9]{2} ]]; then + if [[ $line == *"> $TARGET_IP.$TARGET_PORT"* ]]; then + # Only show initial request packets to reduce noise + if [[ $line == *"Flags [P.]"* ]] || [[ $line == *"Flags [S]"* ]]; then + echo "→ [SENT] $line" + fi + elif [[ $line == *"$TARGET_IP.$TARGET_PORT >"* ]]; then + # Only show response packets with data + if [[ $line == *"Flags [P.]"* ]] || [[ $line == *"Flags [S.]"* ]]; then + echo "← [RECV] $line" + fi + fi + fi +done diff --git a/native-modules/react-native-sni-connect/src/@types/react-native-codegen.d.ts b/native-modules/react-native-sni-connect/src/@types/react-native-codegen.d.ts new file mode 100644 index 00000000..6bc99af4 --- /dev/null +++ b/native-modules/react-native-sni-connect/src/@types/react-native-codegen.d.ts @@ -0,0 +1,8 @@ +declare module 'react-native/Libraries/Types/CodegenTypes' { + export type Int32 = number; + export type Double = number; + export type Float = number; + export type UnsafeObject = Record; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + export type WithDefault = Type; +} diff --git a/native-modules/react-native-sni-connect/src/NativeSniConnect.ts b/native-modules/react-native-sni-connect/src/NativeSniConnect.ts new file mode 100644 index 00000000..f009c50d --- /dev/null +++ b/native-modules/react-native-sni-connect/src/NativeSniConnect.ts @@ -0,0 +1,70 @@ +import { + NativeModules, + Platform, + TurboModuleRegistry, + type TurboModule, +} from 'react-native'; +import type { Double, Int32 } from 'react-native/Libraries/Types/CodegenTypes'; + +type HeaderMap = { + [key: string]: string; +}; + +export type SniConnectRequest = { + requestId?: string; + ip: string; + hostname: string; + method: string; + path: string; + headers: HeaderMap; + body?: string | null; + timeout: Double; +}; + +export type SniConnectResponse = { + data: string; + status: Int32; + statusText: string; + headers: HeaderMap; +}; + +export interface Spec extends TurboModule { + request(config: SniConnectRequest): Promise; + cancelRequest(requestId: string): Promise<{ success: boolean }>; + cancelAllRequests(): Promise<{ success: boolean }>; + clearDNSCache(): Promise<{ success: boolean }>; + + // Event emitter methods required for NativeEventEmitter + addListener(eventType: string): void; + removeListeners(count: Int32): void; +} + +const LINKING_ERROR = + `The package '@onekeyfe/react-native-sni-connect' doesn't seem to be linked. Make sure:\n\n` + + Platform.select({ ios: "- You have run 'pod install'\n", default: '' }) + + '- You rebuilt the app after installing the package\n' + + '- You are not using Expo Go; create a custom dev client instead\n'; + +export type SniConnectModule = Spec & { + request(config: SniConnectRequest): Promise; + cancelRequest(requestId: string): Promise<{ success: boolean }>; + cancelAllRequests(): Promise<{ success: boolean }>; + clearDNSCache(): Promise<{ success: boolean }>; +}; + +const turboModuleResult = TurboModuleRegistry.get('SniConnect'); + +const turboModule: Spec | null = turboModuleResult ?? null; + +const bridgeModule: Spec | null = + (NativeModules.SniConnect as Spec | undefined) ?? null; + +const nativeModule = (turboModule ?? bridgeModule) as SniConnectModule | null; + +if (nativeModule == null) { + throw new Error(LINKING_ERROR); +} + +const NativeSniConnect: SniConnectModule = nativeModule; + +export default NativeSniConnect; diff --git a/native-modules/react-native-sni-connect/src/__tests__/index.test.ts b/native-modules/react-native-sni-connect/src/__tests__/index.test.ts new file mode 100644 index 00000000..f90cd7b3 --- /dev/null +++ b/native-modules/react-native-sni-connect/src/__tests__/index.test.ts @@ -0,0 +1,435 @@ +import { NativeModules } from 'react-native'; +import { + request, + cancelRequest, + cancelAllRequests, + clearDNSCache, + subscribeToLogs, + type LogEntry, + type SniConnectRequest, + type SniConnectResponse, +} from '../index'; + +// Mock React Native modules +jest.mock('react-native', () => ({ + NativeModules: { + SniConnect: { + request: jest.fn(), + cancelRequest: jest.fn(), + cancelAllRequests: jest.fn(), + clearDNSCache: jest.fn(), + addListener: jest.fn(), + removeListeners: jest.fn(), + }, + }, + NativeEventEmitter: jest.fn().mockImplementation(() => ({ + addListener: jest.fn(() => ({ + remove: jest.fn(), + })), + })), + Platform: { + select: jest.fn((obj) => obj.default), + }, + TurboModuleRegistry: { + get: jest.fn(() => null), + }, +})); + +describe('SniConnect Module', () => { + const mockNativeModule = NativeModules.SniConnect; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('request()', () => { + it('should call native request method with correct config', async () => { + const config: SniConnectRequest = { + ip: '1.1.1.1', + hostname: 'example.com', + method: 'GET', + path: '/api/test', + headers: { Authorization: 'Bearer token' }, + timeout: 30000, + }; + + const mockResponse: SniConnectResponse = { + data: '{"success": true}', + status: 200, + statusText: 'OK', + headers: { 'content-type': 'application/json' }, + }; + + mockNativeModule.request.mockResolvedValue(mockResponse); + + const response = await request(config); + + expect(mockNativeModule.request).toHaveBeenCalledWith(config); + expect(response).toEqual(mockResponse); + }); + + it('should handle request with requestId', async () => { + const config: SniConnectRequest = { + requestId: 'test-123', + ip: '1.1.1.1', + hostname: 'example.com', + method: 'POST', + path: '/api/data', + headers: {}, + body: '{"key": "value"}', + timeout: 30000, + }; + + mockNativeModule.request.mockResolvedValue({ + data: '', + status: 201, + statusText: 'Created', + headers: {}, + }); + + await request(config); + + expect(mockNativeModule.request).toHaveBeenCalledWith( + expect.objectContaining({ + requestId: 'test-123', + }) + ); + }); + + it('should handle request errors', async () => { + const config: SniConnectRequest = { + ip: '1.1.1.1', + hostname: 'example.com', + method: 'GET', + path: '/api/test', + headers: {}, + timeout: 30000, + }; + + const error = new Error('Network error'); + mockNativeModule.request.mockRejectedValue(error); + + await expect(request(config)).rejects.toThrow('Network error'); + }); + + it('should handle different HTTP methods', async () => { + const methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD']; + + for (const method of methods) { + mockNativeModule.request.mockResolvedValue({ + data: '', + status: 200, + statusText: 'OK', + headers: {}, + }); + + const config: SniConnectRequest = { + ip: '1.1.1.1', + hostname: 'example.com', + method, + path: '/test', + headers: {}, + timeout: 30000, + }; + + await request(config); + expect(mockNativeModule.request).toHaveBeenCalledWith( + expect.objectContaining({ method }) + ); + } + }); + }); + + describe('cancelRequest()', () => { + it('should call native cancelRequest with requestId', async () => { + mockNativeModule.cancelRequest.mockResolvedValue({ success: true }); + + const result = await cancelRequest('test-request-123'); + + expect(mockNativeModule.cancelRequest).toHaveBeenCalledWith( + 'test-request-123' + ); + expect(result).toEqual({ success: true }); + }); + + it('should handle cancellation failure', async () => { + mockNativeModule.cancelRequest.mockResolvedValue({ success: false }); + + const result = await cancelRequest('non-existent'); + + expect(result.success).toBe(false); + }); + }); + + describe('cancelAllRequests()', () => { + it('should call native cancelAllRequests', async () => { + mockNativeModule.cancelAllRequests.mockResolvedValue({ success: true }); + + const result = await cancelAllRequests(); + + expect(mockNativeModule.cancelAllRequests).toHaveBeenCalled(); + expect(result).toEqual({ success: true }); + }); + }); + + describe('clearDNSCache()', () => { + it('should call native clearDNSCache', async () => { + mockNativeModule.clearDNSCache.mockResolvedValue({ success: true }); + + const result = await clearDNSCache(); + + expect(mockNativeModule.clearDNSCache).toHaveBeenCalled(); + expect(result).toEqual({ success: true }); + }); + }); + + describe('subscribeToLogs()', () => { + it('should subscribe to log events and return unsubscribe function', () => { + const mockCallback = jest.fn(); + + const unsubscribe = subscribeToLogs(mockCallback); + + // Should return a function + expect(typeof unsubscribe).toBe('function'); + + // Unsubscribe should not throw + expect(() => unsubscribe()).not.toThrow(); + }); + + it('should call callback when log event is received', () => { + const mockCallback = jest.fn(); + + subscribeToLogs(mockCallback); + + // The actual callback invocation would be triggered by native module + // In unit tests, we're just verifying the subscription was set up correctly + expect(mockCallback).not.toHaveBeenCalled(); // Not called immediately + }); + + it('should handle log entry type correctly', () => { + // Type checking test + const logEntry: LogEntry = { + level: 'info', + message: 'Test log message', + timestamp: Date.now(), + }; + + expect(logEntry).toBeDefined(); + expect(logEntry.level).toBe('info'); + expect(logEntry.message).toBe('Test log message'); + expect(typeof logEntry.timestamp).toBe('number'); + }); + }); + + describe('Request Configuration', () => { + it('should handle empty headers', async () => { + mockNativeModule.request.mockResolvedValue({ + data: '', + status: 200, + statusText: 'OK', + headers: {}, + }); + + const config: SniConnectRequest = { + ip: '1.1.1.1', + hostname: 'example.com', + method: 'GET', + path: '/test', + headers: {}, + timeout: 30000, + }; + + await request(config); + expect(mockNativeModule.request).toHaveBeenCalledWith(config); + }); + + it('should handle custom headers', async () => { + mockNativeModule.request.mockResolvedValue({ + data: '', + status: 200, + statusText: 'OK', + headers: {}, + }); + + const config: SniConnectRequest = { + ip: '1.1.1.1', + hostname: 'example.com', + method: 'GET', + path: '/test', + headers: { + 'Authorization': 'Bearer token', + 'X-Custom-Header': 'custom-value', + 'Content-Type': 'application/json', + }, + timeout: 30000, + }; + + await request(config); + expect(mockNativeModule.request).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + 'Authorization': 'Bearer token', + 'X-Custom-Header': 'custom-value', + }), + }) + ); + }); + + it('should handle request body', async () => { + mockNativeModule.request.mockResolvedValue({ + data: '', + status: 200, + statusText: 'OK', + headers: {}, + }); + + const config: SniConnectRequest = { + ip: '1.1.1.1', + hostname: 'example.com', + method: 'POST', + path: '/api/data', + headers: {}, + body: JSON.stringify({ key: 'value', number: 123 }), + timeout: 30000, + }; + + await request(config); + expect(mockNativeModule.request).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.stringContaining('key'), + }) + ); + }); + + it('should handle different timeout values', async () => { + mockNativeModule.request.mockResolvedValue({ + data: '', + status: 200, + statusText: 'OK', + headers: {}, + }); + + const timeouts = [1000, 5000, 30000, 60000]; + + for (const timeout of timeouts) { + const config: SniConnectRequest = { + ip: '1.1.1.1', + hostname: 'example.com', + method: 'GET', + path: '/test', + headers: {}, + timeout, + }; + + await request(config); + expect(mockNativeModule.request).toHaveBeenCalledWith( + expect.objectContaining({ timeout }) + ); + } + }); + }); + + describe('Response Handling', () => { + it('should handle JSON response', async () => { + const mockResponse: SniConnectResponse = { + data: JSON.stringify({ result: 'success', count: 42 }), + status: 200, + statusText: 'OK', + headers: { 'content-type': 'application/json' }, + }; + + mockNativeModule.request.mockResolvedValue(mockResponse); + + const response = await request({ + ip: '1.1.1.1', + hostname: 'example.com', + method: 'GET', + path: '/test', + headers: {}, + timeout: 30000, + }); + + expect(response.data).toContain('success'); + expect(response.headers['content-type']).toBe('application/json'); + }); + + it('should handle plain text response', async () => { + const mockResponse: SniConnectResponse = { + data: 'Plain text response', + status: 200, + statusText: 'OK', + headers: { 'content-type': 'text/plain' }, + }; + + mockNativeModule.request.mockResolvedValue(mockResponse); + + const response = await request({ + ip: '1.1.1.1', + hostname: 'example.com', + method: 'GET', + path: '/test', + headers: {}, + timeout: 30000, + }); + + expect(response.data).toBe('Plain text response'); + }); + + it('should handle different status codes', async () => { + const statusCodes = [200, 201, 204, 400, 404, 500]; + + for (const status of statusCodes) { + mockNativeModule.request.mockResolvedValue({ + data: '', + status, + statusText: 'Status', + headers: {}, + }); + + const response = await request({ + ip: '1.1.1.1', + hostname: 'example.com', + method: 'GET', + path: '/test', + headers: {}, + timeout: 30000, + }); + + expect(response.status).toBe(status); + } + }); + }); + + describe('Type Exports', () => { + it('should export LogEntry type', () => { + const log: LogEntry = { + level: 'info', + message: 'test', + timestamp: Date.now(), + }; + expect(log).toBeDefined(); + }); + + it('should export SniConnectRequest type', () => { + const req: SniConnectRequest = { + ip: '1.1.1.1', + hostname: 'example.com', + method: 'GET', + path: '/', + headers: {}, + timeout: 30000, + }; + expect(req).toBeDefined(); + }); + + it('should export SniConnectResponse type', () => { + const res: SniConnectResponse = { + data: '', + status: 200, + statusText: 'OK', + headers: {}, + }; + expect(res).toBeDefined(); + }); + }); +}); diff --git a/native-modules/react-native-sni-connect/src/index.tsx b/native-modules/react-native-sni-connect/src/index.tsx new file mode 100644 index 00000000..46efc80d --- /dev/null +++ b/native-modules/react-native-sni-connect/src/index.tsx @@ -0,0 +1,46 @@ +import { NativeEventEmitter } from 'react-native'; +import NativeSniConnect, { + type SniConnectRequest, + type SniConnectResponse, +} from './NativeSniConnect'; + +// Log entry type definition +export type LogEntry = { + level: string; + message: string; + timestamp: number; +}; + +// Create event emitter instance +const eventEmitter = new NativeEventEmitter(NativeSniConnect as any); + +// Simple log subscription function +export function subscribeToLogs(callback: (log: LogEntry) => void): () => void { + const subscription = eventEmitter.addListener('SniConnectLog', (log: any) => { + // Type assertion since we know the structure + callback(log as LogEntry); + }); + return () => subscription.remove(); +} + +export function request( + config: SniConnectRequest +): Promise { + return NativeSniConnect.request(config); +} + +export function cancelRequest( + requestId: string +): Promise<{ success: boolean }> { + return NativeSniConnect.cancelRequest(requestId); +} + +export function cancelAllRequests(): Promise<{ success: boolean }> { + return NativeSniConnect.cancelAllRequests(); +} + +export function clearDNSCache(): Promise<{ success: boolean }> { + return NativeSniConnect.clearDNSCache(); +} + +export type { SniConnectRequest, SniConnectResponse } from './NativeSniConnect'; diff --git a/native-modules/react-native-sni-connect/tsconfig.build.json b/native-modules/react-native-sni-connect/tsconfig.build.json new file mode 100644 index 00000000..3c0636ad --- /dev/null +++ b/native-modules/react-native-sni-connect/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig", + "exclude": ["example", "lib"] +} diff --git a/native-modules/react-native-sni-connect/tsconfig.json b/native-modules/react-native-sni-connect/tsconfig.json new file mode 100644 index 00000000..d36b8298 --- /dev/null +++ b/native-modules/react-native-sni-connect/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "rootDir": ".", + "paths": { + "@onekeyfe/react-native-sni-connect": ["./src/index"] + }, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "customConditions": ["react-native-strict-api"], + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react-jsx", + "lib": ["ESNext"], + "module": "ESNext", + "moduleResolution": "bundler", + "noEmit": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noImplicitUseStrict": false, + "noStrictGenericChecks": false, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ESNext", + "verbatimModuleSyntax": true + } +} diff --git a/native-modules/react-native-sni-connect/turbo.json b/native-modules/react-native-sni-connect/turbo.json new file mode 100644 index 00000000..c4d78c49 --- /dev/null +++ b/native-modules/react-native-sni-connect/turbo.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://turbo.build/schema.json", + "globalDependencies": [".nvmrc", ".yarnrc.yml"], + "globalEnv": ["NODE_ENV"], + "tasks": { + "build:android": { + "env": ["ANDROID_HOME", "ORG_GRADLE_PROJECT_newArchEnabled"], + "inputs": [ + "package.json", + "android", + "!android/build", + "src/*.ts", + "src/*.tsx", + "example/package.json", + "example/android", + "!example/android/.gradle", + "!example/android/build", + "!example/android/app/build" + ], + "outputs": [] + }, + "build:ios": { + "env": [ + "RCT_NEW_ARCH_ENABLED", + "RCT_USE_RN_DEP", + "RCT_USE_PREBUILT_RNCORE" + ], + "inputs": [ + "package.json", + "*.podspec", + "ios", + "src/*.ts", + "src/*.tsx", + "example/package.json", + "example/ios", + "!example/ios/build", + "!example/ios/Pods" + ], + "outputs": [] + } + } +} diff --git a/yarn.lock b/yarn.lock index 266ebb7f..b5c20ceb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3751,6 +3751,39 @@ __metadata: languageName: unknown linkType: soft +"@onekeyfe/react-native-sni-connect@workspace:native-modules/react-native-sni-connect": + version: 0.0.0-use.local + resolution: "@onekeyfe/react-native-sni-connect@workspace:native-modules/react-native-sni-connect" + dependencies: + "@commitlint/config-conventional": "npm:^19.8.1" + "@eslint/compat": "npm:^1.3.2" + "@eslint/eslintrc": "npm:^3.3.1" + "@eslint/js": "npm:^9.35.0" + "@react-native/babel-preset": "npm:0.83.0" + "@react-native/eslint-config": "npm:0.83.0" + "@release-it/conventional-changelog": "npm:^10.0.1" + "@types/jest": "npm:^29.5.14" + "@types/react": "npm:^19.2.0" + commitlint: "npm:^19.8.1" + del-cli: "npm:^6.0.0" + eslint: "npm:^9.35.0" + eslint-config-prettier: "npm:^10.1.8" + eslint-plugin-prettier: "npm:^5.5.4" + jest: "npm:^29.7.0" + lefthook: "npm:^2.0.3" + prettier: "npm:^2.8.8" + react: "npm:19.2.0" + react-native: "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch" + react-native-builder-bob: "npm:^0.40.17" + release-it: "npm:^19.0.4" + turbo: "npm:^2.5.6" + typescript: "npm:^5.9.2" + peerDependencies: + react: "*" + react-native: "*" + languageName: unknown + linkType: soft + "@onekeyfe/react-native-splash-screen@workspace:*, @onekeyfe/react-native-splash-screen@workspace:native-modules/react-native-splash-screen": version: 0.0.0-use.local resolution: "@onekeyfe/react-native-splash-screen@workspace:native-modules/react-native-splash-screen" From bf4d61284a82c7311df16c36399a1f15d8c0210a Mon Sep 17 00:00:00 2001 From: huhuanming Date: Sat, 13 Jun 2026 01:08:09 +0800 Subject: [PATCH 2/7] fix(sni-connect): harden security, fix concurrency/lifecycle, unify logging via reflection Security (boundary validation, both platforms): - Validate ip as a public IP literal (reject loopback/private/link-local/ metadata/CGNAT/multicast/reserved); reject hostname-as-ip (no DNS). - Force https://hostname:443; reject absolute/scheme/authority paths (no scheme downgrade or host/port override). - Method allowlist, strict hostname, reject CR/LF in header names/values. Concurrency / correctness: - iOS: per-request connect timeout via setConnectTimeoutIntervalFor(...) instead of the process-global setter; simplify DNS cache and fix the cleanExpired read-after-remove bug; fix block-based NotificationCenter observer leak (retain + remove token). - iOS new-arch: forward requestId and null-guard the codegen->dict bridge. - Android: bounded LRU OkHttpClient cache with shared dispatcher/pool; AtomicBoolean guard against double-settling the promise; validate timeout. Logging (Q2): route native logs to OneKeyLog via reflection (SniConnectLog / SniConnectLogger), mirroring BTLogger/SBLLogger, so this TurboModule does not hard-link the nitro-based native-logger; remove the bespoke JS log event channel (RCTEventEmitter / subscribeToLogs). Falls back to os/android log. Cleanup: drop dev scripts/ (sudo pfctl/tcpdump + hardcoded token), fix repository/podspec URLs, rewrite README to the real API, narrow Android packagingOptions, trim dead code. Example: add the module to the example app + Podfile (aliyun spec source, EMASCurl modular_headers). Verified: Android compileDebugKotlin and iOS xcodebuild of the SniConnect target both succeed; tsc + jest pass. --- example/react-native/ios/Podfile | 9 + example/react-native/package.json | 1 + .../react-native-sni-connect/README.md | 63 ++- .../SniConnect.podspec | 2 +- .../android/build.gradle | 21 - .../java/com/sniconnect/SniConnectLogger.kt | 55 +++ .../java/com/sniconnect/SniConnectModule.kt | 227 ++++----- .../com/sniconnect/SniConnectValidation.kt | 144 ++++++ .../ios/SniConnect-Bridging-Header.h | 1 - .../ios/SniConnect.mm | 60 +-- .../ios/SniConnect.swift | 69 +-- .../ios/SniConnectClient.swift | 204 ++++---- .../ios/SniConnectLog.swift | 29 ++ .../ios/SniConnectValidation.swift | 151 ++++++ .../scripts/block-ips.sh | 44 -- .../scripts/sni-request.js | 74 --- .../scripts/verify-sni.sh | 84 ---- .../src/NativeSniConnect.ts | 11 +- .../src/__tests__/index.test.ts | 465 ++---------------- .../react-native-sni-connect/src/index.tsx | 20 - yarn.lock | 3 +- 21 files changed, 713 insertions(+), 1024 deletions(-) create mode 100644 native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectLogger.kt create mode 100644 native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectValidation.kt create mode 100644 native-modules/react-native-sni-connect/ios/SniConnectLog.swift create mode 100644 native-modules/react-native-sni-connect/ios/SniConnectValidation.swift delete mode 100755 native-modules/react-native-sni-connect/scripts/block-ips.sh delete mode 100644 native-modules/react-native-sni-connect/scripts/sni-request.js delete mode 100755 native-modules/react-native-sni-connect/scripts/verify-sni.sh diff --git a/example/react-native/ios/Podfile b/example/react-native/ios/Podfile index 6b191e01..3b73561b 100644 --- a/example/react-native/ios/Podfile +++ b/example/react-native/ios/Podfile @@ -1,3 +1,8 @@ +# Spec sources. The default CDN plus Aliyun's repo, which hosts EMASCurl +# (a transitive native dependency of @onekeyfe/react-native-sni-connect). +source 'https://github.com/CocoaPods/Specs.git' +source 'https://github.com/aliyun/aliyun-specs.git' + # Resolve react_native_pods.rb with node to allow for hoisting ENV['RCT_NEW_ARCH_ENABLED'] = '1' @@ -27,6 +32,10 @@ target 'example' do :app_path => "#{Pod::Config.instance.installation_root}/.." ) + # Enable modular headers for EMASCurl so SniConnect's Swift `import EMASCurl` + # works when building as static libraries (matches app-monorepo). + pod 'EMASCurl', :modular_headers => true + # Copy optional offline TradingView chart material into the app bundle as # `tradingview-assets/`, preserving the # directory tree so react-native-chart-webview's WKURLSchemeHandler can serve it. diff --git a/example/react-native/package.json b/example/react-native/package.json index cf10c522..248215d6 100644 --- a/example/react-native/package.json +++ b/example/react-native/package.json @@ -39,6 +39,7 @@ "@onekeyfe/react-native-scroll-guard": "workspace:*", "@onekeyfe/react-native-segment-slider": "workspace:*", "@onekeyfe/react-native-skeleton": "workspace:*", + "@onekeyfe/react-native-sni-connect": "workspace:*", "@onekeyfe/react-native-splash-screen": "workspace:*", "@onekeyfe/react-native-split-bundle-loader": "workspace:*", "@onekeyfe/react-native-tab-view": "workspace:*", diff --git a/native-modules/react-native-sni-connect/README.md b/native-modules/react-native-sni-connect/README.md index ae6ef919..099779c5 100644 --- a/native-modules/react-native-sni-connect/README.md +++ b/native-modules/react-native-sni-connect/README.md @@ -1,37 +1,62 @@ -# react-native-sni-connect +# @onekeyfe/react-native-sni-connect -onekey sni http client +OneKey SNI HTTP client for React Native. Performs HTTPS requests to a caller-supplied +IP address while preserving the original TLS SNI / `Host` of a hostname, so certificate +chain and hostname verification are still enforced against the real hostname (not the IP). + +Backed by EMASCurl (libcurl) on iOS and OkHttp on Android. ## Installation +This package is published as part of the OneKey `app-modules` workspace: ```sh -npm install react-native-sni-connect +yarn add @onekeyfe/react-native-sni-connect ``` +iOS: run `pod install`. Android autolinks. ## Usage - -```js -import { multiply } from 'react-native-sni-connect'; - -// ... - -const result = multiply(3, 7); +```ts +import { + request, + cancelRequest, + cancelAllRequests, + clearDNSCache, +} from '@onekeyfe/react-native-sni-connect'; + +const res = await request({ + // requestId is optional; required only if you want to cancel the request. + requestId: 'health-check-1', + ip: '93.184.216.34', // must be a public IP literal (private/loopback/metadata are rejected) + hostname: 'example.com', // used for SNI, Host header and certificate validation + method: 'GET', + path: '/api/v1/ping', // relative path only — absolute URLs are rejected + headers: { 'Content-Type': 'application/json' }, + timeout: 30_000, +}); + +console.log(res.status, res.headers, res.data); + +// Cancellation (requires requestId on the request) +await cancelRequest('health-check-1'); +await cancelAllRequests(); + +// Drop pinned-IP connections / cached clients +await clearDNSCache(); ``` +### Security notes -## Contributing - -- [Development workflow](CONTRIBUTING.md#development-workflow) -- [Sending a pull request](CONTRIBUTING.md#sending-a-pull-request) -- [Code of conduct](CODE_OF_CONDUCT.md) +- The request scheme is always `https` on port `443`; `path` cannot override scheme, host or port. +- `ip` must be an IPv4/IPv6 literal that routes to a public destination; loopback, private, + link-local (incl. cloud metadata), CGNAT, multicast and reserved ranges are rejected. +- Header names/values containing CR/LF/control characters are rejected; the `Host` header is + managed by the module. +- Native logs go through OneKey's native logger (with sensitive-data redaction); there is no + JS log event channel. ## License MIT - ---- - -Made with [create-react-native-library](https://github.com/callstack/react-native-builder-bob) diff --git a/native-modules/react-native-sni-connect/SniConnect.podspec b/native-modules/react-native-sni-connect/SniConnect.podspec index cd05d30f..8ff6dbf0 100644 --- a/native-modules/react-native-sni-connect/SniConnect.podspec +++ b/native-modules/react-native-sni-connect/SniConnect.podspec @@ -11,7 +11,7 @@ Pod::Spec.new do |s| s.authors = package["author"] s.platforms = { :ios => min_ios_version_supported } - s.source = { :git => "https://github.com/OneKeyHQ/react-native-sni-connect.git", :tag => "#{s.version}" } + s.source = { :git => "https://github.com/OneKeyHQ/app-modules/react-native-sni-connect.git", :tag => "#{s.version}" } s.static_framework = true diff --git a/native-modules/react-native-sni-connect/android/build.gradle b/native-modules/react-native-sni-connect/android/build.gradle index 90287b90..efd08421 100644 --- a/native-modules/react-native-sni-connect/android/build.gradle +++ b/native-modules/react-native-sni-connect/android/build.gradle @@ -34,27 +34,6 @@ android { targetSdkVersion getExtOrIntegerDefault("targetSdkVersion") } - packagingOptions { - excludes = [ - "META-INF", - "META-INF/**", - "**/libc++_shared.so", - "**/libfbjni.so", - "**/libjsi.so", - "**/libfolly_json.so", - "**/libfolly_runtime.so", - "**/libglog.so", - "**/libhermes.so", - "**/libhermes-executor-debug.so", - "**/libhermes_executor.so", - "**/libreactnative.so", - "**/libreactnativejni.so", - "**/libturbomodulejsijni.so", - "**/libreact_nativemodule_core.so", - "**/libjscexecutor.so" - ] - } - buildFeatures { buildConfig true } diff --git a/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectLogger.kt b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectLogger.kt new file mode 100644 index 00000000..d4489394 --- /dev/null +++ b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectLogger.kt @@ -0,0 +1,55 @@ +package com.sniconnect + +/** + * Lightweight logging wrapper that dynamically dispatches to OneKeyLog. + * Uses reflection to avoid a hard dependency on the (nitro-based) native-logger + * module. Falls back to android.util.Log when OneKeyLog is not available. + * + * Mirrors iOS SniConnectLog and the existing BTLogger / SBLLogger. + */ +internal object SniConnectLogger { + private const val TAG = "SniConnect" + + private val logClass: Class<*>? by lazy { + try { + Class.forName("com.margelo.nitro.nativelogger.OneKeyLog") + } catch (_: ClassNotFoundException) { + null + } + } + + private val methods by lazy { + val cls = logClass ?: return@lazy null + mapOf( + "debug" to cls.getMethod("debug", String::class.java, String::class.java), + "info" to cls.getMethod("info", String::class.java, String::class.java), + "warn" to cls.getMethod("warn", String::class.java, String::class.java), + "error" to cls.getMethod("error", String::class.java, String::class.java), + ) + } + + @JvmStatic + fun debug(message: String) = log("debug", message, android.util.Log.DEBUG) + + @JvmStatic + fun info(message: String) = log("info", message, android.util.Log.INFO) + + @JvmStatic + fun warn(message: String) = log("warn", message, android.util.Log.WARN) + + @JvmStatic + fun error(message: String) = log("error", message, android.util.Log.ERROR) + + private fun log(level: String, message: String, androidLogLevel: Int) { + val method = methods?.get(level) + if (method != null) { + try { + method.invoke(null, TAG, message) + return + } catch (_: Exception) { + // Fall through to android.util.Log + } + } + android.util.Log.println(androidLogLevel, TAG, message) + } +} diff --git a/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectModule.kt b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectModule.kt index 284f64f9..a86da5da 100644 --- a/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectModule.kt +++ b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectModule.kt @@ -1,16 +1,16 @@ package com.sniconnect -import android.util.Log import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactMethod import com.facebook.react.bridge.ReadableMap import com.facebook.react.bridge.WritableMap -import com.facebook.react.modules.core.DeviceEventManagerModule import com.facebook.react.module.annotations.ReactModule import okhttp3.Call import okhttp3.Callback +import okhttp3.ConnectionPool +import okhttp3.Dispatcher import okhttp3.Dns import okhttp3.Headers import okhttp3.MediaType.Companion.toMediaTypeOrNull @@ -21,10 +21,10 @@ import okhttp3.Response import okhttp3.ResponseBody import java.io.IOException import java.net.InetAddress -import java.net.UnknownHostException import java.util.Locale import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean import javax.net.ssl.HttpsURLConnection private const val TAG = "SniConnect" @@ -35,65 +35,49 @@ class SniConnectModule(reactContext: ReactApplicationContext) : companion object { const val NAME = "SniConnect" - private const val EVENT_NAME = "SniConnectLog" + + // Upper bound on cached OkHttpClient instances to prevent unbounded growth + // from JS-controlled host/IP pairs (e.g. speed-testing many endpoints). + private const val MAX_CLIENTS = 32 + + // A single dispatcher + connection pool shared across all cached clients so we + // don't spawn a thread pool / connection pool per (hostname, ip) pair. + private val sharedDispatcher = Dispatcher() + private val sharedConnectionPool = ConnectionPool() } /** - * Cache key for OkHttpClient instances - * Uses hostname:IP combination to ensure: - * - Different IPs for the same hostname are isolated (accurate speed testing) - * - Same hostname+IP combination can reuse connections (performance optimization) - * Note: timeout is NOT part of the key to allow connection reuse across different timeout values + * Cache key for OkHttpClient instances. + * Uses hostname:IP so different IPs for the same hostname stay isolated (accurate + * speed testing) while the same hostname+IP reuses connections. Timeout is NOT part + * of the key — it is applied per-call via `call.timeout()`. */ private data class ClientKey( val hostname: String, val ip: String, ) - private val clientCache = ConcurrentHashMap() - private val activeCalls = ConcurrentHashMap() - private var listenerCount = 0 - - override fun getName(): String = NAME - - // Event emitter methods required for NativeEventEmitter - @ReactMethod - override fun addListener(eventType: String) { - listenerCount += 1 - } - - @ReactMethod - override fun removeListeners(count: Double) { - listenerCount -= count.toInt() - if (listenerCount < 0) { - listenerCount = 0 + // Bounded LRU: access-ordered, evicts the eldest client when capacity is exceeded. + // Idle connections are reclaimed by the shared ConnectionPool's own keep-alive, so + // we do NOT evictAll here (that pool is shared by every client). Synchronized because + // LinkedHashMap is not thread-safe. + private val clientCache = object : LinkedHashMap(16, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry): Boolean { + return size > MAX_CLIENTS } } - private fun sendLogEvent(level: String, message: String) { - if (listenerCount > 0) { - val logData = Arguments.createMap().apply { - putString("level", level) - putString("message", message) - putDouble("timestamp", System.currentTimeMillis().toDouble()) - } + private val activeCalls = ConcurrentHashMap() - reactApplicationContext - .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) - ?.emit(EVENT_NAME, logData) - } - } + override fun getName(): String = NAME override fun request(config: ReadableMap, promise: Promise) { try { val requestConfig = config.toRequestConfig() - // Log module initialization - sendLogEvent("info", "SniConnect module initialized successfully") performRequest(requestConfig, promise) } catch (error: Exception) { - Log.e(TAG, "[SniConnect] Request failed", error) - sendLogEvent("error", "Config parsing failed: ${error.message}") - promise.reject("SNI_REQUEST_FAILED", error.message, error) + SniConnectLogger.error("Config parsing failed: ${error.message}") + promise.reject("SNI_INVALID_CONFIG", error.message, error) } } @@ -102,38 +86,31 @@ class SniConnectModule(reactContext: ReactApplicationContext) : val call = activeCalls.remove(requestId) if (call != null) { call.cancel() - sendLogEvent("info", "Cancelled request: $requestId") - promise.resolve(Arguments.createMap().apply { - putBoolean("success", true) - }) + SniConnectLogger.info("Cancelled request: $requestId") + promise.resolve(Arguments.createMap().apply { putBoolean("success", true) }) } else { - sendLogEvent("info", "Request not found: $requestId") - promise.resolve(Arguments.createMap().apply { - putBoolean("success", false) - }) + promise.resolve(Arguments.createMap().apply { putBoolean("success", false) }) } } @ReactMethod override fun cancelAllRequests(promise: Promise) { val count = activeCalls.size - activeCalls.forEach { (_, call) -> - call.cancel() - } + activeCalls.forEach { (_, call) -> call.cancel() } activeCalls.clear() - sendLogEvent("info", "Cancelled $count active requests") - promise.resolve(Arguments.createMap().apply { - putBoolean("success", true) - }) + SniConnectLogger.info("Cancelled $count active requests") + promise.resolve(Arguments.createMap().apply { putBoolean("success", true) }) } @ReactMethod override fun clearDNSCache(promise: Promise) { - clientCache.clear() - sendLogEvent("info", "DNS cache cleared") - promise.resolve(Arguments.createMap().apply { - putBoolean("success", true) - }) + synchronized(clientCache) { + clientCache.clear() + } + // Drop pinned-IP connections from the shared pool. + sharedConnectionPool.evictAll() + SniConnectLogger.info("DNS cache cleared") + promise.resolve(Arguments.createMap().apply { putBoolean("success", true) }) } private fun performRequest(config: RequestConfig, promise: Promise) { @@ -148,52 +125,55 @@ class SniConnectModule(reactContext: ReactApplicationContext) : // Register the call if requestId is provided config.requestId?.let { requestId -> activeCalls[requestId] = call - sendLogEvent("info", "Registered request: $requestId, total active: ${activeCalls.size}") } + // Guard against double-settling the promise (RN hard-crashes otherwise). + val settled = AtomicBoolean(false) + call.enqueue(object : Callback { override fun onFailure(call: Call, e: IOException) { - // Unregister the call config.requestId?.let { activeCalls.remove(it) } + if (!settled.compareAndSet(false, true)) return if (call.isCanceled()) { - sendLogEvent("info", "Request cancelled") promise.reject("SNI_CANCELLED", "Request cancelled", null) } else { - Log.e(TAG, "[SniConnect] Request failed", e) - sendLogEvent("error", "Request failed: ${e.message}") + SniConnectLogger.error("Request failed: ${e.message}") promise.reject("SNI_REQUEST_FAILED", e.message, e) } } override fun onResponse(call: Call, response: Response) { - // Unregister the call config.requestId?.let { activeCalls.remove(it) } - try { + val result: WritableMap = try { response.use { val bodyString = response.body.safeString() val headerMap = headersToMap(response.headers) - - val result: WritableMap = Arguments.createMap().apply { + Arguments.createMap().apply { putString("data", bodyString) putInt("status", response.code) putString("statusText", response.message) putMap("headers", headerMap.toWritableMap()) } - - promise.resolve(result) } } catch (error: Exception) { - Log.e(TAG, "[SniConnect] Response processing failed", error) - sendLogEvent("error", "Response processing failed: ${error.message}") + if (!settled.compareAndSet(false, true)) return + SniConnectLogger.error("Response processing failed: ${error.message}") promise.reject("SNI_RESPONSE_FAILED", error.message, error) + return + } + + if (response.code >= 400) { + SniConnectLogger.warn("HTTP ${response.code} for ${config.hostname}") + } + if (settled.compareAndSet(false, true)) { + promise.resolve(result) } } }) } catch (error: Exception) { - Log.e(TAG, "[SniConnect] Request setup failed", error) - sendLogEvent("error", "Request setup failed: ${error.message}") + SniConnectLogger.error("Request setup failed: ${error.message}") promise.reject("SNI_REQUEST_FAILED", error.message, error) } } @@ -202,47 +182,54 @@ class SniConnectModule(reactContext: ReactApplicationContext) : val normalizedHost = config.hostname.lowercase(Locale.US) val key = ClientKey(normalizedHost, config.ip) - return clientCache.getOrPut(key) { - // Note: timeout is not part of the cache key to allow connection reuse - // Use reasonable default timeouts for the client - // Per-request timeout is applied via call.timeout() in performRequest - val defaultTimeout = 60_000L // 60 seconds + synchronized(clientCache) { + clientCache[key]?.let { return it } + + // 60s defaults at the client level; the real deadline is the per-call timeout. + val defaultTimeout = 60_000L - OkHttpClient.Builder() + val client = OkHttpClient.Builder() + .dispatcher(sharedDispatcher) + .connectionPool(sharedConnectionPool) .connectTimeout(defaultTimeout, TimeUnit.MILLISECONDS) .readTimeout(defaultTimeout, TimeUnit.MILLISECONDS) .writeTimeout(defaultTimeout, TimeUnit.MILLISECONDS) - .callTimeout(0, TimeUnit.MILLISECONDS) // Disable client-level call timeout + .callTimeout(0, TimeUnit.MILLISECONDS) + // TLS is validated normally: cert chain via the default trust manager and + // hostname verification against the REAL hostname (not the pinned IP). .hostnameVerifier { _, session -> HttpsURLConnection.getDefaultHostnameVerifier().verify(config.hostname, session) } .dns(createPinnedDns(config.ip, config.hostname)) .build() + + clientCache[key] = client + return client } } private fun createPinnedDns(ip: String, hostname: String): Dns = object : Dns { private val expectedHost = hostname.lowercase(Locale.US) + // Resolve the literal IP once up front (validated; never triggers DNS). + private val pinnedAddress: InetAddress = SniConnectValidation.literalToInetAddress(ip) override fun lookup(requestedHost: String): List { return if (requestedHost.lowercase(Locale.US) == expectedHost) { - listOf(resolveIp(ip)) + listOf(pinnedAddress) } else { Dns.SYSTEM.lookup(requestedHost) } } } + /** + * Build the request. Always `https://` on the implicit port 443 — + * `path` has been validated as relative, so scheme/host/port cannot be overridden. + */ private fun buildRequest(config: RequestConfig): Request { - val normalizedPath = if (config.path.startsWith("http")) { - config.path - } else { - val prefix = if (config.path.startsWith("/")) "" else "/" - "https://${config.hostname}$prefix${config.path}" - } - - val builder = Request.Builder().url(normalizedPath) + val url = "https://${config.hostname}${config.path}" + val builder = Request.Builder().url(url) config.headers.forEach { (key, value) -> if (!key.equals("host", ignoreCase = true)) { @@ -251,7 +238,7 @@ class SniConnectModule(reactContext: ReactApplicationContext) : } builder.header("Host", config.hostname) - val method = config.method.uppercase(Locale.US) + val method = config.method val bodyContent = config.body ?: "" val mediaType = config.headers.entries .firstOrNull { it.key.equals("Content-Type", ignoreCase = true) } @@ -278,13 +265,10 @@ class SniConnectModule(reactContext: ReactApplicationContext) : } private fun ResponseBody?.safeString(): String { - if (this == null) { - return "" - } + if (this == null) return "" return try { this.string() } catch (error: IOException) { - Log.e(TAG, "[SniConnect] Failed to read response body", error) throw IOException("Failed to read response body", error) } } @@ -297,50 +281,47 @@ class SniConnectModule(reactContext: ReactApplicationContext) : return map } - private fun resolveIp(ip: String): InetAddress { - return try { - InetAddress.getByName(ip) - } catch (error: UnknownHostException) { - throw IOException("Invalid IP address: $ip", error) - } - } - private fun Map.toWritableMap(): WritableMap { return Arguments.createMap().apply { - forEach { (key, value) -> - putString(key, value) - } + forEach { (key, value) -> putString(key, value) } } } private fun ReadableMap.toRequestConfig(): RequestConfig { val headersMap = if (hasKey("headers") && !isNull("headers")) { - val headersReadable = getMap("headers") - headersReadable?.toHashMap() + getMap("headers")?.toHashMap() ?.mapValues { (_, value) -> value?.toString() ?: "" } ?: emptyMap() } else { emptyMap() } - val timeoutMillis = if (hasKey("timeout")) { - (getDouble("timeout") * 1.0).toLong() + val timeoutMillis = if (hasKey("timeout") && !isNull("timeout")) { + getDouble("timeout").toLong().coerceAtLeast(1L) } else { 30_000L } - val requestId = if (hasKey("requestId") && !isNull("requestId")) { - getString("requestId") - } else { - null - } + val requestId = if (hasKey("requestId") && !isNull("requestId")) getString("requestId") else null + + val ip = getString("ip") ?: throw IllegalArgumentException("ip is required") + val hostname = getString("hostname") ?: throw IllegalArgumentException("hostname is required") + val method = getString("method") ?: "GET" + val path = getString("path") ?: "/" + + // Validate every caller-controlled field at the boundary. + SniConnectValidation.validatePublicIp(ip) + SniConnectValidation.validateHostname(hostname) + SniConnectValidation.validateHeaders(headersMap) + val normalizedMethod = SniConnectValidation.normalizeMethod(method) + val normalizedPath = SniConnectValidation.normalizePath(path) return RequestConfig( requestId = requestId, - ip = getString("ip") ?: throw IllegalArgumentException("ip is required"), - hostname = getString("hostname") ?: throw IllegalArgumentException("hostname is required"), - method = getString("method") ?: "GET", - path = getString("path") ?: "/", + ip = ip, + hostname = hostname, + method = normalizedMethod, + path = normalizedPath, headers = headersMap, body = if (hasKey("body") && !isNull("body")) getString("body") else null, timeoutMillis = timeoutMillis, diff --git a/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectValidation.kt b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectValidation.kt new file mode 100644 index 00000000..5d1c9a12 --- /dev/null +++ b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectValidation.kt @@ -0,0 +1,144 @@ +package com.sniconnect + +import java.net.Inet6Address +import java.net.InetAddress +import java.util.Locale + +/** + * Boundary validation/normalization for SNI request inputs. + * + * The module connects to a caller-supplied IP while preserving the TLS SNI/Host of + * `hostname`. Because the connect target is caller-controlled, every field that + * reaches the network layer is validated here to prevent SSRF, scheme/host/port + * override, cleartext downgrade and CR/LF header injection. + */ +internal object SniConnectValidation { + + class ValidationException(message: String) : IllegalArgumentException(message) + + private val ALLOWED_METHODS = + setOf("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS") + + private val HOSTNAME_REGEX = Regex( + "^(?=.{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])?)*\$" + ) + private val IPV4_REGEX = Regex("^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\$") + private val SCHEME_REGEX = Regex("^[A-Za-z][A-Za-z0-9+.-]*:") + + fun normalizeMethod(method: String): String { + val upper = method.trim().uppercase(Locale.US) + if (upper !in ALLOWED_METHODS) { + throw ValidationException("Invalid method: $method") + } + return upper + } + + fun validateHostname(hostname: String) { + if (hostname.isEmpty() || hostname.length > 253 || !HOSTNAME_REGEX.matches(hostname)) { + throw ValidationException("Invalid hostname: $hostname") + } + } + + /** Must be a relative path/query only — reject absolute/protocol-relative URLs and control chars. */ + fun normalizePath(path: String): String { + val trimmed = path.trim() + if (containsControlChars(trimmed)) { + throw ValidationException("Invalid path") + } + if (trimmed.contains("://") || trimmed.startsWith("//") || SCHEME_REGEX.containsMatchIn(trimmed.take(64).substringBefore('/'))) { + throw ValidationException("Invalid path: absolute URLs are not allowed") + } + if (trimmed.isEmpty()) return "/" + return if (trimmed.startsWith("/")) trimmed else "/$trimmed" + } + + fun validateHeaders(headers: Map) { + for ((key, value) in headers) { + if (key.isEmpty() || containsControlChars(key) || containsControlChars(value)) { + throw ValidationException("Invalid header: $key") + } + } + } + + private fun containsControlChars(s: String): Boolean = + s.any { it.code < 0x20 || it.code == 0x7F } + + /** + * Validate `ip` is a literal IPv4/IPv6 address (never a hostname) routing to a + * public/global-unicast destination. Rejects loopback, private, link-local + * (incl. 169.254.169.254 metadata), CGNAT, multicast and reserved ranges. + */ + fun validatePublicIp(ip: String) { + val octets = IPV4_REGEX.matchEntire(ip)?.groupValues?.drop(1)?.map { it.toInt() } + if (octets != null) { + if (octets.any { it > 255 }) throw ValidationException("Invalid IP: $ip") + if (isForbiddenIpv4(octets)) throw ValidationException("Forbidden IP: $ip") + return + } + // IPv6: only treat as literal if it contains ':' (avoids any DNS lookup). + if (ip.contains(':')) { + val addr: InetAddress = try { + InetAddress.getByName(ip) + } catch (e: Exception) { + throw ValidationException("Invalid IP: $ip") + } + if (addr !is Inet6Address) throw ValidationException("Invalid IP: $ip") + if (isForbiddenIpv6(addr)) throw ValidationException("Forbidden IP: $ip") + return + } + throw ValidationException("Invalid IP: $ip") + } + + private fun isForbiddenIpv4(o: List): Boolean { + val a = o[0]; val b = o[1]; val c = o[2]; val d = o[3] + return when { + a == 0 -> true // 0.0.0.0/8 + a == 10 -> true // 10/8 private + a == 127 -> true // 127/8 loopback + a == 100 && (b and 0xC0) == 0x40 -> true // 100.64/10 CGNAT + a == 169 && b == 254 -> true // 169.254/16 link-local + metadata + a == 172 && b in 16..31 -> true // 172.16/12 private + a == 192 && b == 168 -> true // 192.168/16 private + a == 192 && b == 0 && c == 0 -> true // 192.0.0/24 + a == 192 && b == 0 && c == 2 -> true // 192.0.2/24 TEST-NET-1 + a == 198 && (b == 18 || b == 19) -> true // 198.18/15 benchmarking + a == 198 && b == 51 && c == 100 -> true // 198.51.100/24 TEST-NET-2 + a == 203 && b == 0 && c == 113 -> true // 203.0.113/24 TEST-NET-3 + a >= 224 -> true // 224/4 multicast + 240/4 reserved + broadcast + else -> false + } + } + + private fun isForbiddenIpv6(addr: Inet6Address): Boolean { + if (addr.isAnyLocalAddress || addr.isLoopbackAddress || addr.isLinkLocalAddress || + addr.isSiteLocalAddress || addr.isMulticastAddress + ) { + return true + } + val bytes = addr.address + // Unique local fc00::/7 + if ((bytes[0].toInt() and 0xFE) == 0xFC) return true + // IPv4-mapped ::ffff:a.b.c.d — validate the embedded IPv4 + val mappedPrefixZero = (0..9).all { bytes[it].toInt() == 0 } + if (mappedPrefixZero && (bytes[10].toInt() and 0xFF) == 0xFF && (bytes[11].toInt() and 0xFF) == 0xFF) { + return isForbiddenIpv4( + listOf( + bytes[12].toInt() and 0xFF, + bytes[13].toInt() and 0xFF, + bytes[14].toInt() and 0xFF, + bytes[15].toInt() and 0xFF, + ) + ) + } + return false + } + + /** Parse a validated IPv4/IPv6 literal into an InetAddress without DNS resolution. */ + fun literalToInetAddress(ip: String): InetAddress { + val octets = IPV4_REGEX.matchEntire(ip)?.groupValues?.drop(1)?.map { it.toInt().toByte() } + if (octets != null) { + return InetAddress.getByAddress(byteArrayOf(octets[0], octets[1], octets[2], octets[3])) + } + return InetAddress.getByName(ip) // safe: already validated as an IPv6 literal + } +} diff --git a/native-modules/react-native-sni-connect/ios/SniConnect-Bridging-Header.h b/native-modules/react-native-sni-connect/ios/SniConnect-Bridging-Header.h index 0a583fa9..bf77aa61 100644 --- a/native-modules/react-native-sni-connect/ios/SniConnect-Bridging-Header.h +++ b/native-modules/react-native-sni-connect/ios/SniConnect-Bridging-Header.h @@ -6,7 +6,6 @@ // #import -#import #ifdef RCT_NEW_ARCH_ENABLED #import diff --git a/native-modules/react-native-sni-connect/ios/SniConnect.mm b/native-modules/react-native-sni-connect/ios/SniConnect.mm index 336c6545..68e8d4b9 100644 --- a/native-modules/react-native-sni-connect/ios/SniConnect.mm +++ b/native-modules/react-native-sni-connect/ios/SniConnect.mm @@ -1,5 +1,4 @@ #import -#import #import #ifdef RCT_NEW_ARCH_ENABLED @@ -8,7 +7,7 @@ // Forward declaration of the Swift implementation @interface SniConnectImpl : NSObject -- (instancetype)initWithEventSender:(id)eventSender; +- (instancetype)init; - (void)request:(NSDictionary *)config resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject; @@ -21,7 +20,7 @@ - (void)clearDNSCache:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject; @end -@interface SniConnect : RCTEventEmitter +@interface SniConnect : NSObject #ifdef RCT_NEW_ARCH_ENABLED #else @@ -31,63 +30,42 @@ @interface SniConnect : RCTEventEmitter @implementation SniConnect { SniConnectImpl *_implementation; - BOOL _hasListeners; } RCT_EXPORT_MODULE(SniConnect) -// Expose hasListeners as a property for Swift access -- (BOOL)hasListeners { - return _hasListeners; -} - + (BOOL)requiresMainQueueSetup { return NO; } - (instancetype)init { if (self = [super init]) { - _implementation = [[SniConnectImpl alloc] initWithEventSender:self]; - _hasListeners = NO; + _implementation = [[SniConnectImpl alloc] init]; } return self; } -// Event emitter methods -- (NSArray *)supportedEvents { - return @[@"SniConnectLog"]; -} - -- (void)startObserving { - _hasListeners = YES; -} - -- (void)stopObserving { - _hasListeners = NO; -} - -// Method to send log event to JS -- (void)sendLogEvent:(NSDictionary *)logData { - if (_hasListeners) { - [self sendEventWithName:@"SniConnectLog" body:logData]; - } -} - #ifdef RCT_NEW_ARCH_ENABLED // TurboModule interface implementation - (void)request:(JS::NativeSniConnect::SniConnectRequest &)config resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { - // Convert Codegen struct to NSDictionary for Swift implementation - NSDictionary *configDict = @{ - @"ip": config.ip(), - @"hostname": config.hostname(), - @"method": config.method(), - @"path": config.path(), - @"headers": config.headers(), - @"body": config.body() ?: [NSNull null], - @"timeout": @(config.timeout()) - }; + // Convert Codegen struct to NSDictionary for the Swift implementation. + // Null-guard every field so a nil value never crashes the dictionary literal, + // and forward requestId so cancellation works under the new architecture. + NSMutableDictionary *configDict = [NSMutableDictionary dictionary]; + configDict[@"ip"] = config.ip() ?: @""; + configDict[@"hostname"] = config.hostname() ?: @""; + configDict[@"method"] = config.method() ?: @"GET"; + configDict[@"path"] = config.path() ?: @"/"; + configDict[@"headers"] = config.headers() ?: @{}; + configDict[@"timeout"] = @(config.timeout()); + if (config.requestId()) { + configDict[@"requestId"] = config.requestId(); + } + if (config.body()) { + configDict[@"body"] = config.body(); + } [_implementation request:configDict resolve:resolve reject:reject]; } diff --git a/native-modules/react-native-sni-connect/ios/SniConnect.swift b/native-modules/react-native-sni-connect/ios/SniConnect.swift index 24df23df..2ad98a4b 100644 --- a/native-modules/react-native-sni-connect/ios/SniConnect.swift +++ b/native-modules/react-native-sni-connect/ios/SniConnect.swift @@ -8,52 +8,25 @@ private enum SniConnectError: Error { @objc(SniConnectImpl) final class SniConnectImpl: NSObject { private let client: SniConnectClient - private weak var eventSender: AnyObject? @objc - init(eventSender: AnyObject) { - self.eventSender = eventSender + override init() { self.client = SniConnectClient() super.init() - - // Set up logging closure for the client - self.client.onLog = { [weak self] level, message in - self?.sendLogEvent(level: level, message: message) - } - } - - private func sendLogEvent(level: String, message: String) { - // Send log event to the Objective-C bridge - guard let eventSender = eventSender else { - return - } - - let logData: [String: Any] = [ - "level": level, - "message": message, - "timestamp": Int(Date().timeIntervalSince1970 * 1000) - ] - - // Call the sendLogEvent method on the bridge - let selector = NSSelectorFromString("sendLogEvent:") - if eventSender.responds(to: selector) { - eventSender.perform(selector, with: logData) - } } @objc public func request( _ config: NSDictionary, - resolve resolve: @escaping RCTPromiseResolveBlock, - reject reject: @escaping RCTPromiseRejectBlock + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock ) { do { let parsed = try Self.parseDictionary(config) handleRequest(config: parsed, resolve: resolve, reject: reject) } catch { - NSLog("[SniConnect] ❌ Config parsing failed: \(error)") - sendLogEvent(level: "error", message: "Config parsing failed: \(error)") - reject("SNI_REQUEST_FAILED", error.localizedDescription, error) + SniConnectLog.error("Config parsing failed: \(error)") + reject("SNI_INVALID_CONFIG", "\(error)", error) } } @@ -67,7 +40,7 @@ final class SniConnectImpl: NSObject { return try await client.performRequest(config: config) } - // Register task synchronously if requestId is provided + // Register task if requestId is provided if let requestId = config.requestId { client.registerTask(task, for: requestId) } @@ -86,16 +59,11 @@ final class SniConnectImpl: NSObject { "multiValueHeaders": result.multiValueHeaders, ]) } catch let error as SniConnectClient.SniConnectError { - NSLog("[SniConnect] ❌ [\(error.code)] \(error.message)") - sendLogEvent(level: "error", message: "[\(error.code)] \(error.message)") reject(error.code, error.message, error) } catch is CancellationError { - NSLog("[SniConnect] ❌ Request cancelled") - sendLogEvent(level: "info", message: "Request cancelled") reject("SNI_CANCELLED", "Request cancelled", nil) } catch { - NSLog("[SniConnect] ❌ Request failed: \(error.localizedDescription)") - sendLogEvent(level: "error", message: "Request failed: \(error.localizedDescription)") + SniConnectLog.error("Request failed: \(error.localizedDescription)") reject("SNI_UNKNOWN_ERROR", error.localizedDescription, error) } } @@ -104,8 +72,8 @@ final class SniConnectImpl: NSObject { @objc public func cancelRequest( _ requestId: String, - resolve resolve: @escaping RCTPromiseResolveBlock, - reject reject: @escaping RCTPromiseRejectBlock + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock ) { client.cancelRequest(requestId: requestId) resolve(["success": true]) @@ -114,7 +82,7 @@ final class SniConnectImpl: NSObject { @objc public func cancelAllRequests( _ resolve: @escaping RCTPromiseResolveBlock, - reject reject: @escaping RCTPromiseRejectBlock + reject: @escaping RCTPromiseRejectBlock ) { client.cancelAllRequests() resolve(["success": true]) @@ -123,7 +91,7 @@ final class SniConnectImpl: NSObject { @objc public func clearDNSCache( _ resolve: @escaping RCTPromiseResolveBlock, - reject reject: @escaping RCTPromiseRejectBlock + reject: @escaping RCTPromiseRejectBlock ) { client.clearDNSCache() resolve(["success": true]) @@ -163,23 +131,16 @@ final class SniConnectImpl: NSObject { } private static func serializeResponseData(_ data: Any) -> String { - if let dict = data as? [String: Any], - let jsonData = try? JSONSerialization.data(withJSONObject: dict, options: []), - let jsonString = String(data: jsonData, encoding: .utf8) { - return jsonString + if let stringValue = data as? String { + return stringValue } - if let array = data as? [[String: Any]], - let jsonData = try? JSONSerialization.data(withJSONObject: array, options: []), + if JSONSerialization.isValidJSONObject(data), + let jsonData = try? JSONSerialization.data(withJSONObject: data, options: []), let jsonString = String(data: jsonData, encoding: .utf8) { return jsonString } - if let stringValue = data as? String { - return stringValue - } - return String(describing: data) } } - diff --git a/native-modules/react-native-sni-connect/ios/SniConnectClient.swift b/native-modules/react-native-sni-connect/ios/SniConnectClient.swift index 5bbe57ca..511d0736 100644 --- a/native-modules/react-native-sni-connect/ios/SniConnectClient.swift +++ b/native-modules/react-native-sni-connect/ios/SniConnectClient.swift @@ -1,30 +1,34 @@ import Foundation +import UIKit import EMASCurl /// Core HTTPS client that enforces IP direct connection with SNI. final class SniConnectClient { - // Logging callback - var onLog: ((String, String) -> Void)? - // Active requests tracking for cancellation support private var activeTasks: [String: Task] = [:] private let tasksQueue = DispatchQueue(label: "com.onekey.sni.connect.tasks", attributes: .concurrent) + // Token for the memory-warning observer (block-based observers are not removed + // by `removeObserver(self)`, so the token must be retained and removed explicitly). + private var memoryWarningObserver: NSObjectProtocol? + init() { // Register for memory warnings to clean cache - NotificationCenter.default.addObserver( + memoryWarningObserver = NotificationCenter.default.addObserver( forName: UIApplication.didReceiveMemoryWarningNotification, object: nil, queue: .main - ) { [weak self] _ in - self?.onLog?("warning", "Memory warning received, cleaning DNS cache") + ) { _ in + SniConnectLog.info("Memory warning received, cleaning DNS cache") DNSResolver.cleanExpiredEntries() } } deinit { - NotificationCenter.default.removeObserver(self) + if let observer = memoryWarningObserver { + NotificationCenter.default.removeObserver(observer) + } } struct RequestConfig { @@ -164,8 +168,10 @@ final class SniConnectClient { curlConfig.enableBuiltInRedirection = true curlConfig.cacheEnabled = false - // Enable full certificate validation for security - // The certificate will be validated against the SNI hostname, not the IP + // Enable full certificate validation for security. + // The certificate is validated against the SNI hostname, not the IP, because + // the custom DNS resolver only overrides address resolution — libcurl keeps the + // original hostname for SNI and certificate CN/SAN matching. curlConfig.certificateValidationEnabled = true curlConfig.domainNameVerificationEnabled = true curlConfig.dnsResolver = DNSResolver.self @@ -178,88 +184,54 @@ final class SniConnectClient { private static let queue = DispatchQueue(label: "com.onekey.sni.connect.dns", attributes: .concurrent) private static let cache = DNSCache() - /// LRU cache for DNS mappings with size limit and TTL support - /// Uses hostname:IP as cache key to ensure different IPs for the same hostname are isolated - private class DNSCache { - private struct CacheEntry { - let hostname: String + /// Thread-safe hostname -> IP pin with TTL. + /// + /// LIMITATION: EMASCurl only exposes a process-global DNS resolver + /// (`setDNSResolver:`), which receives only the hostname. There is no + /// per-request DNS API, so the pin is keyed by hostname and the most recent + /// IP for a hostname wins. Concurrent requests to the SAME hostname targeting + /// DIFFERENT IPs are therefore not guaranteed to each hit their own IP. Normal + /// usage (one IP per hostname at a time) is unaffected. + private final class DNSCache { + private struct Entry { let ip: String let timestamp: TimeInterval } - private var storage: [String: CacheEntry] = [:] - private var accessOrder: [String] = [] - - // Mapping from hostname to IP for DNS resolution - // This stores the most recently set IP for each hostname - private var hostnameToIP: [String: String] = [:] - - // Maximum cache entries (100 as specified in requirements) + private var hostnameToEntry: [String: Entry] = [:] private let maxSize = 100 - // TTL in seconds (5 minutes default) - private let ttl: TimeInterval = 300 - - /// Generate cache key using hostname:IP format - /// This ensures different IPs for the same hostname are isolated (accurate speed testing) - private func makeCacheKey(hostname: String, ip: String) -> String { - return "\(hostname.lowercased()):\(ip)" - } + private let ttl: TimeInterval = 300 // 5 minutes func get(_ domain: String) -> String? { - let normalizedDomain = domain.lowercased() - - // Return the most recently set IP for this hostname - return hostnameToIP[normalizedDomain] + let key = domain.lowercased() + guard let entry = hostnameToEntry[key] else { return nil } + if Date().timeIntervalSince1970 - entry.timestamp > ttl { + hostnameToEntry.removeValue(forKey: key) + return nil + } + return entry.ip } func set(_ ip: String, for domain: String) { - let normalizedDomain = domain.lowercased() - let key = makeCacheKey(hostname: normalizedDomain, ip: ip) - - // Update hostname to IP mapping - hostnameToIP[normalizedDomain] = ip - - // Evict oldest entry if cache is full - if storage.count >= maxSize && storage[key] == nil { - if let oldest = accessOrder.first { - storage.removeValue(forKey: oldest) - accessOrder.removeFirst() + let key = domain.lowercased() + // Evict the oldest entry when at capacity (and this is a new host). + if hostnameToEntry.count >= maxSize && hostnameToEntry[key] == nil { + if let oldest = hostnameToEntry.min(by: { $0.value.timestamp < $1.value.timestamp })?.key { + hostnameToEntry.removeValue(forKey: oldest) } } - - // Add or update entry - let entry = CacheEntry(hostname: normalizedDomain, ip: ip, timestamp: Date().timeIntervalSince1970) - storage[key] = entry - - // Update access order - if let index = accessOrder.firstIndex(of: key) { - accessOrder.remove(at: index) - } - accessOrder.append(key) - } - - func remove(_ key: String) { - storage.removeValue(forKey: key) - if let index = accessOrder.firstIndex(of: key) { - accessOrder.remove(at: index) - } + hostnameToEntry[key] = Entry(ip: ip, timestamp: Date().timeIntervalSince1970) } func clear() { - storage.removeAll() - accessOrder.removeAll() - hostnameToIP.removeAll() + hostnameToEntry.removeAll() } func cleanExpired() { let now = Date().timeIntervalSince1970 - let expiredKeys = storage.filter { now - $0.value.timestamp > ttl }.map { $0.key } - for key in expiredKeys { - remove(key) - // Also remove from hostnameToIP if this was the active mapping - if let entry = storage[key], hostnameToIP[entry.hostname] == entry.ip { - hostnameToIP.removeValue(forKey: entry.hostname) - } + let expired = hostnameToEntry.filter { now - $0.value.timestamp > ttl }.map { $0.key } + for key in expired { + hostnameToEntry.removeValue(forKey: key) } } } @@ -296,19 +268,19 @@ final class SniConnectClient { /// Clear all DNS cache entries func clearDNSCache() { DNSResolver.clearCache() - onLog?("info", "DNS cache cleared") + SniConnectLog.info("DNS cache cleared") } /// Cancel a request by ID func cancelRequest(requestId: String) { tasksQueue.async(flags: .barrier) { [weak self] in guard let task = self?.activeTasks[requestId] else { - self?.onLog?("warning", "No active request found with ID: \(requestId)") + SniConnectLog.warn("No active request found with ID: \(requestId)") return } task.cancel() self?.activeTasks.removeValue(forKey: requestId) - self?.onLog?("info", "Request cancelled: \(requestId)") + SniConnectLog.info("Request cancelled: \(requestId)") } } @@ -321,15 +293,14 @@ final class SniConnectClient { task.cancel() } self.activeTasks.removeAll() - self.onLog?("info", "Cancelled \(count) active requests") + SniConnectLog.info("Cancelled \(count) active requests") } } - /// Register an active task (synchronous for immediate registration) + /// Register an active task (async barrier; the task is already running). func registerTask(_ task: Task, for requestId: String) { - tasksQueue.sync(flags: .barrier) { [weak self] in + tasksQueue.async(flags: .barrier) { [weak self] in self?.activeTasks[requestId] = task - self?.onLog?("info", "Registered request: \(requestId), total active: \(self?.activeTasks.count ?? 0)") } } @@ -338,7 +309,6 @@ final class SniConnectClient { guard let requestId = requestId else { return } tasksQueue.async(flags: .barrier) { [weak self] in self?.activeTasks.removeValue(forKey: requestId) - self?.onLog?("info", "Unregistered request: \(requestId)") } } @@ -350,12 +320,25 @@ final class SniConnectClient { unregisterTask(requestId: config.requestId) } + // Validate every caller-controlled field before it reaches the network layer. + let method: String + let normalizedPath: String + do { + try SniConnectValidation.validatePublicIP(config.ip) + try SniConnectValidation.validateHostname(config.hostname) + try SniConnectValidation.validateHeaders(config.headers) + method = try SniConnectValidation.normalizeMethod(config.method) + normalizedPath = try SniConnectValidation.normalizePath(config.path) + } catch { + throw SniConnectError.invalidConfig("\(error)") + } + DNSResolver.setIP(config.ip, for: config.hostname) - let url = try Self.buildURL(hostname: config.hostname, path: config.path) + let url = try Self.buildURL(hostname: config.hostname, normalizedPath: normalizedPath) let mutableRequest = NSMutableURLRequest(url: url) - mutableRequest.httpMethod = config.method.uppercased() + mutableRequest.httpMethod = method // Convert milliseconds to seconds for timeout values let totalTimeoutSeconds = config.effectiveTotalTimeout / 1000.0 @@ -380,15 +363,15 @@ final class SniConnectClient { mutableRequest.httpBody = bodyData } - // Configure connection timeout for EMASCurl - EMASCurlProtocol.setConnectTimeoutInterval(connectTimeoutSeconds) + // Per-request connect timeout (avoids the process-global setter race). + EMASCurlProtocol.setConnectTimeoutIntervalFor(mutableRequest, connectTimeoutInterval: connectTimeoutSeconds) let request = mutableRequest as URLRequest do { let (data, response) = try await Self.urlSession.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { let errorMsg = "Invalid HTTP response type" - onLog?("error", errorMsg) + SniConnectLog.error(errorMsg) throw SniConnectError.invalidConfig(errorMsg) } @@ -397,11 +380,10 @@ final class SniConnectClient { let (headers, multiValueHeaders) = Self.extractHeaders(from: httpResponse) let statusText = HTTPURLResponse.localizedString(forStatusCode: status) - // Check for HTTP errors (4xx, 5xx) + // 4xx/5xx are returned to JS as a normal response (the caller inspects + // `status`); we only record it for diagnostics. if status >= 400 { - let error = SniConnectError.httpError(code: status, message: statusText) - onLog?("error", "HTTP error: \(error.message)") - // Still return the response for client to handle + SniConnectLog.warn("HTTP \(status) for \(config.hostname)") } return Response( @@ -412,33 +394,33 @@ final class SniConnectClient { multiValueHeaders: multiValueHeaders ) } catch let error as SniConnectError { - onLog?("error", "[\(error.code)] \(error.message)") + SniConnectLog.error("[\(error.code)] \(error.message)") throw error } catch { // Convert generic errors to specific SniConnectError types let sniError = SniConnectError.from(error) - onLog?("error", "[\(sniError.code)] \(sniError.message)") + SniConnectLog.error("[\(sniError.code)] \(sniError.message)") throw sniError } } - private static func buildURL(hostname: String, path: String) throws -> URL { - let trimmedPath = path.trimmingCharacters(in: .whitespacesAndNewlines) - - if let url = URL(string: trimmedPath), url.scheme != nil { - return url - } - - let normalizedPath: String - if trimmedPath.isEmpty { - normalizedPath = "/" - } else if trimmedPath.hasPrefix("/") { - normalizedPath = trimmedPath + /// Build the request URL. Always `https://` on port 443 — the + /// path has already been validated as relative (no scheme/authority), so the + /// caller cannot override scheme, host or port. + private static func buildURL(hostname: String, normalizedPath: String) throws -> URL { + var components = URLComponents() + components.scheme = "https" + components.host = hostname + // `normalizedPath` is "/...optional?query". Split off the query so URLComponents + // percent-encodes each part correctly. + if let queryIndex = normalizedPath.firstIndex(of: "?") { + components.percentEncodedPath = String(normalizedPath[.. Any { guard !data.isEmpty else { - return [:] + return "" } if let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []) { @@ -483,13 +465,7 @@ final class SniConnectClient { for (key, values) in headerGroups { // For backward compatibility, single-value headers use the last value singleValueHeaders[key] = values.last - - // Multi-value headers contain all values - if values.count > 1 { - multiValueHeaders[key] = values - } else { - multiValueHeaders[key] = values - } + multiValueHeaders[key] = values } return (singleValueHeaders, multiValueHeaders) diff --git a/native-modules/react-native-sni-connect/ios/SniConnectLog.swift b/native-modules/react-native-sni-connect/ios/SniConnectLog.swift new file mode 100644 index 00000000..b7e34ae4 --- /dev/null +++ b/native-modules/react-native-sni-connect/ios/SniConnectLog.swift @@ -0,0 +1,29 @@ +import Foundation + +/// Lightweight logging wrapper that dynamically dispatches to OneKeyLog through +/// the Objective-C runtime. +/// +/// Using reflection (instead of `import ReactNativeNativeLogger`) keeps this +/// TurboModule from hard-linking the nitro-based native-logger module. When +/// OneKeyLog is unavailable the logs are silently dropped. Mirrors the Android +/// `SniConnectLogger` and the existing `BTLogger` / `SBLLogger`. +enum SniConnectLog { + private static let tag = "SniConnect" + + // Swift classes are exposed to the ObjC runtime as `Module.ClassName`. + private static let logClass: AnyObject? = + (NSClassFromString("ReactNativeNativeLogger.OneKeyLog") + ?? NSClassFromString("OneKeyLog")) as AnyObject? + + static func debug(_ message: String) { dispatch("debug::", message) } + static func info(_ message: String) { dispatch("info::", message) } + static func warn(_ message: String) { dispatch("warn::", message) } + static func error(_ message: String) { dispatch("error::", message) } + + private static func dispatch(_ selectorName: String, _ message: String) { + guard let cls = logClass else { return } + let sel = NSSelectorFromString(selectorName) + guard cls.responds(to: sel) else { return } + _ = cls.perform(sel, with: tag, with: message) + } +} diff --git a/native-modules/react-native-sni-connect/ios/SniConnectValidation.swift b/native-modules/react-native-sni-connect/ios/SniConnectValidation.swift new file mode 100644 index 00000000..82645647 --- /dev/null +++ b/native-modules/react-native-sni-connect/ios/SniConnectValidation.swift @@ -0,0 +1,151 @@ +import Foundation + +/// Boundary validation/normalization for SNI request inputs. +/// +/// The module connects to a caller-supplied IP while preserving the TLS SNI/Host +/// of `hostname`. Because the connect target is caller-controlled, every field that +/// reaches the network layer is validated here to prevent SSRF, scheme/host/port +/// override, cleartext downgrade and CR/LF header injection. +enum SniConnectValidation { + + enum ValidationError: Error { + case invalidIP(String) + case forbiddenIP(String) + case invalidHostname(String) + case invalidMethod(String) + case invalidPath(String) + case invalidHeader(String) + } + + /// HTTP methods the module is allowed to issue. + private static let allowedMethods: Set = [ + "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", + ] + + /// Validate and uppercase the HTTP method. + static func normalizeMethod(_ method: String) throws -> String { + let upper = method.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + guard allowedMethods.contains(upper) else { + throw ValidationError.invalidMethod(method) + } + return upper + } + + /// Validate `hostname` as a DNS host (used for SNI, Host header and cert matching). + static func validateHostname(_ hostname: String) throws { + guard hostname.count <= 253, !hostname.isEmpty else { + throw ValidationError.invalidHostname(hostname) + } + // Labels: 1-63 chars, alphanumeric + hyphen, not starting/ending with hyphen. + let pattern = "^(?=.{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])?)*$" + guard hostname.range(of: pattern, options: .regularExpression) != nil else { + throw ValidationError.invalidHostname(hostname) + } + } + + /// Validate `path`: must be a relative path/query only. Reject absolute URLs + /// (scheme/authority), protocol-relative URLs and control characters so the + /// caller cannot override scheme/host/port or downgrade to cleartext. + static func normalizePath(_ path: String) throws -> String { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + if containsControlCharacters(trimmed) { + throw ValidationError.invalidPath(path) + } + // Reject anything that looks like it carries a scheme or authority. + if trimmed.contains("://") || trimmed.hasPrefix("//") { + throw ValidationError.invalidPath(path) + } + // A bare scheme like "javascript:..." has no "//"; reject any leading scheme. + if let schemeRange = trimmed.range(of: "^[A-Za-z][A-Za-z0-9+.-]*:", options: .regularExpression), + schemeRange.lowerBound == trimmed.startIndex { + throw ValidationError.invalidPath(path) + } + if trimmed.isEmpty { return "/" } + return trimmed.hasPrefix("/") ? trimmed : "/" + trimmed + } + + /// Validate header names/values: reject CR/LF/control characters (header + /// injection) and the Host header (the module sets Host itself). + static func validateHeaders(_ headers: [String: String]) throws { + for (key, value) in headers { + if key.isEmpty || containsControlCharacters(key) || containsControlCharacters(value) { + throw ValidationError.invalidHeader(key) + } + } + } + + static func containsControlCharacters(_ s: String) -> Bool { + return s.unicodeScalars.contains { $0.value < 0x20 || $0.value == 0x7F } + } + + // MARK: - IP validation + + /// Validate `ip` is a literal IPv4/IPv6 address (never a hostname) and routes + /// to a public/global-unicast destination. Rejects loopback, private, + /// link-local (incl. 169.254.169.254 metadata), CGNAT, multicast and reserved. + static func validatePublicIP(_ ip: String) throws { + if let v4 = parseIPv4(ip) { + if isForbiddenIPv4(v4) { throw ValidationError.forbiddenIP(ip) } + return + } + if let v6 = parseIPv6(ip) { + if isForbiddenIPv6(v6) { throw ValidationError.forbiddenIP(ip) } + return + } + throw ValidationError.invalidIP(ip) + } + + private static func parseIPv4(_ ip: String) -> [UInt8]? { + var addr = in_addr() + guard ip.withCString({ inet_pton(AF_INET, $0, &addr) }) == 1 else { return nil } + let raw = addr.s_addr.bigEndian + return [ + UInt8((raw >> 24) & 0xFF), + UInt8((raw >> 16) & 0xFF), + UInt8((raw >> 8) & 0xFF), + UInt8(raw & 0xFF), + ] + } + + private static func parseIPv6(_ ip: String) -> [UInt8]? { + var addr = in6_addr() + guard ip.withCString({ inet_pton(AF_INET6, $0, &addr) }) == 1 else { return nil } + return withUnsafeBytes(of: &addr) { Array($0.bindMemory(to: UInt8.self)) } + } + + private static func isForbiddenIPv4(_ b: [UInt8]) -> Bool { + let a = b[0], c = b[1], d = b[2] + if a == 0 { return true } // 0.0.0.0/8 "this network" + if a == 10 { return true } // 10/8 private + if a == 127 { return true } // 127/8 loopback + if a == 100 && (c & 0xC0) == 0x40 { return true } // 100.64/10 CGNAT + if a == 169 && c == 254 { return true } // 169.254/16 link-local + metadata + if a == 172 && c >= 16 && c <= 31 { return true } // 172.16/12 private + if a == 192 && c == 168 { return true } // 192.168/16 private + if a == 192 && c == 0 && d == 0 { return true } // 192.0.0/24 + if a == 192 && c == 0 && d == 2 { return true } // 192.0.2/24 TEST-NET-1 + if a == 198 && (c == 18 || c == 19) { return true } // 198.18/15 benchmarking + if a == 198 && c == 51 && d == 100 { return true } // 198.51.100/24 TEST-NET-2 + if a == 203 && c == 0 && d == 113 { return true } // 203.0.113/24 TEST-NET-3 + if a >= 224 { return true } // 224/4 multicast + 240/4 reserved + broadcast + return false + } + + private static func isForbiddenIPv6(_ b: [UInt8]) -> Bool { + // Unspecified :: + if b.allSatisfy({ $0 == 0 }) { return true } + // Loopback ::1 + if b[0...14].allSatisfy({ $0 == 0 }) && b[15] == 1 { return true } + // Multicast ff00::/8 + if b[0] == 0xFF { return true } + // Link-local fe80::/10 + if b[0] == 0xFE && (b[1] & 0xC0) == 0x80 { return true } + // Unique local fc00::/7 + if (b[0] & 0xFE) == 0xFC { return true } + // IPv4-mapped ::ffff:0:0/96 — validate the embedded IPv4 + if b[0...9].allSatisfy({ $0 == 0 }) && b[10] == 0xFF && b[11] == 0xFF { + return isForbiddenIPv4([b[12], b[13], b[14], b[15]]) + } + return false + } +} diff --git a/native-modules/react-native-sni-connect/scripts/block-ips.sh b/native-modules/react-native-sni-connect/scripts/block-ips.sh deleted file mode 100755 index e11b2240..00000000 --- a/native-modules/react-native-sni-connect/scripts/block-ips.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/bin/bash - -# 配置要屏蔽的 IP 列表 -BLOCKED_IPS=( - # "216.19.4.106" - "104.18.31.39" - # 在这里添加更多 IP -) - -# pf anchor 文件路径 -PF_ANCHOR_FILE="/etc/pf.anchors/block_ips" -PF_CONF="/etc/pf.conf" - -# 检查是否有 sudo 权限 -if [ "$EUID" -ne 0 ]; then - echo "❌ 需要 sudo 权限运行此脚本" - echo "请使用: sudo $0" - exit 1 -fi - -echo "📝 正在生成 pf 规则..." - -# 生成规则文件 -> "$PF_ANCHOR_FILE" # 清空文件 - -for ip in "${BLOCKED_IPS[@]}"; do - echo "block drop out quick from any to $ip" >> "$PF_ANCHOR_FILE" - echo " ✓ 添加屏蔽规则: $ip" -done - -echo "" -echo "📋 生成的规则文件内容:" -cat "$PF_ANCHOR_FILE" - -echo "" -echo "🔄 重新加载 pf 配置..." -pfctl -f "$PF_CONF" 2>&1 | grep -v "Use of -f option" - -echo "" -echo "✅ 当前生效的规则:" -pfctl -a block_ips -s rules 2>&1 | grep -v "ALTQ" - -echo "" -echo "🎉 完成!已屏蔽 ${#BLOCKED_IPS[@]} 个 IP" diff --git a/native-modules/react-native-sni-connect/scripts/sni-request.js b/native-modules/react-native-sni-connect/scripts/sni-request.js deleted file mode 100644 index 21f512c5..00000000 --- a/native-modules/react-native-sni-connect/scripts/sni-request.js +++ /dev/null @@ -1,74 +0,0 @@ -/** - * SNI Direct IP Connection Test Script - * - * This script demonstrates how to make HTTPS requests using: - * - Direct IP connection (bypassing DNS) - * - SNI (Server Name Indication) with domain name - * - Custom headers for API authentication - */ - -const https = require('https'); - -const options = { - host: '104.18.31.39', // Direct IP connection - // host: '216.19.4.106', - port: 443, - path: '/wallet/v1/account/validate-address?networkId=btc--0&accountAddress=bc1qezh467l5gwkk72v2dx6yj488hlpad8d34u6z2j', - method: 'GET', - servername: 'wallet.onekeytest.com', // CRITICAL: SNI must use domain name for TLS handshake - headers: { - 'Host': 'wallet.onekeytest.com', - 'X-Onekey-Request-ID': 'cc740bab-7cbb-412f-9d9a-1d7b515f601d', - 'X-Onekey-Request-Currency': 'usd', - 'X-Onekey-Request-Locale': 'zh-cn', - 'X-Onekey-Request-Theme': 'light', - 'X-Onekey-Request-Platform': 'android-apk', - 'X-Onekey-Request-Version': '5.16.0', - 'X-Onekey-Request-Build-Number': '2000000000', - 'X-Onekey-Request-Token': 'eyJhbGciOi...', // Truncated token for security - 'X-Onekey-Request-Currency-Value': '1.0', - 'X-Onekey-Instance-Id': '67848a28-b89c-4e0b-8c0f-b87824480d6a', - 'x-onekey-wallet-type': 'hd', - 'x-onekey-hide-asset-details': 'false', - }, -}; - -console.log('🚀 Starting SNI test...'); -console.log(`📡 Connecting to: ${options.host}:${options.port}`); -console.log(`🔐 SNI servername: ${options.servername}`); -console.log(''); - -const req = https.request(options, (res) => { - console.log('✅ Connection successful!'); - console.log(`📊 Status Code: ${res.statusCode}`); - console.log('📋 Response Headers:', JSON.stringify(res.headers, null, 2)); - console.log(''); - - res.setEncoding('utf8'); - let body = ''; - - res.on('data', (chunk) => { - body += chunk; - }); - - res.on('end', () => { - console.log('📦 Response Body:'); - try { - const parsed = JSON.parse(body); - console.log(JSON.stringify(parsed, null, 2)); - } catch (e) { - console.log(body); - } - }); -}); - -req.on('error', (e) => { - console.error('❌ Request failed:', e.message); - console.error('💡 Possible causes:'); - console.error(' - Network connectivity issues'); - console.error(' - Invalid IP address or port'); - console.error(' - SNI configuration mismatch'); - console.error(' - Certificate validation failure'); -}); - -req.end(); diff --git a/native-modules/react-native-sni-connect/scripts/verify-sni.sh b/native-modules/react-native-sni-connect/scripts/verify-sni.sh deleted file mode 100755 index 384f388d..00000000 --- a/native-modules/react-native-sni-connect/scripts/verify-sni.sh +++ /dev/null @@ -1,84 +0,0 @@ -#!/bin/bash - -# SNI Direct IP Connection Verification Tool -# This script captures network traffic to verify that: -# 1. Direct IP connection is established -# 2. SNI (Server Name Indication) is correctly sent during TLS handshake - -# Configuration -TARGET_IP="104.18.31.39" -TARGET_PORT="443" -SNI_HOSTNAME="wallet.onekeytest.com" - -echo "🔍 SNI Direct IP Connection Verification Tool" -echo "==============================================" -echo "" -echo "Target IP: $TARGET_IP:$TARGET_PORT" -echo "SNI Lookup: $SNI_HOSTNAME" -echo "" -echo "⚠️ Prerequisites:" -echo " 1. Your application is running" -echo " 2. Ready to trigger the 'Direct IP Connection' feature" -echo " 3. sudo permission is required for packet capture" -echo "" - -# Check for sudo permission early -if ! sudo -v &>/dev/null; then - echo "❌ Error: sudo permission is required to run tcpdump" - echo "💡 Please run this script with appropriate permissions" - exit 1 -fi - -echo "Press Enter to start monitoring..." -read - -echo "" -echo "🎯 Packet capture started... (Press Ctrl+C to stop)" -echo "==============================================" -echo "" - -# Packet counter -count=0 - -# Capture and analyze packets -# Using -X for hex+ASCII output to better capture binary TLS data -# Using -s 0 to capture full packets (SNI is in early handshake) -sudo tcpdump -i any -n -X -s 0 "host $TARGET_IP and port $TARGET_PORT" 2>/dev/null | while read -r line; do - # Detect TCP connection initiation (SYN) - if [[ $line == *"Flags [S]"* ]] && [[ $line == *"$TARGET_IP.$TARGET_PORT"* ]]; then - echo "✅ [TCP] Connection initiated to $TARGET_IP:$TARGET_PORT" - echo "" - fi - - # Detect TCP connection response (SYN-ACK) - if [[ $line == *"Flags [S.]"* ]] && [[ $line == *"$TARGET_IP.$TARGET_PORT"* ]]; then - echo "✅ [TCP] Server acknowledged connection" - echo "" - fi - - # Detect SNI field in TLS handshake (both ASCII and hex patterns) - if [[ $line == *"$SNI_HOSTNAME"* ]]; then - count=$((count + 1)) - echo "" - echo "🎉 [TLS] SNI detected (#$count): $SNI_HOSTNAME" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo " ✓ Direct IP Connection + SNI VERIFIED!" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - fi - - # Display packet summary with timestamps (reduced verbosity) - if [[ $line =~ ^[0-9]{2}:[0-9]{2}:[0-9]{2} ]]; then - if [[ $line == *"> $TARGET_IP.$TARGET_PORT"* ]]; then - # Only show initial request packets to reduce noise - if [[ $line == *"Flags [P.]"* ]] || [[ $line == *"Flags [S]"* ]]; then - echo "→ [SENT] $line" - fi - elif [[ $line == *"$TARGET_IP.$TARGET_PORT >"* ]]; then - # Only show response packets with data - if [[ $line == *"Flags [P.]"* ]] || [[ $line == *"Flags [S.]"* ]]; then - echo "← [RECV] $line" - fi - fi - fi -done diff --git a/native-modules/react-native-sni-connect/src/NativeSniConnect.ts b/native-modules/react-native-sni-connect/src/NativeSniConnect.ts index f009c50d..9d93ebd2 100644 --- a/native-modules/react-native-sni-connect/src/NativeSniConnect.ts +++ b/native-modules/react-native-sni-connect/src/NativeSniConnect.ts @@ -33,10 +33,6 @@ export interface Spec extends TurboModule { cancelRequest(requestId: string): Promise<{ success: boolean }>; cancelAllRequests(): Promise<{ success: boolean }>; clearDNSCache(): Promise<{ success: boolean }>; - - // Event emitter methods required for NativeEventEmitter - addListener(eventType: string): void; - removeListeners(count: Int32): void; } const LINKING_ERROR = @@ -45,12 +41,7 @@ const LINKING_ERROR = '- You rebuilt the app after installing the package\n' + '- You are not using Expo Go; create a custom dev client instead\n'; -export type SniConnectModule = Spec & { - request(config: SniConnectRequest): Promise; - cancelRequest(requestId: string): Promise<{ success: boolean }>; - cancelAllRequests(): Promise<{ success: boolean }>; - clearDNSCache(): Promise<{ success: boolean }>; -}; +export type SniConnectModule = Spec; const turboModuleResult = TurboModuleRegistry.get('SniConnect'); diff --git a/native-modules/react-native-sni-connect/src/__tests__/index.test.ts b/native-modules/react-native-sni-connect/src/__tests__/index.test.ts index f90cd7b3..cc56b008 100644 --- a/native-modules/react-native-sni-connect/src/__tests__/index.test.ts +++ b/native-modules/react-native-sni-connect/src/__tests__/index.test.ts @@ -1,435 +1,66 @@ -import { NativeModules } from 'react-native'; +jest.mock('../NativeSniConnect', () => ({ + __esModule: true, + default: { + request: jest.fn(), + cancelRequest: jest.fn(), + cancelAllRequests: jest.fn(), + clearDNSCache: jest.fn(), + }, +})); + import { - request, - cancelRequest, cancelAllRequests, + cancelRequest, clearDNSCache, - subscribeToLogs, - type LogEntry, - type SniConnectRequest, - type SniConnectResponse, + request, } from '../index'; +import NativeSniConnect, { + type SniConnectRequest, +} from '../NativeSniConnect'; -// Mock React Native modules -jest.mock('react-native', () => ({ - NativeModules: { - SniConnect: { - request: jest.fn(), - cancelRequest: jest.fn(), - cancelAllRequests: jest.fn(), - clearDNSCache: jest.fn(), - addListener: jest.fn(), - removeListeners: jest.fn(), - }, - }, - NativeEventEmitter: jest.fn().mockImplementation(() => ({ - addListener: jest.fn(() => ({ - remove: jest.fn(), - })), - })), - Platform: { - select: jest.fn((obj) => obj.default), - }, - TurboModuleRegistry: { - get: jest.fn(() => null), - }, -})); - -describe('SniConnect Module', () => { - const mockNativeModule = NativeModules.SniConnect; +const mockNativeModule = NativeSniConnect as unknown as { + request: jest.Mock; + cancelRequest: jest.Mock; + cancelAllRequests: jest.Mock; + clearDNSCache: jest.Mock; +}; +describe('react-native-sni-connect public API', () => { beforeEach(() => { jest.clearAllMocks(); }); - describe('request()', () => { - it('should call native request method with correct config', async () => { - const config: SniConnectRequest = { - ip: '1.1.1.1', - hostname: 'example.com', - method: 'GET', - path: '/api/test', - headers: { Authorization: 'Bearer token' }, - timeout: 30000, - }; - - const mockResponse: SniConnectResponse = { - data: '{"success": true}', - status: 200, - statusText: 'OK', - headers: { 'content-type': 'application/json' }, - }; - - mockNativeModule.request.mockResolvedValue(mockResponse); - - const response = await request(config); - - expect(mockNativeModule.request).toHaveBeenCalledWith(config); - expect(response).toEqual(mockResponse); - }); - - it('should handle request with requestId', async () => { - const config: SniConnectRequest = { - requestId: 'test-123', - ip: '1.1.1.1', - hostname: 'example.com', - method: 'POST', - path: '/api/data', - headers: {}, - body: '{"key": "value"}', - timeout: 30000, - }; - - mockNativeModule.request.mockResolvedValue({ - data: '', - status: 201, - statusText: 'Created', - headers: {}, - }); - - await request(config); - - expect(mockNativeModule.request).toHaveBeenCalledWith( - expect.objectContaining({ - requestId: 'test-123', - }) - ); - }); - - it('should handle request errors', async () => { - const config: SniConnectRequest = { - ip: '1.1.1.1', - hostname: 'example.com', - method: 'GET', - path: '/api/test', - headers: {}, - timeout: 30000, - }; - - const error = new Error('Network error'); - mockNativeModule.request.mockRejectedValue(error); - - await expect(request(config)).rejects.toThrow('Network error'); - }); - - it('should handle different HTTP methods', async () => { - const methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD']; - - for (const method of methods) { - mockNativeModule.request.mockResolvedValue({ - data: '', - status: 200, - statusText: 'OK', - headers: {}, - }); - - const config: SniConnectRequest = { - ip: '1.1.1.1', - hostname: 'example.com', - method, - path: '/test', - headers: {}, - timeout: 30000, - }; - - await request(config); - expect(mockNativeModule.request).toHaveBeenCalledWith( - expect.objectContaining({ method }) - ); - } - }); - }); - - describe('cancelRequest()', () => { - it('should call native cancelRequest with requestId', async () => { - mockNativeModule.cancelRequest.mockResolvedValue({ success: true }); - - const result = await cancelRequest('test-request-123'); - - expect(mockNativeModule.cancelRequest).toHaveBeenCalledWith( - 'test-request-123' - ); - expect(result).toEqual({ success: true }); - }); - - it('should handle cancellation failure', async () => { - mockNativeModule.cancelRequest.mockResolvedValue({ success: false }); - - const result = await cancelRequest('non-existent'); - - expect(result.success).toBe(false); - }); + it('forwards request() to the native module and returns its result', async () => { + const config: SniConnectRequest = { + ip: '93.184.216.34', + hostname: 'example.com', + method: 'GET', + path: '/', + headers: {}, + timeout: 30_000, + }; + const response = { data: '{}', status: 200, statusText: 'OK', headers: {} }; + mockNativeModule.request.mockResolvedValue(response); + + await expect(request(config)).resolves.toBe(response); + expect(mockNativeModule.request).toHaveBeenCalledWith(config); }); - describe('cancelAllRequests()', () => { - it('should call native cancelAllRequests', async () => { - mockNativeModule.cancelAllRequests.mockResolvedValue({ success: true }); - - const result = await cancelAllRequests(); - - expect(mockNativeModule.cancelAllRequests).toHaveBeenCalled(); - expect(result).toEqual({ success: true }); - }); + it('forwards cancelRequest() with the request id', async () => { + mockNativeModule.cancelRequest.mockResolvedValue({ success: true }); + await expect(cancelRequest('req-1')).resolves.toEqual({ success: true }); + expect(mockNativeModule.cancelRequest).toHaveBeenCalledWith('req-1'); }); - describe('clearDNSCache()', () => { - it('should call native clearDNSCache', async () => { - mockNativeModule.clearDNSCache.mockResolvedValue({ success: true }); - - const result = await clearDNSCache(); - - expect(mockNativeModule.clearDNSCache).toHaveBeenCalled(); - expect(result).toEqual({ success: true }); - }); + it('forwards cancelAllRequests()', async () => { + mockNativeModule.cancelAllRequests.mockResolvedValue({ success: true }); + await expect(cancelAllRequests()).resolves.toEqual({ success: true }); + expect(mockNativeModule.cancelAllRequests).toHaveBeenCalledTimes(1); }); - describe('subscribeToLogs()', () => { - it('should subscribe to log events and return unsubscribe function', () => { - const mockCallback = jest.fn(); - - const unsubscribe = subscribeToLogs(mockCallback); - - // Should return a function - expect(typeof unsubscribe).toBe('function'); - - // Unsubscribe should not throw - expect(() => unsubscribe()).not.toThrow(); - }); - - it('should call callback when log event is received', () => { - const mockCallback = jest.fn(); - - subscribeToLogs(mockCallback); - - // The actual callback invocation would be triggered by native module - // In unit tests, we're just verifying the subscription was set up correctly - expect(mockCallback).not.toHaveBeenCalled(); // Not called immediately - }); - - it('should handle log entry type correctly', () => { - // Type checking test - const logEntry: LogEntry = { - level: 'info', - message: 'Test log message', - timestamp: Date.now(), - }; - - expect(logEntry).toBeDefined(); - expect(logEntry.level).toBe('info'); - expect(logEntry.message).toBe('Test log message'); - expect(typeof logEntry.timestamp).toBe('number'); - }); - }); - - describe('Request Configuration', () => { - it('should handle empty headers', async () => { - mockNativeModule.request.mockResolvedValue({ - data: '', - status: 200, - statusText: 'OK', - headers: {}, - }); - - const config: SniConnectRequest = { - ip: '1.1.1.1', - hostname: 'example.com', - method: 'GET', - path: '/test', - headers: {}, - timeout: 30000, - }; - - await request(config); - expect(mockNativeModule.request).toHaveBeenCalledWith(config); - }); - - it('should handle custom headers', async () => { - mockNativeModule.request.mockResolvedValue({ - data: '', - status: 200, - statusText: 'OK', - headers: {}, - }); - - const config: SniConnectRequest = { - ip: '1.1.1.1', - hostname: 'example.com', - method: 'GET', - path: '/test', - headers: { - 'Authorization': 'Bearer token', - 'X-Custom-Header': 'custom-value', - 'Content-Type': 'application/json', - }, - timeout: 30000, - }; - - await request(config); - expect(mockNativeModule.request).toHaveBeenCalledWith( - expect.objectContaining({ - headers: expect.objectContaining({ - 'Authorization': 'Bearer token', - 'X-Custom-Header': 'custom-value', - }), - }) - ); - }); - - it('should handle request body', async () => { - mockNativeModule.request.mockResolvedValue({ - data: '', - status: 200, - statusText: 'OK', - headers: {}, - }); - - const config: SniConnectRequest = { - ip: '1.1.1.1', - hostname: 'example.com', - method: 'POST', - path: '/api/data', - headers: {}, - body: JSON.stringify({ key: 'value', number: 123 }), - timeout: 30000, - }; - - await request(config); - expect(mockNativeModule.request).toHaveBeenCalledWith( - expect.objectContaining({ - body: expect.stringContaining('key'), - }) - ); - }); - - it('should handle different timeout values', async () => { - mockNativeModule.request.mockResolvedValue({ - data: '', - status: 200, - statusText: 'OK', - headers: {}, - }); - - const timeouts = [1000, 5000, 30000, 60000]; - - for (const timeout of timeouts) { - const config: SniConnectRequest = { - ip: '1.1.1.1', - hostname: 'example.com', - method: 'GET', - path: '/test', - headers: {}, - timeout, - }; - - await request(config); - expect(mockNativeModule.request).toHaveBeenCalledWith( - expect.objectContaining({ timeout }) - ); - } - }); - }); - - describe('Response Handling', () => { - it('should handle JSON response', async () => { - const mockResponse: SniConnectResponse = { - data: JSON.stringify({ result: 'success', count: 42 }), - status: 200, - statusText: 'OK', - headers: { 'content-type': 'application/json' }, - }; - - mockNativeModule.request.mockResolvedValue(mockResponse); - - const response = await request({ - ip: '1.1.1.1', - hostname: 'example.com', - method: 'GET', - path: '/test', - headers: {}, - timeout: 30000, - }); - - expect(response.data).toContain('success'); - expect(response.headers['content-type']).toBe('application/json'); - }); - - it('should handle plain text response', async () => { - const mockResponse: SniConnectResponse = { - data: 'Plain text response', - status: 200, - statusText: 'OK', - headers: { 'content-type': 'text/plain' }, - }; - - mockNativeModule.request.mockResolvedValue(mockResponse); - - const response = await request({ - ip: '1.1.1.1', - hostname: 'example.com', - method: 'GET', - path: '/test', - headers: {}, - timeout: 30000, - }); - - expect(response.data).toBe('Plain text response'); - }); - - it('should handle different status codes', async () => { - const statusCodes = [200, 201, 204, 400, 404, 500]; - - for (const status of statusCodes) { - mockNativeModule.request.mockResolvedValue({ - data: '', - status, - statusText: 'Status', - headers: {}, - }); - - const response = await request({ - ip: '1.1.1.1', - hostname: 'example.com', - method: 'GET', - path: '/test', - headers: {}, - timeout: 30000, - }); - - expect(response.status).toBe(status); - } - }); - }); - - describe('Type Exports', () => { - it('should export LogEntry type', () => { - const log: LogEntry = { - level: 'info', - message: 'test', - timestamp: Date.now(), - }; - expect(log).toBeDefined(); - }); - - it('should export SniConnectRequest type', () => { - const req: SniConnectRequest = { - ip: '1.1.1.1', - hostname: 'example.com', - method: 'GET', - path: '/', - headers: {}, - timeout: 30000, - }; - expect(req).toBeDefined(); - }); - - it('should export SniConnectResponse type', () => { - const res: SniConnectResponse = { - data: '', - status: 200, - statusText: 'OK', - headers: {}, - }; - expect(res).toBeDefined(); - }); + it('forwards clearDNSCache()', async () => { + mockNativeModule.clearDNSCache.mockResolvedValue({ success: true }); + await expect(clearDNSCache()).resolves.toEqual({ success: true }); + expect(mockNativeModule.clearDNSCache).toHaveBeenCalledTimes(1); }); }); diff --git a/native-modules/react-native-sni-connect/src/index.tsx b/native-modules/react-native-sni-connect/src/index.tsx index 46efc80d..e75deb12 100644 --- a/native-modules/react-native-sni-connect/src/index.tsx +++ b/native-modules/react-native-sni-connect/src/index.tsx @@ -1,28 +1,8 @@ -import { NativeEventEmitter } from 'react-native'; import NativeSniConnect, { type SniConnectRequest, type SniConnectResponse, } from './NativeSniConnect'; -// Log entry type definition -export type LogEntry = { - level: string; - message: string; - timestamp: number; -}; - -// Create event emitter instance -const eventEmitter = new NativeEventEmitter(NativeSniConnect as any); - -// Simple log subscription function -export function subscribeToLogs(callback: (log: LogEntry) => void): () => void { - const subscription = eventEmitter.addListener('SniConnectLog', (log: any) => { - // Type assertion since we know the structure - callback(log as LogEntry); - }); - return () => subscription.remove(); -} - export function request( config: SniConnectRequest ): Promise { diff --git a/yarn.lock b/yarn.lock index b5c20ceb..ed78ef59 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2772,6 +2772,7 @@ __metadata: "@onekeyfe/react-native-scroll-guard": "workspace:*" "@onekeyfe/react-native-segment-slider": "workspace:*" "@onekeyfe/react-native-skeleton": "workspace:*" + "@onekeyfe/react-native-sni-connect": "workspace:*" "@onekeyfe/react-native-splash-screen": "workspace:*" "@onekeyfe/react-native-split-bundle-loader": "workspace:*" "@onekeyfe/react-native-tab-view": "workspace:*" @@ -3751,7 +3752,7 @@ __metadata: languageName: unknown linkType: soft -"@onekeyfe/react-native-sni-connect@workspace:native-modules/react-native-sni-connect": +"@onekeyfe/react-native-sni-connect@workspace:*, @onekeyfe/react-native-sni-connect@workspace:native-modules/react-native-sni-connect": version: 0.0.0-use.local resolution: "@onekeyfe/react-native-sni-connect@workspace:native-modules/react-native-sni-connect" dependencies: From 2d48c4295f1efacaadb1405cf3ca627e7ed4d5af Mon Sep 17 00:00:00 2001 From: huhuanming Date: Mon, 29 Jun 2026 16:43:51 +0800 Subject: [PATCH 3/7] fix(sni-connect): align release and ios request safety --- example/react-native/ios/Podfile.lock | 186 +++++++++++------- .../ios/SniConnect.swift | 18 +- .../ios/SniConnectClient.swift | 5 +- .../react-native-sni-connect/package.json | 2 +- 4 files changed, 136 insertions(+), 75 deletions(-) diff --git a/example/react-native/ios/Podfile.lock b/example/react-native/ios/Podfile.lock index 45195c99..dd5555ff 100644 --- a/example/react-native/ios/Podfile.lock +++ b/example/react-native/ios/Podfile.lock @@ -1,5 +1,5 @@ PODS: - - AesCrypto (3.0.67): + - AesCrypto (3.0.68): - boost - DoubleConversion - fast_float @@ -27,7 +27,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - AsyncStorage (3.0.67): + - AsyncStorage (3.0.68): - boost - DoubleConversion - fast_float @@ -55,7 +55,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - AutoSizeInput (3.0.67): + - AutoSizeInput (3.0.68): - boost - DoubleConversion - fast_float @@ -85,7 +85,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - BackgroundThread (3.0.67): + - BackgroundThread (3.0.68): - boost - DoubleConversion - fast_float @@ -115,7 +115,7 @@ PODS: - SocketRocket - Yoga - boost (1.84.0) - - ChartWebview (3.0.67): + - ChartWebview (3.0.68): - boost - DoubleConversion - fast_float @@ -146,7 +146,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - CloudFs (3.0.67): + - CloudFs (3.0.68): - boost - DoubleConversion - fast_float @@ -174,7 +174,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - CloudKitModule (3.0.67): + - CloudKitModule (3.0.68): - boost - DoubleConversion - fast_float @@ -208,7 +208,7 @@ PODS: - CocoaLumberjack/Core (3.9.0) - CocoaLumberjack/Swift (3.9.0): - CocoaLumberjack/Core - - DnsLookup (3.0.67): + - DnsLookup (3.0.68): - boost - DoubleConversion - fast_float @@ -237,6 +237,9 @@ PODS: - SocketRocket - Yoga - DoubleConversion (1.1.6) + - EMASCurl (1.5.5): + - EMASCurl/HTTP2 (= 1.5.5) + - EMASCurl/HTTP2 (1.5.5) - fast_float (8.0.0) - FBLazyVector (0.83.0) - fmt (12.1.0) @@ -244,7 +247,7 @@ PODS: - hermes-engine (0.14.0): - hermes-engine/Pre-built (= 0.14.0) - hermes-engine/Pre-built (0.14.0) - - KeychainModule (3.0.67): + - KeychainModule (3.0.68): - boost - DoubleConversion - fast_float @@ -278,7 +281,7 @@ PODS: - MMKV (2.2.4): - MMKVCore (~> 2.2.4) - MMKVCore (2.2.4) - - NetworkInfo (3.0.67): + - NetworkInfo (3.0.68): - boost - DoubleConversion - fast_float @@ -366,7 +369,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - Pbkdf2 (3.0.67): + - Pbkdf2 (3.0.68): - boost - DoubleConversion - fast_float @@ -394,7 +397,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - PerpDepthBar (3.0.67): + - PerpDepthBar (3.0.68): - boost - DoubleConversion - fast_float @@ -424,7 +427,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - Ping (3.0.67): + - Ping (3.0.68): - boost - DoubleConversion - fast_float @@ -494,6 +497,7 @@ PODS: - React-RCTText (= 0.83.0) - React-RCTVibration (= 0.83.0) - React-callinvoker (0.83.0) + - React-Codegen (0.1.0) - React-Core (0.83.0): - boost - DoubleConversion @@ -2336,7 +2340,7 @@ PODS: - React-RCTFBReactNativeSpec - ReactCommon/turbomodule/core - SocketRocket - - react-native-pager-view (3.0.67): + - react-native-pager-view (3.0.68): - boost - DoubleConversion - fast_float @@ -2451,7 +2455,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - react-native-tab-view (3.0.67): + - react-native-tab-view (3.0.68): - boost - DoubleConversion - fast_float @@ -2469,7 +2473,7 @@ PODS: - React-graphics - React-ImageManager - React-jsi - - react-native-tab-view/common (= 3.0.67) + - react-native-tab-view/common (= 3.0.68) - React-NativeModulesApple - React-RCTFabric - React-renderercss @@ -2480,7 +2484,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - react-native-tab-view/common (3.0.67): + - react-native-tab-view/common (3.0.68): - boost - DoubleConversion - fast_float @@ -3065,7 +3069,7 @@ PODS: - React-perflogger (= 0.83.0) - React-utils (= 0.83.0) - SocketRocket - - ReactNativeAppUpdate (3.0.67): + - ReactNativeAppUpdate (3.0.68): - boost - DoubleConversion - fast_float @@ -3096,7 +3100,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeBundleCrypto (3.0.67): + - ReactNativeBundleCrypto (3.0.68): - boost - DoubleConversion - fast_float @@ -3127,7 +3131,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeBundleUpdate (3.0.67): + - ReactNativeBundleUpdate (3.0.68): - boost - DoubleConversion - fast_float @@ -3162,7 +3166,7 @@ PODS: - SocketRocket - SSZipArchive (>= 2.5.4) - Yoga - - ReactNativeCheckBiometricAuthChanged (3.0.67): + - ReactNativeCheckBiometricAuthChanged (3.0.68): - boost - DoubleConversion - fast_float @@ -3193,7 +3197,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeDeviceUtils (3.0.67): + - ReactNativeDeviceUtils (3.0.68): - boost - DoubleConversion - fast_float @@ -3224,7 +3228,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeGetRandomValues (3.0.67): + - ReactNativeGetRandomValues (3.0.68): - boost - DoubleConversion - fast_float @@ -3255,7 +3259,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeLiteCard (3.0.67): + - ReactNativeLiteCard (3.0.68): - boost - DoubleConversion - fast_float @@ -3284,7 +3288,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeNativeLogger (3.0.67): + - ReactNativeNativeLogger (3.0.68): - boost - CocoaLumberjack/Swift (~> 3.8) - DoubleConversion @@ -3315,7 +3319,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - ReactNativePerfMemory (3.0.67): + - ReactNativePerfMemory (3.0.68): - boost - DoubleConversion - fast_float @@ -3346,7 +3350,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativePerfStats (3.0.67): + - ReactNativePerfStats (3.0.68): - boost - DoubleConversion - fast_float @@ -3377,7 +3381,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeRangeDownloader (3.0.67): + - ReactNativeRangeDownloader (3.0.68): - boost - DoubleConversion - fast_float @@ -3408,7 +3412,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeSplashScreen (3.0.67): + - ReactNativeSplashScreen (3.0.68): - boost - DoubleConversion - fast_float @@ -3439,7 +3443,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeZipArchive (3.0.67): + - ReactNativeZipArchive (3.0.68): - boost - DoubleConversion - fast_float @@ -3528,7 +3532,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - ScrollGuard (3.0.67): + - ScrollGuard (3.0.68): - boost - DoubleConversion - fast_float @@ -3558,7 +3562,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - SegmentSlider (3.0.67): + - SegmentSlider (3.0.68): - boost - DoubleConversion - fast_float @@ -3588,7 +3592,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - Skeleton (3.0.67): + - Skeleton (3.0.68): - boost - DoubleConversion - fast_float @@ -3618,8 +3622,39 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga + - SniConnect (3.0.68): + - boost + - DoubleConversion + - EMASCurl (= 1.5.5) + - fast_float + - fmt + - glog + - hermes-engine + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-callinvoker + - React-Codegen + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SocketRocket + - Yoga - SocketRocket (0.7.1) - - SplitBundleLoader (3.0.67): + - SplitBundleLoader (3.0.68): - boost - DoubleConversion - fast_float @@ -3649,7 +3684,7 @@ PODS: - SocketRocket - Yoga - SSZipArchive (2.6.0) - - TcpSocket (3.0.67): + - TcpSocket (3.0.68): - boost - DoubleConversion - fast_float @@ -3690,6 +3725,7 @@ DEPENDENCIES: - "CloudKitModule (from `../../../node_modules/@onekeyfe/react-native-cloud-kit-module`)" - "DnsLookup (from `../../../node_modules/@onekeyfe/react-native-dns-lookup`)" - DoubleConversion (from `../../../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`) + - EMASCurl - fast_float (from `../../../node_modules/react-native/third-party-podspecs/fast_float.podspec`) - FBLazyVector (from `../../../node_modules/react-native/Libraries/FBLazyVector`) - fmt (from `../../../node_modules/react-native/third-party-podspecs/fmt.podspec`) @@ -3792,12 +3828,17 @@ DEPENDENCIES: - "ScrollGuard (from `../../../node_modules/@onekeyfe/react-native-scroll-guard`)" - "SegmentSlider (from `../../../node_modules/@onekeyfe/react-native-segment-slider`)" - "Skeleton (from `../../../node_modules/@onekeyfe/react-native-skeleton`)" + - "SniConnect (from `../../../node_modules/@onekeyfe/react-native-sni-connect`)" - SocketRocket (~> 0.7.1) - "SplitBundleLoader (from `../../../node_modules/@onekeyfe/react-native-split-bundle-loader`)" - "TcpSocket (from `../../../node_modules/@onekeyfe/react-native-tcp-socket`)" - Yoga (from `../../../node_modules/react-native/ReactCommon/yoga`) SPEC REPOS: + https://github.com/aliyun/aliyun-specs.git: + - EMASCurl + https://github.com/CocoaPods/Specs.git: + - React-Codegen trunk: - CocoaLumberjack - MMKV @@ -4029,6 +4070,8 @@ EXTERNAL SOURCES: :path: "../../../node_modules/@onekeyfe/react-native-segment-slider" Skeleton: :path: "../../../node_modules/@onekeyfe/react-native-skeleton" + SniConnect: + :path: "../../../node_modules/@onekeyfe/react-native-sni-connect" SplitBundleLoader: :path: "../../../node_modules/@onekeyfe/react-native-split-bundle-loader" TcpSocket: @@ -4037,31 +4080,32 @@ EXTERNAL SOURCES: :path: "../../../node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: - AesCrypto: 59c2d9cbb92e42e1b8583009290e7bc6334cf334 - AsyncStorage: ca50c12aa6a0001156b763c73d36c1092461777b - AutoSizeInput: 922901d6d81c8590145cde1874f547be864b6155 - BackgroundThread: 3bef5865d0faa5ecb63a4c83429f555b045d1daf + AesCrypto: 7cda96128d422b29e70d465d59df02bc401353c3 + AsyncStorage: d30dc3668406e28fa4f53f9b1c72c762d69f167b + AutoSizeInput: e838418dc215a2c32b4c1ac14f9dbc852ac12b9f + BackgroundThread: b30869d3ec7c4ec47376bb8a9421872645de4f7a boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90 - ChartWebview: 1dd32680a6dc68b85837cee8a1e9baf447c9b647 - CloudFs: f2f9e5cf4b83efd51c41ad51e57d752d1c7c0098 - CloudKitModule: 505062c38dac6df61e98dad8a7680e32688503e6 + ChartWebview: cf27421fbe8a060ce99c1ed56907cac3b87ebd6b + CloudFs: 00c27709e0957378cb033a328a2ec2967c890426 + CloudKitModule: 5916c184efb892c783329fd4ab588e1fb43e1211 CocoaLumberjack: 5644158777912b7de7469fa881f8a3f259c2512a - DnsLookup: 7740891737ed17e55b5daa69b44a53ee1c8950a0 + DnsLookup: 03b43735233e1cd7f41a7c26b1a3ffb6a18573cc DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb + EMASCurl: d75387e1ce9dec1a75cd25cb33c7a7e7bf21997f fast_float: b32c788ed9c6a8c584d114d0047beda9664e7cc6 FBLazyVector: a293a88992c4c33f0aee184acab0b64a08ff9458 fmt: 530618a01105dae0fa3a2f27c81ae11fa8f67eac glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 - hermes-engine: 70fdc9d0bb0d8532e0411dcb21e53ce5a160960a - KeychainModule: 3a44abae044f0afc2409fd6ec4e6c9d297596d0f + hermes-engine: 958f32aee412e1e575448212785d392b3620737a + KeychainModule: 70e2163cb445031a027369beb3b690f2493660cb MMKV: 1a8e7dbce7f9cad02c52e1b1091d07bd843aefaf MMKVCore: f2dd4c9befea04277a55e84e7812f930537993df - NetworkInfo: 2881c5ede983fb7540d07315e92b971729447104 + NetworkInfo: 0e192d1e71c292919d8176afef2c46f32e493d95 NitroMmkv: 0be91455465952f2b943f753b9ee7df028d89e5c NitroModules: 11bba9d065af151eae51e38a6425e04c3b223ff3 - Pbkdf2: c4d44d0220dada426d4c728000433cd530c4bba3 - PerpDepthBar: 49ddaff3c6402c344bb78d07ecd9844b9d1af880 - Ping: ff93df87bfd728cc5ee31ffbf54767f7ddff35cd + Pbkdf2: 8c8162fe154f194b97b593a9375c025c12bc2576 + PerpDepthBar: 635a72973cb0709a37bdfe2914efd97791096860 + Ping: b43b08ca15aa6ccd019939cd17956fcac49a15a9 RCT-Folly: b29feb752b08042c62badaef7d453f3bb5e6ae23 RCTDeprecation: 2b70c6e3abe00396cefd8913efbf6a2db01a2b36 RCTRequired: f3540eee8094231581d40c5c6d41b0f170237a81 @@ -4070,6 +4114,7 @@ SPEC CHECKSUMS: RCTTypeSafety: 6359ff3fcbe18c52059f4d4ce301e47f9da5f0d5 React: f6f8fc5c01e77349cdfaf49102bcb928ac31d8ed React-callinvoker: 032b6d1d03654b9fb7de9e2b3b978d3cb1a893ad + React-Codegen: 4b8b4817cea7a54b83851d4c1f91f79aa73de30a React-Core: 418c9278f8a071b44a88a87be9a4943234cc2e77 React-CoreModules: 925b8cb677649f967f6000f9b1ef74dc4ff60c30 React-cxxreact: 21f6f0cb2a7d26fbed4d09e04482e5c75662beaf @@ -4098,9 +4143,9 @@ SPEC CHECKSUMS: React-logger: 9e597cbeda7b8cc8aa8fb93860dade97190f69cc React-Mapbuffer: 20046c0447efaa7aace0b76085aa9bb35b0e8105 React-microtasksnativemodule: 0e837de56519c92d8a2e3097717df9497feb33cb - react-native-pager-view: 969b570d4df41d518b3a0c5064411c9f84f2fb0d + react-native-pager-view: d15ef77fcff7a97fde3d62163a75c284bbaf00ac react-native-safe-area-context: c00143b4823773bba23f2f19f85663ae89ceb460 - react-native-tab-view: 7c7102194026c5cf6f90d506afe07efa3f8a9fff + react-native-tab-view: 1c614f7158ba15256eb1d06b7850438cbfbd7d76 React-NativeModulesApple: 1a378198515f8e825c5931a7613e98da69320cee React-networking: bfd1695ada5a57023006ce05823ac5391c3ce072 React-oscompat: aedc0afbded67280de6bb6bfac8cfde0389e2b33 @@ -4134,29 +4179,30 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: ebcf3a78dc1bcdf054c9e8d309244bade6b31568 ReactCodegen: 554b421c45b7df35ac791da1b734335470b55fcc ReactCommon: 424cc34cf5055d69a3dcf02f3436481afb8b0f6f - ReactNativeAppUpdate: a8b8a599de1b75ddee435db8819fd3d846526dd9 - ReactNativeBundleCrypto: 040f2824b49eefdf6e8568b612cafdbd12a99f22 - ReactNativeBundleUpdate: 47c1720c0b5ef3f63dc1e2855b41825024e41799 - ReactNativeCheckBiometricAuthChanged: 461a0984bcc4c0b85db5aabc000a28edcd2a4d4c - ReactNativeDeviceUtils: 77d51784f92659c20e8134561625f591a772eb0e - ReactNativeGetRandomValues: e492be2cd6e3d5f4ad8582730596e7f2a1df75f8 - ReactNativeLiteCard: dec11c240dc327d0b85e3ff8aeca59c4be4da59d - ReactNativeNativeLogger: 008cd55912829c60bf85b5e52ff15d519e1fd210 - ReactNativePerfMemory: a21938afb8161413fea294fd8129a4b28da50d68 - ReactNativePerfStats: 200eb55b45ddb0ec4c23c7f0d55f76ade1c27b30 - ReactNativeRangeDownloader: 9372ed9319f42008f0570d0d0fa29b3c66d0a478 - ReactNativeSplashScreen: 00ee4d0da1f8eab079ff3eff5dc8d46bbbde7fb8 - ReactNativeZipArchive: 145faa8bf413ce7d45a74fac21e02a6566d84012 + ReactNativeAppUpdate: c80052c5c06ee0932ca6fcbe298f55f33572123f + ReactNativeBundleCrypto: 6788c8a44a1b27661b3431ebc2e76a65244e6dee + ReactNativeBundleUpdate: e20a9a4a774e4a9035c9d304f64fd78b2ab3bbad + ReactNativeCheckBiometricAuthChanged: 24d817aeb98898f5c898cac6461e55e67e26949a + ReactNativeDeviceUtils: 56d9e48388f07df84c7d6f0545dadb6bca8c870d + ReactNativeGetRandomValues: fe34a4a570625976f99414f3b2448deea839c316 + ReactNativeLiteCard: f42798ccb1d0fe21d2a36c46f03628c333a1716f + ReactNativeNativeLogger: 4840470e6d3dda83210088610733daaa0a3a4ead + ReactNativePerfMemory: 228f2f77918b9c891f20285ebde69d8f3d21b643 + ReactNativePerfStats: 425c37de36a447ef51d9b3d160e39cda147a489b + ReactNativeRangeDownloader: 0719fac32ce4ac8001507494f3a5e3709363342f + ReactNativeSplashScreen: a11ddbc7227731bbda9536fe2554658633c649e3 + ReactNativeZipArchive: 91f9619b5b4b56e66833cab53cf40bccac5ebf4b RNScreens: 7f643ee0fd1407dc5085c7795460bd93da113b8f - ScrollGuard: 9636f872bd88220f7631301698a1b3110d062914 - SegmentSlider: 07c1081fd70e173ba36de9194dd6c1b6e147c513 - Skeleton: a5ac4c29c713e591d237872f27d46faeb06aa556 + ScrollGuard: 78b788643a8c4cde25270785dae4ce6626ddb561 + SegmentSlider: 8a46e1fee2790e26b9755146b3acf8088a42fd71 + Skeleton: 8bac3ea35207d3b3fb29518e87ae095cd1546f88 + SniConnect: f4f7c741fdd9b81d1f5a582dd17d0a2f908930ed SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 - SplitBundleLoader: 120852ff0a02c3cb529ef1c722c40c06ae9865d0 + SplitBundleLoader: c1c5ef924f1e79e047a17ab1d9cd5f0dce4dcc18 SSZipArchive: 8a6ee5677c8e304bebc109e39cf0da91ccef22ea - TcpSocket: d9070521064fa987f6e348d4e94b74c37b81e08b + TcpSocket: 47439f8c7dab2660c6efe312da1197b3fd16b0b2 Yoga: 6ca93c8c13f56baeec55eb608577619b17a4d64e -PODFILE CHECKSUM: 11e5274bb8ec6380d5fbde829e700fe4f0cf9c8a +PODFILE CHECKSUM: 7dbc28b8923a22b4d9fcd35e5fe10389c4fccc09 COCOAPODS: 1.16.2 diff --git a/native-modules/react-native-sni-connect/ios/SniConnect.swift b/native-modules/react-native-sni-connect/ios/SniConnect.swift index 2ad98a4b..d1bc8d43 100644 --- a/native-modules/react-native-sni-connect/ios/SniConnect.swift +++ b/native-modules/react-native-sni-connect/ios/SniConnect.swift @@ -35,7 +35,15 @@ final class SniConnectImpl: NSObject { resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock ) { - // Create task and register it synchronously before it starts executing + do { + try Self.validate(config) + } catch { + SniConnectLog.error("Config validation failed: \(error)") + reject("SNI_INVALID_CONFIG", "\(error)", error) + return + } + + // Create the task and register it synchronously before JS can cancel it. let task = Task { () -> SniConnectClient.Response in return try await client.performRequest(config: config) } @@ -130,6 +138,14 @@ final class SniConnectImpl: NSObject { ) } + private static func validate(_ config: SniConnectClient.RequestConfig) throws { + try SniConnectValidation.validatePublicIP(config.ip) + try SniConnectValidation.validateHostname(config.hostname) + try SniConnectValidation.validateHeaders(config.headers) + _ = try SniConnectValidation.normalizeMethod(config.method) + _ = try SniConnectValidation.normalizePath(config.path) + } + private static func serializeResponseData(_ data: Any) -> String { if let stringValue = data as? String { return stringValue diff --git a/native-modules/react-native-sni-connect/ios/SniConnectClient.swift b/native-modules/react-native-sni-connect/ios/SniConnectClient.swift index 511d0736..92bf8840 100644 --- a/native-modules/react-native-sni-connect/ios/SniConnectClient.swift +++ b/native-modules/react-native-sni-connect/ios/SniConnectClient.swift @@ -206,7 +206,6 @@ final class SniConnectClient { let key = domain.lowercased() guard let entry = hostnameToEntry[key] else { return nil } if Date().timeIntervalSince1970 - entry.timestamp > ttl { - hostnameToEntry.removeValue(forKey: key) return nil } return entry.ip @@ -297,9 +296,9 @@ final class SniConnectClient { } } - /// Register an active task (async barrier; the task is already running). + /// Register an active task immediately after creation, before JS can cancel it. func registerTask(_ task: Task, for requestId: String) { - tasksQueue.async(flags: .barrier) { [weak self] in + tasksQueue.sync(flags: .barrier) { [weak self] in self?.activeTasks[requestId] = task } } diff --git a/native-modules/react-native-sni-connect/package.json b/native-modules/react-native-sni-connect/package.json index f934dfa3..dff1a1e0 100644 --- a/native-modules/react-native-sni-connect/package.json +++ b/native-modules/react-native-sni-connect/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-sni-connect", - "version": "3.0.66", + "version": "3.0.68", "description": "A React Native library for SNI-based HTTP requests with DNS caching and request management", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", From c7f9d254ac26c44cdc480e662e7496d2b304a047 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Mon, 29 Jun 2026 17:08:00 +0800 Subject: [PATCH 4/7] chore: bump version to 3.0.69 --- native-modules/native-logger/package.json | 2 +- native-modules/react-native-aes-crypto/package.json | 2 +- native-modules/react-native-app-update/package.json | 2 +- native-modules/react-native-async-storage/package.json | 2 +- native-modules/react-native-background-thread/package.json | 2 +- native-modules/react-native-bundle-crypto/package.json | 2 +- native-modules/react-native-bundle-update/package.json | 2 +- .../react-native-check-biometric-auth-changed/package.json | 2 +- native-modules/react-native-cloud-fs/package.json | 2 +- native-modules/react-native-cloud-kit-module/package.json | 2 +- native-modules/react-native-device-utils/package.json | 2 +- native-modules/react-native-dns-lookup/package.json | 2 +- native-modules/react-native-get-random-values/package.json | 2 +- native-modules/react-native-keychain-module/package.json | 2 +- native-modules/react-native-lite-card/package.json | 2 +- native-modules/react-native-network-info/package.json | 2 +- native-modules/react-native-pbkdf2/package.json | 2 +- native-modules/react-native-perf-memory/package.json | 2 +- native-modules/react-native-perf-stats/package.json | 2 +- native-modules/react-native-ping/package.json | 2 +- native-modules/react-native-range-downloader/package.json | 2 +- native-modules/react-native-sni-connect/package.json | 2 +- native-modules/react-native-splash-screen/package.json | 2 +- native-modules/react-native-split-bundle-loader/package.json | 2 +- native-modules/react-native-tcp-socket/package.json | 2 +- native-modules/react-native-zip-archive/package.json | 2 +- native-views/react-native-auto-size-input/package.json | 2 +- native-views/react-native-chart-webview/package.json | 2 +- native-views/react-native-pager-view/package.json | 2 +- native-views/react-native-perp-depth-bar/package.json | 2 +- native-views/react-native-scroll-guard/package.json | 2 +- native-views/react-native-segment-slider/package.json | 2 +- native-views/react-native-skeleton/package.json | 2 +- native-views/react-native-tab-view/package.json | 2 +- 34 files changed, 34 insertions(+), 34 deletions(-) diff --git a/native-modules/native-logger/package.json b/native-modules/native-logger/package.json index 12b2b881..46b48743 100644 --- a/native-modules/native-logger/package.json +++ b/native-modules/native-logger/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-native-logger", - "version": "3.0.68", + "version": "3.0.69", "description": "react-native-native-logger", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-aes-crypto/package.json b/native-modules/react-native-aes-crypto/package.json index ca714a54..b66bd2ed 100644 --- a/native-modules/react-native-aes-crypto/package.json +++ b/native-modules/react-native-aes-crypto/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-aes-crypto", - "version": "3.0.68", + "version": "3.0.69", "description": "react-native-aes-crypto", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-app-update/package.json b/native-modules/react-native-app-update/package.json index b107d006..521e5a0c 100644 --- a/native-modules/react-native-app-update/package.json +++ b/native-modules/react-native-app-update/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-app-update", - "version": "3.0.68", + "version": "3.0.69", "description": "react-native-app-update", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-async-storage/package.json b/native-modules/react-native-async-storage/package.json index 466c4216..50e01188 100644 --- a/native-modules/react-native-async-storage/package.json +++ b/native-modules/react-native-async-storage/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-async-storage", - "version": "3.0.68", + "version": "3.0.69", "description": "react-native-async-storage", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-background-thread/package.json b/native-modules/react-native-background-thread/package.json index 88489075..ebd0c55b 100644 --- a/native-modules/react-native-background-thread/package.json +++ b/native-modules/react-native-background-thread/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-background-thread", - "version": "3.0.68", + "version": "3.0.69", "description": "react-native-background-thread", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-bundle-crypto/package.json b/native-modules/react-native-bundle-crypto/package.json index 07ec40b3..c96bd61d 100644 --- a/native-modules/react-native-bundle-crypto/package.json +++ b/native-modules/react-native-bundle-crypto/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-bundle-crypto", - "version": "3.0.68", + "version": "3.0.69", "description": "react-native-bundle-crypto", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-bundle-update/package.json b/native-modules/react-native-bundle-update/package.json index 220d96fa..9a3ff27d 100644 --- a/native-modules/react-native-bundle-update/package.json +++ b/native-modules/react-native-bundle-update/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-bundle-update", - "version": "3.0.68", + "version": "3.0.69", "description": "react-native-bundle-update", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-check-biometric-auth-changed/package.json b/native-modules/react-native-check-biometric-auth-changed/package.json index 10c7c89f..eb11622b 100644 --- a/native-modules/react-native-check-biometric-auth-changed/package.json +++ b/native-modules/react-native-check-biometric-auth-changed/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-check-biometric-auth-changed", - "version": "3.0.68", + "version": "3.0.69", "description": "react-native-check-biometric-auth-changed", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-cloud-fs/package.json b/native-modules/react-native-cloud-fs/package.json index 6557c48a..5661c666 100644 --- a/native-modules/react-native-cloud-fs/package.json +++ b/native-modules/react-native-cloud-fs/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-cloud-fs", - "version": "3.0.68", + "version": "3.0.69", "description": "react-native-cloud-fs TurboModule for OneKey", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-cloud-kit-module/package.json b/native-modules/react-native-cloud-kit-module/package.json index 9d8d6a3b..34d43801 100644 --- a/native-modules/react-native-cloud-kit-module/package.json +++ b/native-modules/react-native-cloud-kit-module/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-cloud-kit-module", - "version": "3.0.68", + "version": "3.0.69", "description": "react-native-cloud-kit-module", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-device-utils/package.json b/native-modules/react-native-device-utils/package.json index a17ec499..796e0c04 100644 --- a/native-modules/react-native-device-utils/package.json +++ b/native-modules/react-native-device-utils/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-device-utils", - "version": "3.0.68", + "version": "3.0.69", "description": "react-native-device-utils", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-dns-lookup/package.json b/native-modules/react-native-dns-lookup/package.json index 1f0e86ba..59d09b90 100644 --- a/native-modules/react-native-dns-lookup/package.json +++ b/native-modules/react-native-dns-lookup/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-dns-lookup", - "version": "3.0.68", + "version": "3.0.69", "description": "react-native-dns-lookup", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-get-random-values/package.json b/native-modules/react-native-get-random-values/package.json index a012203a..1db04f5e 100644 --- a/native-modules/react-native-get-random-values/package.json +++ b/native-modules/react-native-get-random-values/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-get-random-values", - "version": "3.0.68", + "version": "3.0.69", "description": "react-native-get-random-values", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-keychain-module/package.json b/native-modules/react-native-keychain-module/package.json index 9642cf85..38ab12bf 100644 --- a/native-modules/react-native-keychain-module/package.json +++ b/native-modules/react-native-keychain-module/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-keychain-module", - "version": "3.0.68", + "version": "3.0.69", "description": "react-native-keychain-module", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-lite-card/package.json b/native-modules/react-native-lite-card/package.json index e70d6ef5..bf137fb8 100644 --- a/native-modules/react-native-lite-card/package.json +++ b/native-modules/react-native-lite-card/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-lite-card", - "version": "3.0.68", + "version": "3.0.69", "description": "lite card", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-network-info/package.json b/native-modules/react-native-network-info/package.json index ab7ed85f..67e3b9b2 100644 --- a/native-modules/react-native-network-info/package.json +++ b/native-modules/react-native-network-info/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-network-info", - "version": "3.0.68", + "version": "3.0.69", "description": "react-native-network-info", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-pbkdf2/package.json b/native-modules/react-native-pbkdf2/package.json index 0910ade1..28792211 100644 --- a/native-modules/react-native-pbkdf2/package.json +++ b/native-modules/react-native-pbkdf2/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-pbkdf2", - "version": "3.0.68", + "version": "3.0.69", "description": "react-native-pbkdf2", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-perf-memory/package.json b/native-modules/react-native-perf-memory/package.json index 8a4ec9e4..ba619c0f 100644 --- a/native-modules/react-native-perf-memory/package.json +++ b/native-modules/react-native-perf-memory/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-perf-memory", - "version": "3.0.68", + "version": "3.0.69", "description": "react-native-perf-memory", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-perf-stats/package.json b/native-modules/react-native-perf-stats/package.json index 2c8868e7..32ea3e5c 100644 --- a/native-modules/react-native-perf-stats/package.json +++ b/native-modules/react-native-perf-stats/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-perf-stats", - "version": "3.0.68", + "version": "3.0.69", "description": "react-native-perf-stats", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-ping/package.json b/native-modules/react-native-ping/package.json index 1b2c4577..b0eb6fe5 100644 --- a/native-modules/react-native-ping/package.json +++ b/native-modules/react-native-ping/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-ping", - "version": "3.0.68", + "version": "3.0.69", "description": "react-native-ping TurboModule for OneKey", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-range-downloader/package.json b/native-modules/react-native-range-downloader/package.json index 9dbc5e42..c599e534 100644 --- a/native-modules/react-native-range-downloader/package.json +++ b/native-modules/react-native-range-downloader/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-range-downloader", - "version": "3.0.68", + "version": "3.0.69", "description": "react-native-range-downloader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-sni-connect/package.json b/native-modules/react-native-sni-connect/package.json index dff1a1e0..edeaa41b 100644 --- a/native-modules/react-native-sni-connect/package.json +++ b/native-modules/react-native-sni-connect/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-sni-connect", - "version": "3.0.68", + "version": "3.0.69", "description": "A React Native library for SNI-based HTTP requests with DNS caching and request management", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-splash-screen/package.json b/native-modules/react-native-splash-screen/package.json index 140665e5..e7b568e6 100644 --- a/native-modules/react-native-splash-screen/package.json +++ b/native-modules/react-native-splash-screen/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-splash-screen", - "version": "3.0.68", + "version": "3.0.69", "description": "react-native-splash-screen", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-split-bundle-loader/package.json b/native-modules/react-native-split-bundle-loader/package.json index 4a5ca3aa..8cfb1488 100644 --- a/native-modules/react-native-split-bundle-loader/package.json +++ b/native-modules/react-native-split-bundle-loader/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-split-bundle-loader", - "version": "3.0.68", + "version": "3.0.69", "description": "react-native-split-bundle-loader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-tcp-socket/package.json b/native-modules/react-native-tcp-socket/package.json index c7df46b8..33ca75fd 100644 --- a/native-modules/react-native-tcp-socket/package.json +++ b/native-modules/react-native-tcp-socket/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-tcp-socket", - "version": "3.0.68", + "version": "3.0.69", "description": "react-native-tcp-socket", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-zip-archive/package.json b/native-modules/react-native-zip-archive/package.json index ee33b7f9..25e602b7 100644 --- a/native-modules/react-native-zip-archive/package.json +++ b/native-modules/react-native-zip-archive/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-zip-archive", - "version": "3.0.68", + "version": "3.0.69", "description": "react-native-zip-archive Nitro HybridObject for OneKey", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-auto-size-input/package.json b/native-views/react-native-auto-size-input/package.json index 84616f60..7647cec2 100644 --- a/native-views/react-native-auto-size-input/package.json +++ b/native-views/react-native-auto-size-input/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-auto-size-input", - "version": "3.0.68", + "version": "3.0.69", "description": "Auto-sizing text input with font scaling, prefix and suffix support", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-chart-webview/package.json b/native-views/react-native-chart-webview/package.json index a958bec5..35a1692f 100644 --- a/native-views/react-native-chart-webview/package.json +++ b/native-views/react-native-chart-webview/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-chart-webview", - "version": "3.0.68", + "version": "3.0.69", "description": "react-native-chart-webview", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-pager-view/package.json b/native-views/react-native-pager-view/package.json index 6fbc3b71..033f0e32 100644 --- a/native-views/react-native-pager-view/package.json +++ b/native-views/react-native-pager-view/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-pager-view", - "version": "3.0.68", + "version": "3.0.69", "description": "React Native wrapper for Android and iOS ViewPager", "source": "./src/index.tsx", "main": "./lib/module/index.js", diff --git a/native-views/react-native-perp-depth-bar/package.json b/native-views/react-native-perp-depth-bar/package.json index 8d947b7b..d3f21d19 100644 --- a/native-views/react-native-perp-depth-bar/package.json +++ b/native-views/react-native-perp-depth-bar/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-perp-depth-bar", - "version": "3.0.68", + "version": "3.0.69", "description": "react-native-perp-depth-bar", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-scroll-guard/package.json b/native-views/react-native-scroll-guard/package.json index 82400910..f677ceff 100644 --- a/native-views/react-native-scroll-guard/package.json +++ b/native-views/react-native-scroll-guard/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-scroll-guard", - "version": "3.0.68", + "version": "3.0.69", "description": "A native view wrapper that prevents parent scrollable containers (PagerView/ViewPager2) from intercepting child scroll gestures", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-segment-slider/package.json b/native-views/react-native-segment-slider/package.json index 321974e1..32319f67 100644 --- a/native-views/react-native-segment-slider/package.json +++ b/native-views/react-native-segment-slider/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-segment-slider", - "version": "3.0.68", + "version": "3.0.69", "description": "react-native-segment-slider", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-skeleton/package.json b/native-views/react-native-skeleton/package.json index 5f6073ad..32ab7fc3 100644 --- a/native-views/react-native-skeleton/package.json +++ b/native-views/react-native-skeleton/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-skeleton", - "version": "3.0.68", + "version": "3.0.69", "description": "react-native-skeleton", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-tab-view/package.json b/native-views/react-native-tab-view/package.json index 127b8f8c..ccdc058e 100644 --- a/native-views/react-native-tab-view/package.json +++ b/native-views/react-native-tab-view/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-tab-view", - "version": "3.0.68", + "version": "3.0.69", "description": "Native Bottom Tabs for React Native (UIKit implementation)", "source": "./src/index.tsx", "main": "./lib/module/index.js", From 84bf63313779f4d66099f4b1ebc63f0e2a7551c3 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Mon, 29 Jun 2026 17:19:16 +0800 Subject: [PATCH 5/7] chore: bump version to 3.0.70 --- native-modules/native-logger/package.json | 2 +- native-modules/react-native-aes-crypto/package.json | 2 +- native-modules/react-native-app-update/package.json | 2 +- native-modules/react-native-async-storage/package.json | 2 +- native-modules/react-native-background-thread/package.json | 2 +- native-modules/react-native-bundle-crypto/package.json | 2 +- native-modules/react-native-bundle-update/package.json | 2 +- .../react-native-check-biometric-auth-changed/package.json | 2 +- native-modules/react-native-cloud-fs/package.json | 2 +- native-modules/react-native-cloud-kit-module/package.json | 2 +- native-modules/react-native-device-utils/package.json | 2 +- native-modules/react-native-dns-lookup/package.json | 2 +- native-modules/react-native-get-random-values/package.json | 2 +- native-modules/react-native-keychain-module/package.json | 2 +- native-modules/react-native-lite-card/package.json | 2 +- native-modules/react-native-network-info/package.json | 2 +- native-modules/react-native-pbkdf2/package.json | 2 +- native-modules/react-native-perf-memory/package.json | 2 +- native-modules/react-native-perf-stats/package.json | 2 +- native-modules/react-native-ping/package.json | 2 +- native-modules/react-native-range-downloader/package.json | 2 +- native-modules/react-native-sni-connect/package.json | 2 +- native-modules/react-native-splash-screen/package.json | 2 +- native-modules/react-native-split-bundle-loader/package.json | 2 +- native-modules/react-native-tcp-socket/package.json | 2 +- native-modules/react-native-zip-archive/package.json | 2 +- native-views/react-native-auto-size-input/package.json | 2 +- native-views/react-native-chart-webview/package.json | 2 +- native-views/react-native-pager-view/package.json | 2 +- native-views/react-native-perp-depth-bar/package.json | 2 +- native-views/react-native-scroll-guard/package.json | 2 +- native-views/react-native-segment-slider/package.json | 2 +- native-views/react-native-skeleton/package.json | 2 +- native-views/react-native-tab-view/package.json | 2 +- 34 files changed, 34 insertions(+), 34 deletions(-) diff --git a/native-modules/native-logger/package.json b/native-modules/native-logger/package.json index 46b48743..d7dab29a 100644 --- a/native-modules/native-logger/package.json +++ b/native-modules/native-logger/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-native-logger", - "version": "3.0.69", + "version": "3.0.70", "description": "react-native-native-logger", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-aes-crypto/package.json b/native-modules/react-native-aes-crypto/package.json index b66bd2ed..ed5182f3 100644 --- a/native-modules/react-native-aes-crypto/package.json +++ b/native-modules/react-native-aes-crypto/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-aes-crypto", - "version": "3.0.69", + "version": "3.0.70", "description": "react-native-aes-crypto", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-app-update/package.json b/native-modules/react-native-app-update/package.json index 521e5a0c..f094eec0 100644 --- a/native-modules/react-native-app-update/package.json +++ b/native-modules/react-native-app-update/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-app-update", - "version": "3.0.69", + "version": "3.0.70", "description": "react-native-app-update", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-async-storage/package.json b/native-modules/react-native-async-storage/package.json index 50e01188..9fe2649f 100644 --- a/native-modules/react-native-async-storage/package.json +++ b/native-modules/react-native-async-storage/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-async-storage", - "version": "3.0.69", + "version": "3.0.70", "description": "react-native-async-storage", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-background-thread/package.json b/native-modules/react-native-background-thread/package.json index ebd0c55b..f869decb 100644 --- a/native-modules/react-native-background-thread/package.json +++ b/native-modules/react-native-background-thread/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-background-thread", - "version": "3.0.69", + "version": "3.0.70", "description": "react-native-background-thread", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-bundle-crypto/package.json b/native-modules/react-native-bundle-crypto/package.json index c96bd61d..d5edc615 100644 --- a/native-modules/react-native-bundle-crypto/package.json +++ b/native-modules/react-native-bundle-crypto/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-bundle-crypto", - "version": "3.0.69", + "version": "3.0.70", "description": "react-native-bundle-crypto", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-bundle-update/package.json b/native-modules/react-native-bundle-update/package.json index 9a3ff27d..861a62f9 100644 --- a/native-modules/react-native-bundle-update/package.json +++ b/native-modules/react-native-bundle-update/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-bundle-update", - "version": "3.0.69", + "version": "3.0.70", "description": "react-native-bundle-update", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-check-biometric-auth-changed/package.json b/native-modules/react-native-check-biometric-auth-changed/package.json index eb11622b..e7f89297 100644 --- a/native-modules/react-native-check-biometric-auth-changed/package.json +++ b/native-modules/react-native-check-biometric-auth-changed/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-check-biometric-auth-changed", - "version": "3.0.69", + "version": "3.0.70", "description": "react-native-check-biometric-auth-changed", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-cloud-fs/package.json b/native-modules/react-native-cloud-fs/package.json index 5661c666..1849043e 100644 --- a/native-modules/react-native-cloud-fs/package.json +++ b/native-modules/react-native-cloud-fs/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-cloud-fs", - "version": "3.0.69", + "version": "3.0.70", "description": "react-native-cloud-fs TurboModule for OneKey", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-cloud-kit-module/package.json b/native-modules/react-native-cloud-kit-module/package.json index 34d43801..b17af4ed 100644 --- a/native-modules/react-native-cloud-kit-module/package.json +++ b/native-modules/react-native-cloud-kit-module/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-cloud-kit-module", - "version": "3.0.69", + "version": "3.0.70", "description": "react-native-cloud-kit-module", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-device-utils/package.json b/native-modules/react-native-device-utils/package.json index 796e0c04..6bc357ae 100644 --- a/native-modules/react-native-device-utils/package.json +++ b/native-modules/react-native-device-utils/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-device-utils", - "version": "3.0.69", + "version": "3.0.70", "description": "react-native-device-utils", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-dns-lookup/package.json b/native-modules/react-native-dns-lookup/package.json index 59d09b90..999d3dc2 100644 --- a/native-modules/react-native-dns-lookup/package.json +++ b/native-modules/react-native-dns-lookup/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-dns-lookup", - "version": "3.0.69", + "version": "3.0.70", "description": "react-native-dns-lookup", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-get-random-values/package.json b/native-modules/react-native-get-random-values/package.json index 1db04f5e..eaf032a3 100644 --- a/native-modules/react-native-get-random-values/package.json +++ b/native-modules/react-native-get-random-values/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-get-random-values", - "version": "3.0.69", + "version": "3.0.70", "description": "react-native-get-random-values", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-keychain-module/package.json b/native-modules/react-native-keychain-module/package.json index 38ab12bf..af44a839 100644 --- a/native-modules/react-native-keychain-module/package.json +++ b/native-modules/react-native-keychain-module/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-keychain-module", - "version": "3.0.69", + "version": "3.0.70", "description": "react-native-keychain-module", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-lite-card/package.json b/native-modules/react-native-lite-card/package.json index bf137fb8..127c820b 100644 --- a/native-modules/react-native-lite-card/package.json +++ b/native-modules/react-native-lite-card/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-lite-card", - "version": "3.0.69", + "version": "3.0.70", "description": "lite card", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-network-info/package.json b/native-modules/react-native-network-info/package.json index 67e3b9b2..d832fd8b 100644 --- a/native-modules/react-native-network-info/package.json +++ b/native-modules/react-native-network-info/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-network-info", - "version": "3.0.69", + "version": "3.0.70", "description": "react-native-network-info", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-pbkdf2/package.json b/native-modules/react-native-pbkdf2/package.json index 28792211..533ebbe0 100644 --- a/native-modules/react-native-pbkdf2/package.json +++ b/native-modules/react-native-pbkdf2/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-pbkdf2", - "version": "3.0.69", + "version": "3.0.70", "description": "react-native-pbkdf2", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-perf-memory/package.json b/native-modules/react-native-perf-memory/package.json index ba619c0f..95e28b9b 100644 --- a/native-modules/react-native-perf-memory/package.json +++ b/native-modules/react-native-perf-memory/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-perf-memory", - "version": "3.0.69", + "version": "3.0.70", "description": "react-native-perf-memory", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-perf-stats/package.json b/native-modules/react-native-perf-stats/package.json index 32ea3e5c..de37de5d 100644 --- a/native-modules/react-native-perf-stats/package.json +++ b/native-modules/react-native-perf-stats/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-perf-stats", - "version": "3.0.69", + "version": "3.0.70", "description": "react-native-perf-stats", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-ping/package.json b/native-modules/react-native-ping/package.json index b0eb6fe5..b093166f 100644 --- a/native-modules/react-native-ping/package.json +++ b/native-modules/react-native-ping/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-ping", - "version": "3.0.69", + "version": "3.0.70", "description": "react-native-ping TurboModule for OneKey", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-range-downloader/package.json b/native-modules/react-native-range-downloader/package.json index c599e534..f000d768 100644 --- a/native-modules/react-native-range-downloader/package.json +++ b/native-modules/react-native-range-downloader/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-range-downloader", - "version": "3.0.69", + "version": "3.0.70", "description": "react-native-range-downloader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-sni-connect/package.json b/native-modules/react-native-sni-connect/package.json index edeaa41b..a8c95463 100644 --- a/native-modules/react-native-sni-connect/package.json +++ b/native-modules/react-native-sni-connect/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-sni-connect", - "version": "3.0.69", + "version": "3.0.70", "description": "A React Native library for SNI-based HTTP requests with DNS caching and request management", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-splash-screen/package.json b/native-modules/react-native-splash-screen/package.json index e7b568e6..10086dd7 100644 --- a/native-modules/react-native-splash-screen/package.json +++ b/native-modules/react-native-splash-screen/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-splash-screen", - "version": "3.0.69", + "version": "3.0.70", "description": "react-native-splash-screen", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-split-bundle-loader/package.json b/native-modules/react-native-split-bundle-loader/package.json index 8cfb1488..b03eabe9 100644 --- a/native-modules/react-native-split-bundle-loader/package.json +++ b/native-modules/react-native-split-bundle-loader/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-split-bundle-loader", - "version": "3.0.69", + "version": "3.0.70", "description": "react-native-split-bundle-loader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-tcp-socket/package.json b/native-modules/react-native-tcp-socket/package.json index 33ca75fd..dc955441 100644 --- a/native-modules/react-native-tcp-socket/package.json +++ b/native-modules/react-native-tcp-socket/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-tcp-socket", - "version": "3.0.69", + "version": "3.0.70", "description": "react-native-tcp-socket", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-zip-archive/package.json b/native-modules/react-native-zip-archive/package.json index 25e602b7..f6299242 100644 --- a/native-modules/react-native-zip-archive/package.json +++ b/native-modules/react-native-zip-archive/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-zip-archive", - "version": "3.0.69", + "version": "3.0.70", "description": "react-native-zip-archive Nitro HybridObject for OneKey", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-auto-size-input/package.json b/native-views/react-native-auto-size-input/package.json index 7647cec2..39a26634 100644 --- a/native-views/react-native-auto-size-input/package.json +++ b/native-views/react-native-auto-size-input/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-auto-size-input", - "version": "3.0.69", + "version": "3.0.70", "description": "Auto-sizing text input with font scaling, prefix and suffix support", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-chart-webview/package.json b/native-views/react-native-chart-webview/package.json index 35a1692f..34ed9aef 100644 --- a/native-views/react-native-chart-webview/package.json +++ b/native-views/react-native-chart-webview/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-chart-webview", - "version": "3.0.69", + "version": "3.0.70", "description": "react-native-chart-webview", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-pager-view/package.json b/native-views/react-native-pager-view/package.json index 033f0e32..6df24ca7 100644 --- a/native-views/react-native-pager-view/package.json +++ b/native-views/react-native-pager-view/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-pager-view", - "version": "3.0.69", + "version": "3.0.70", "description": "React Native wrapper for Android and iOS ViewPager", "source": "./src/index.tsx", "main": "./lib/module/index.js", diff --git a/native-views/react-native-perp-depth-bar/package.json b/native-views/react-native-perp-depth-bar/package.json index d3f21d19..e90eefe4 100644 --- a/native-views/react-native-perp-depth-bar/package.json +++ b/native-views/react-native-perp-depth-bar/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-perp-depth-bar", - "version": "3.0.69", + "version": "3.0.70", "description": "react-native-perp-depth-bar", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-scroll-guard/package.json b/native-views/react-native-scroll-guard/package.json index f677ceff..b0ea29ae 100644 --- a/native-views/react-native-scroll-guard/package.json +++ b/native-views/react-native-scroll-guard/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-scroll-guard", - "version": "3.0.69", + "version": "3.0.70", "description": "A native view wrapper that prevents parent scrollable containers (PagerView/ViewPager2) from intercepting child scroll gestures", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-segment-slider/package.json b/native-views/react-native-segment-slider/package.json index 32319f67..4926fc92 100644 --- a/native-views/react-native-segment-slider/package.json +++ b/native-views/react-native-segment-slider/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-segment-slider", - "version": "3.0.69", + "version": "3.0.70", "description": "react-native-segment-slider", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-skeleton/package.json b/native-views/react-native-skeleton/package.json index 32ab7fc3..6933f68c 100644 --- a/native-views/react-native-skeleton/package.json +++ b/native-views/react-native-skeleton/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-skeleton", - "version": "3.0.69", + "version": "3.0.70", "description": "react-native-skeleton", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-tab-view/package.json b/native-views/react-native-tab-view/package.json index ccdc058e..4e2ae85f 100644 --- a/native-views/react-native-tab-view/package.json +++ b/native-views/react-native-tab-view/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-tab-view", - "version": "3.0.69", + "version": "3.0.70", "description": "Native Bottom Tabs for React Native (UIKit implementation)", "source": "./src/index.tsx", "main": "./lib/module/index.js", From d09d00dfa4b0fb1b4619ba856c68454c3e2de9c3 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Mon, 29 Jun 2026 18:44:23 +0800 Subject: [PATCH 6/7] fix(sni-connect): address ios timeout and pod deps --- example/react-native/ios/Podfile.lock | 147 +++++++++--------- native-modules/native-logger/package.json | 2 +- .../react-native-aes-crypto/package.json | 2 +- .../react-native-app-update/package.json | 2 +- .../react-native-async-storage/package.json | 2 +- .../package.json | 2 +- .../react-native-bundle-crypto/package.json | 2 +- .../react-native-bundle-update/package.json | 2 +- .../package.json | 2 +- .../react-native-cloud-fs/package.json | 2 +- .../package.json | 2 +- .../react-native-device-utils/package.json | 2 +- .../react-native-dns-lookup/package.json | 2 +- .../package.json | 2 +- .../react-native-keychain-module/package.json | 2 +- .../react-native-lite-card/package.json | 2 +- .../react-native-network-info/package.json | 2 +- .../react-native-pbkdf2/package.json | 2 +- .../react-native-perf-memory/package.json | 2 +- .../react-native-perf-stats/package.json | 2 +- native-modules/react-native-ping/package.json | 2 +- .../package.json | 2 +- .../SniConnect.podspec | 1 - .../ios/SniConnectClient.swift | 2 +- .../react-native-sni-connect/package.json | 2 +- .../react-native-splash-screen/package.json | 2 +- .../package.json | 2 +- .../react-native-tcp-socket/package.json | 2 +- .../react-native-zip-archive/package.json | 2 +- .../react-native-auto-size-input/package.json | 2 +- .../react-native-chart-webview/package.json | 2 +- .../react-native-pager-view/package.json | 2 +- .../react-native-perp-depth-bar/package.json | 2 +- .../react-native-scroll-guard/package.json | 2 +- .../react-native-segment-slider/package.json | 2 +- .../react-native-skeleton/package.json | 2 +- .../react-native-tab-view/package.json | 2 +- 37 files changed, 106 insertions(+), 112 deletions(-) diff --git a/example/react-native/ios/Podfile.lock b/example/react-native/ios/Podfile.lock index dd5555ff..75e96689 100644 --- a/example/react-native/ios/Podfile.lock +++ b/example/react-native/ios/Podfile.lock @@ -1,5 +1,5 @@ PODS: - - AesCrypto (3.0.68): + - AesCrypto (3.0.71): - boost - DoubleConversion - fast_float @@ -27,7 +27,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - AsyncStorage (3.0.68): + - AsyncStorage (3.0.71): - boost - DoubleConversion - fast_float @@ -55,7 +55,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - AutoSizeInput (3.0.68): + - AutoSizeInput (3.0.71): - boost - DoubleConversion - fast_float @@ -85,7 +85,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - BackgroundThread (3.0.68): + - BackgroundThread (3.0.71): - boost - DoubleConversion - fast_float @@ -115,7 +115,7 @@ PODS: - SocketRocket - Yoga - boost (1.84.0) - - ChartWebview (3.0.68): + - ChartWebview (3.0.71): - boost - DoubleConversion - fast_float @@ -146,7 +146,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - CloudFs (3.0.68): + - CloudFs (3.0.71): - boost - DoubleConversion - fast_float @@ -174,7 +174,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - CloudKitModule (3.0.68): + - CloudKitModule (3.0.71): - boost - DoubleConversion - fast_float @@ -208,7 +208,7 @@ PODS: - CocoaLumberjack/Core (3.9.0) - CocoaLumberjack/Swift (3.9.0): - CocoaLumberjack/Core - - DnsLookup (3.0.68): + - DnsLookup (3.0.71): - boost - DoubleConversion - fast_float @@ -247,7 +247,7 @@ PODS: - hermes-engine (0.14.0): - hermes-engine/Pre-built (= 0.14.0) - hermes-engine/Pre-built (0.14.0) - - KeychainModule (3.0.68): + - KeychainModule (3.0.71): - boost - DoubleConversion - fast_float @@ -281,7 +281,7 @@ PODS: - MMKV (2.2.4): - MMKVCore (~> 2.2.4) - MMKVCore (2.2.4) - - NetworkInfo (3.0.68): + - NetworkInfo (3.0.71): - boost - DoubleConversion - fast_float @@ -369,7 +369,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - Pbkdf2 (3.0.68): + - Pbkdf2 (3.0.71): - boost - DoubleConversion - fast_float @@ -397,7 +397,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - PerpDepthBar (3.0.68): + - PerpDepthBar (3.0.71): - boost - DoubleConversion - fast_float @@ -427,7 +427,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - Ping (3.0.68): + - Ping (3.0.71): - boost - DoubleConversion - fast_float @@ -497,7 +497,6 @@ PODS: - React-RCTText (= 0.83.0) - React-RCTVibration (= 0.83.0) - React-callinvoker (0.83.0) - - React-Codegen (0.1.0) - React-Core (0.83.0): - boost - DoubleConversion @@ -2340,7 +2339,7 @@ PODS: - React-RCTFBReactNativeSpec - ReactCommon/turbomodule/core - SocketRocket - - react-native-pager-view (3.0.68): + - react-native-pager-view (3.0.71): - boost - DoubleConversion - fast_float @@ -2455,7 +2454,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - react-native-tab-view (3.0.68): + - react-native-tab-view (3.0.71): - boost - DoubleConversion - fast_float @@ -2473,7 +2472,7 @@ PODS: - React-graphics - React-ImageManager - React-jsi - - react-native-tab-view/common (= 3.0.68) + - react-native-tab-view/common (= 3.0.71) - React-NativeModulesApple - React-RCTFabric - React-renderercss @@ -2484,7 +2483,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - react-native-tab-view/common (3.0.68): + - react-native-tab-view/common (3.0.71): - boost - DoubleConversion - fast_float @@ -3069,7 +3068,7 @@ PODS: - React-perflogger (= 0.83.0) - React-utils (= 0.83.0) - SocketRocket - - ReactNativeAppUpdate (3.0.68): + - ReactNativeAppUpdate (3.0.71): - boost - DoubleConversion - fast_float @@ -3100,7 +3099,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeBundleCrypto (3.0.68): + - ReactNativeBundleCrypto (3.0.71): - boost - DoubleConversion - fast_float @@ -3131,7 +3130,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeBundleUpdate (3.0.68): + - ReactNativeBundleUpdate (3.0.71): - boost - DoubleConversion - fast_float @@ -3166,7 +3165,7 @@ PODS: - SocketRocket - SSZipArchive (>= 2.5.4) - Yoga - - ReactNativeCheckBiometricAuthChanged (3.0.68): + - ReactNativeCheckBiometricAuthChanged (3.0.71): - boost - DoubleConversion - fast_float @@ -3197,7 +3196,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeDeviceUtils (3.0.68): + - ReactNativeDeviceUtils (3.0.71): - boost - DoubleConversion - fast_float @@ -3228,7 +3227,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeGetRandomValues (3.0.68): + - ReactNativeGetRandomValues (3.0.71): - boost - DoubleConversion - fast_float @@ -3259,7 +3258,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeLiteCard (3.0.68): + - ReactNativeLiteCard (3.0.71): - boost - DoubleConversion - fast_float @@ -3288,7 +3287,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeNativeLogger (3.0.68): + - ReactNativeNativeLogger (3.0.71): - boost - CocoaLumberjack/Swift (~> 3.8) - DoubleConversion @@ -3319,7 +3318,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - ReactNativePerfMemory (3.0.68): + - ReactNativePerfMemory (3.0.71): - boost - DoubleConversion - fast_float @@ -3350,7 +3349,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativePerfStats (3.0.68): + - ReactNativePerfStats (3.0.71): - boost - DoubleConversion - fast_float @@ -3381,7 +3380,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeRangeDownloader (3.0.68): + - ReactNativeRangeDownloader (3.0.71): - boost - DoubleConversion - fast_float @@ -3412,7 +3411,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeSplashScreen (3.0.68): + - ReactNativeSplashScreen (3.0.71): - boost - DoubleConversion - fast_float @@ -3443,7 +3442,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeZipArchive (3.0.68): + - ReactNativeZipArchive (3.0.71): - boost - DoubleConversion - fast_float @@ -3532,7 +3531,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - ScrollGuard (3.0.68): + - ScrollGuard (3.0.71): - boost - DoubleConversion - fast_float @@ -3562,7 +3561,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - SegmentSlider (3.0.68): + - SegmentSlider (3.0.71): - boost - DoubleConversion - fast_float @@ -3592,7 +3591,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - Skeleton (3.0.68): + - Skeleton (3.0.71): - boost - DoubleConversion - fast_float @@ -3622,7 +3621,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - SniConnect (3.0.68): + - SniConnect (3.0.71): - boost - DoubleConversion - EMASCurl (= 1.5.5) @@ -3635,7 +3634,6 @@ PODS: - RCTRequired - RCTTypeSafety - React-callinvoker - - React-Codegen - React-Core - React-debug - React-Fabric @@ -3654,7 +3652,7 @@ PODS: - SocketRocket - Yoga - SocketRocket (0.7.1) - - SplitBundleLoader (3.0.68): + - SplitBundleLoader (3.0.71): - boost - DoubleConversion - fast_float @@ -3684,7 +3682,7 @@ PODS: - SocketRocket - Yoga - SSZipArchive (2.6.0) - - TcpSocket (3.0.68): + - TcpSocket (3.0.71): - boost - DoubleConversion - fast_float @@ -3837,8 +3835,6 @@ DEPENDENCIES: SPEC REPOS: https://github.com/aliyun/aliyun-specs.git: - EMASCurl - https://github.com/CocoaPods/Specs.git: - - React-Codegen trunk: - CocoaLumberjack - MMKV @@ -4080,32 +4076,32 @@ EXTERNAL SOURCES: :path: "../../../node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: - AesCrypto: 7cda96128d422b29e70d465d59df02bc401353c3 - AsyncStorage: d30dc3668406e28fa4f53f9b1c72c762d69f167b - AutoSizeInput: e838418dc215a2c32b4c1ac14f9dbc852ac12b9f - BackgroundThread: b30869d3ec7c4ec47376bb8a9421872645de4f7a + AesCrypto: 0b2a2324f087de8e1665133f9c1464ea4bde83a3 + AsyncStorage: 1de0dead89eccf2623045576e0f0945962652cf1 + AutoSizeInput: 3cb849b567908ca72dd096ae9210f7102a03d956 + BackgroundThread: 62251079111cd3d7d2b87d82ac5ec4036b53ed36 boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90 - ChartWebview: cf27421fbe8a060ce99c1ed56907cac3b87ebd6b - CloudFs: 00c27709e0957378cb033a328a2ec2967c890426 - CloudKitModule: 5916c184efb892c783329fd4ab588e1fb43e1211 + ChartWebview: 39482589711961037e10cde1c6457aa7cc5121c6 + CloudFs: 63f86809062935ab5772befaa9e46538e581cbfb + CloudKitModule: 5bc9c8a4008a6e3ab3ff06940320de87ca9fbafc CocoaLumberjack: 5644158777912b7de7469fa881f8a3f259c2512a - DnsLookup: 03b43735233e1cd7f41a7c26b1a3ffb6a18573cc + DnsLookup: 83b131ce23646c7480855f4f63ab5b0105f7a30e DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb EMASCurl: d75387e1ce9dec1a75cd25cb33c7a7e7bf21997f fast_float: b32c788ed9c6a8c584d114d0047beda9664e7cc6 FBLazyVector: a293a88992c4c33f0aee184acab0b64a08ff9458 fmt: 530618a01105dae0fa3a2f27c81ae11fa8f67eac glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 - hermes-engine: 958f32aee412e1e575448212785d392b3620737a - KeychainModule: 70e2163cb445031a027369beb3b690f2493660cb + hermes-engine: 70fdc9d0bb0d8532e0411dcb21e53ce5a160960a + KeychainModule: da59c5ebc7b5f254ceff0fdfc4e7c497cfa6c46f MMKV: 1a8e7dbce7f9cad02c52e1b1091d07bd843aefaf MMKVCore: f2dd4c9befea04277a55e84e7812f930537993df - NetworkInfo: 0e192d1e71c292919d8176afef2c46f32e493d95 + NetworkInfo: f2ee90b50429387407fa94b26cc95631f1617b5f NitroMmkv: 0be91455465952f2b943f753b9ee7df028d89e5c NitroModules: 11bba9d065af151eae51e38a6425e04c3b223ff3 - Pbkdf2: 8c8162fe154f194b97b593a9375c025c12bc2576 - PerpDepthBar: 635a72973cb0709a37bdfe2914efd97791096860 - Ping: b43b08ca15aa6ccd019939cd17956fcac49a15a9 + Pbkdf2: 563496195ac73317f6ab7cc7bb9e5f70c2e55d80 + PerpDepthBar: 603e8bb6830880e133875813f64d7c3bdb53444f + Ping: ca2939773846337cd6c41e7eb95190bab1ef57ed RCT-Folly: b29feb752b08042c62badaef7d453f3bb5e6ae23 RCTDeprecation: 2b70c6e3abe00396cefd8913efbf6a2db01a2b36 RCTRequired: f3540eee8094231581d40c5c6d41b0f170237a81 @@ -4114,7 +4110,6 @@ SPEC CHECKSUMS: RCTTypeSafety: 6359ff3fcbe18c52059f4d4ce301e47f9da5f0d5 React: f6f8fc5c01e77349cdfaf49102bcb928ac31d8ed React-callinvoker: 032b6d1d03654b9fb7de9e2b3b978d3cb1a893ad - React-Codegen: 4b8b4817cea7a54b83851d4c1f91f79aa73de30a React-Core: 418c9278f8a071b44a88a87be9a4943234cc2e77 React-CoreModules: 925b8cb677649f967f6000f9b1ef74dc4ff60c30 React-cxxreact: 21f6f0cb2a7d26fbed4d09e04482e5c75662beaf @@ -4143,9 +4138,9 @@ SPEC CHECKSUMS: React-logger: 9e597cbeda7b8cc8aa8fb93860dade97190f69cc React-Mapbuffer: 20046c0447efaa7aace0b76085aa9bb35b0e8105 React-microtasksnativemodule: 0e837de56519c92d8a2e3097717df9497feb33cb - react-native-pager-view: d15ef77fcff7a97fde3d62163a75c284bbaf00ac + react-native-pager-view: 3a1ea7c45e5f43e8a9508273972faa67a9b94f5d react-native-safe-area-context: c00143b4823773bba23f2f19f85663ae89ceb460 - react-native-tab-view: 1c614f7158ba15256eb1d06b7850438cbfbd7d76 + react-native-tab-view: bd22eae498b0ac0af08fe17ae011f308e8f8799a React-NativeModulesApple: 1a378198515f8e825c5931a7613e98da69320cee React-networking: bfd1695ada5a57023006ce05823ac5391c3ce072 React-oscompat: aedc0afbded67280de6bb6bfac8cfde0389e2b33 @@ -4179,28 +4174,28 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: ebcf3a78dc1bcdf054c9e8d309244bade6b31568 ReactCodegen: 554b421c45b7df35ac791da1b734335470b55fcc ReactCommon: 424cc34cf5055d69a3dcf02f3436481afb8b0f6f - ReactNativeAppUpdate: c80052c5c06ee0932ca6fcbe298f55f33572123f - ReactNativeBundleCrypto: 6788c8a44a1b27661b3431ebc2e76a65244e6dee - ReactNativeBundleUpdate: e20a9a4a774e4a9035c9d304f64fd78b2ab3bbad - ReactNativeCheckBiometricAuthChanged: 24d817aeb98898f5c898cac6461e55e67e26949a - ReactNativeDeviceUtils: 56d9e48388f07df84c7d6f0545dadb6bca8c870d - ReactNativeGetRandomValues: fe34a4a570625976f99414f3b2448deea839c316 - ReactNativeLiteCard: f42798ccb1d0fe21d2a36c46f03628c333a1716f - ReactNativeNativeLogger: 4840470e6d3dda83210088610733daaa0a3a4ead - ReactNativePerfMemory: 228f2f77918b9c891f20285ebde69d8f3d21b643 - ReactNativePerfStats: 425c37de36a447ef51d9b3d160e39cda147a489b - ReactNativeRangeDownloader: 0719fac32ce4ac8001507494f3a5e3709363342f - ReactNativeSplashScreen: a11ddbc7227731bbda9536fe2554658633c649e3 - ReactNativeZipArchive: 91f9619b5b4b56e66833cab53cf40bccac5ebf4b + ReactNativeAppUpdate: 81256b31e5da66b2e902dd124a5dacc1a3fbbfc0 + ReactNativeBundleCrypto: cb768b46ef03206d368bf637d1987aaed5259663 + ReactNativeBundleUpdate: d17001f6bca7f25d811c9c36f1e01e01a5a2f8a5 + ReactNativeCheckBiometricAuthChanged: d2dedcd3ca73da1b57016f0b14d0f52dc4d99d60 + ReactNativeDeviceUtils: 7ffebea87c0069b8a84ca9164df94976013e6807 + ReactNativeGetRandomValues: c3babb9bfea00b782a60e00e60ef97f670657d23 + ReactNativeLiteCard: 7445699b50c366cb1938b6e55aed256405869f19 + ReactNativeNativeLogger: 5c86c36b9519338d21f95e39b0af67c8e9874643 + ReactNativePerfMemory: dd6d62b876a30de4322cc38b0d9ad500461544e8 + ReactNativePerfStats: 4ddb7f7e335205d49a2c60313a5fc0def8048ee9 + ReactNativeRangeDownloader: 919849942945fec0cfd93099381f3564815f6445 + ReactNativeSplashScreen: 50e3da05b2198066e57d6a0b7d26bc476ab2fdc7 + ReactNativeZipArchive: 897d122270a9cbf86ea5d99508e8a21fe8d701f8 RNScreens: 7f643ee0fd1407dc5085c7795460bd93da113b8f - ScrollGuard: 78b788643a8c4cde25270785dae4ce6626ddb561 - SegmentSlider: 8a46e1fee2790e26b9755146b3acf8088a42fd71 - Skeleton: 8bac3ea35207d3b3fb29518e87ae095cd1546f88 - SniConnect: f4f7c741fdd9b81d1f5a582dd17d0a2f908930ed + ScrollGuard: 64a1131516769199a75321b4f60f108978d96abe + SegmentSlider: 8239b5e834ff6cf9f26ecb1de2dab3132d1403ee + Skeleton: 914b7c836bbdc0772530e7de9099d4fecfc35502 + SniConnect: 250b0882d087fbed393809964c0462f614528d07 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 - SplitBundleLoader: c1c5ef924f1e79e047a17ab1d9cd5f0dce4dcc18 + SplitBundleLoader: f55fb4e45006219d046a66c6e919e8523562d800 SSZipArchive: 8a6ee5677c8e304bebc109e39cf0da91ccef22ea - TcpSocket: 47439f8c7dab2660c6efe312da1197b3fd16b0b2 + TcpSocket: a2c74c901a29cc0b100fb3279e04af431e3f986b Yoga: 6ca93c8c13f56baeec55eb608577619b17a4d64e PODFILE CHECKSUM: 7dbc28b8923a22b4d9fcd35e5fe10389c4fccc09 diff --git a/native-modules/native-logger/package.json b/native-modules/native-logger/package.json index d7dab29a..10c79c53 100644 --- a/native-modules/native-logger/package.json +++ b/native-modules/native-logger/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-native-logger", - "version": "3.0.70", + "version": "3.0.71", "description": "react-native-native-logger", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-aes-crypto/package.json b/native-modules/react-native-aes-crypto/package.json index ed5182f3..ebbdee09 100644 --- a/native-modules/react-native-aes-crypto/package.json +++ b/native-modules/react-native-aes-crypto/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-aes-crypto", - "version": "3.0.70", + "version": "3.0.71", "description": "react-native-aes-crypto", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-app-update/package.json b/native-modules/react-native-app-update/package.json index f094eec0..6340c72f 100644 --- a/native-modules/react-native-app-update/package.json +++ b/native-modules/react-native-app-update/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-app-update", - "version": "3.0.70", + "version": "3.0.71", "description": "react-native-app-update", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-async-storage/package.json b/native-modules/react-native-async-storage/package.json index 9fe2649f..50276eb9 100644 --- a/native-modules/react-native-async-storage/package.json +++ b/native-modules/react-native-async-storage/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-async-storage", - "version": "3.0.70", + "version": "3.0.71", "description": "react-native-async-storage", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-background-thread/package.json b/native-modules/react-native-background-thread/package.json index f869decb..b6ce2d33 100644 --- a/native-modules/react-native-background-thread/package.json +++ b/native-modules/react-native-background-thread/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-background-thread", - "version": "3.0.70", + "version": "3.0.71", "description": "react-native-background-thread", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-bundle-crypto/package.json b/native-modules/react-native-bundle-crypto/package.json index d5edc615..42059fb2 100644 --- a/native-modules/react-native-bundle-crypto/package.json +++ b/native-modules/react-native-bundle-crypto/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-bundle-crypto", - "version": "3.0.70", + "version": "3.0.71", "description": "react-native-bundle-crypto", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-bundle-update/package.json b/native-modules/react-native-bundle-update/package.json index 861a62f9..34f0dd24 100644 --- a/native-modules/react-native-bundle-update/package.json +++ b/native-modules/react-native-bundle-update/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-bundle-update", - "version": "3.0.70", + "version": "3.0.71", "description": "react-native-bundle-update", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-check-biometric-auth-changed/package.json b/native-modules/react-native-check-biometric-auth-changed/package.json index e7f89297..e469b0f9 100644 --- a/native-modules/react-native-check-biometric-auth-changed/package.json +++ b/native-modules/react-native-check-biometric-auth-changed/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-check-biometric-auth-changed", - "version": "3.0.70", + "version": "3.0.71", "description": "react-native-check-biometric-auth-changed", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-cloud-fs/package.json b/native-modules/react-native-cloud-fs/package.json index 1849043e..adf56c72 100644 --- a/native-modules/react-native-cloud-fs/package.json +++ b/native-modules/react-native-cloud-fs/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-cloud-fs", - "version": "3.0.70", + "version": "3.0.71", "description": "react-native-cloud-fs TurboModule for OneKey", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-cloud-kit-module/package.json b/native-modules/react-native-cloud-kit-module/package.json index b17af4ed..491f1f8d 100644 --- a/native-modules/react-native-cloud-kit-module/package.json +++ b/native-modules/react-native-cloud-kit-module/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-cloud-kit-module", - "version": "3.0.70", + "version": "3.0.71", "description": "react-native-cloud-kit-module", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-device-utils/package.json b/native-modules/react-native-device-utils/package.json index 6bc357ae..bebb9e4f 100644 --- a/native-modules/react-native-device-utils/package.json +++ b/native-modules/react-native-device-utils/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-device-utils", - "version": "3.0.70", + "version": "3.0.71", "description": "react-native-device-utils", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-dns-lookup/package.json b/native-modules/react-native-dns-lookup/package.json index 999d3dc2..98d93ec0 100644 --- a/native-modules/react-native-dns-lookup/package.json +++ b/native-modules/react-native-dns-lookup/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-dns-lookup", - "version": "3.0.70", + "version": "3.0.71", "description": "react-native-dns-lookup", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-get-random-values/package.json b/native-modules/react-native-get-random-values/package.json index eaf032a3..8848c6dd 100644 --- a/native-modules/react-native-get-random-values/package.json +++ b/native-modules/react-native-get-random-values/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-get-random-values", - "version": "3.0.70", + "version": "3.0.71", "description": "react-native-get-random-values", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-keychain-module/package.json b/native-modules/react-native-keychain-module/package.json index af44a839..022b699e 100644 --- a/native-modules/react-native-keychain-module/package.json +++ b/native-modules/react-native-keychain-module/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-keychain-module", - "version": "3.0.70", + "version": "3.0.71", "description": "react-native-keychain-module", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-lite-card/package.json b/native-modules/react-native-lite-card/package.json index 127c820b..0a963300 100644 --- a/native-modules/react-native-lite-card/package.json +++ b/native-modules/react-native-lite-card/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-lite-card", - "version": "3.0.70", + "version": "3.0.71", "description": "lite card", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-network-info/package.json b/native-modules/react-native-network-info/package.json index d832fd8b..c1aff827 100644 --- a/native-modules/react-native-network-info/package.json +++ b/native-modules/react-native-network-info/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-network-info", - "version": "3.0.70", + "version": "3.0.71", "description": "react-native-network-info", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-pbkdf2/package.json b/native-modules/react-native-pbkdf2/package.json index 533ebbe0..d8bcb206 100644 --- a/native-modules/react-native-pbkdf2/package.json +++ b/native-modules/react-native-pbkdf2/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-pbkdf2", - "version": "3.0.70", + "version": "3.0.71", "description": "react-native-pbkdf2", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-perf-memory/package.json b/native-modules/react-native-perf-memory/package.json index 95e28b9b..68b1b91d 100644 --- a/native-modules/react-native-perf-memory/package.json +++ b/native-modules/react-native-perf-memory/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-perf-memory", - "version": "3.0.70", + "version": "3.0.71", "description": "react-native-perf-memory", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-perf-stats/package.json b/native-modules/react-native-perf-stats/package.json index de37de5d..7e571b4a 100644 --- a/native-modules/react-native-perf-stats/package.json +++ b/native-modules/react-native-perf-stats/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-perf-stats", - "version": "3.0.70", + "version": "3.0.71", "description": "react-native-perf-stats", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-ping/package.json b/native-modules/react-native-ping/package.json index b093166f..18a48c27 100644 --- a/native-modules/react-native-ping/package.json +++ b/native-modules/react-native-ping/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-ping", - "version": "3.0.70", + "version": "3.0.71", "description": "react-native-ping TurboModule for OneKey", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-range-downloader/package.json b/native-modules/react-native-range-downloader/package.json index f000d768..8bf5e8d6 100644 --- a/native-modules/react-native-range-downloader/package.json +++ b/native-modules/react-native-range-downloader/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-range-downloader", - "version": "3.0.70", + "version": "3.0.71", "description": "react-native-range-downloader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-sni-connect/SniConnect.podspec b/native-modules/react-native-sni-connect/SniConnect.podspec index 8ff6dbf0..762713bc 100644 --- a/native-modules/react-native-sni-connect/SniConnect.podspec +++ b/native-modules/react-native-sni-connect/SniConnect.podspec @@ -23,7 +23,6 @@ Pod::Spec.new do |s| s.dependency 'React-Core' s.dependency 'React-jsi' s.dependency 'React-callinvoker' - s.dependency 'React-Codegen' s.dependency 'EMASCurl', '1.5.5' s.pod_target_xcconfig = { diff --git a/native-modules/react-native-sni-connect/ios/SniConnectClient.swift b/native-modules/react-native-sni-connect/ios/SniConnectClient.swift index 92bf8840..c2065ea0 100644 --- a/native-modules/react-native-sni-connect/ios/SniConnectClient.swift +++ b/native-modules/react-native-sni-connect/ios/SniConnectClient.swift @@ -46,7 +46,7 @@ final class SniConnectClient { let totalTimeout: TimeInterval? // Total request timeout (overrides `timeout`) var effectiveConnectTimeout: TimeInterval { - connectTimeout ?? min(timeout / 3, 10.0) + connectTimeout ?? min(timeout / 3, 10_000.0) } var effectiveTotalTimeout: TimeInterval { diff --git a/native-modules/react-native-sni-connect/package.json b/native-modules/react-native-sni-connect/package.json index a8c95463..f083f989 100644 --- a/native-modules/react-native-sni-connect/package.json +++ b/native-modules/react-native-sni-connect/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-sni-connect", - "version": "3.0.70", + "version": "3.0.71", "description": "A React Native library for SNI-based HTTP requests with DNS caching and request management", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-splash-screen/package.json b/native-modules/react-native-splash-screen/package.json index 10086dd7..58262d5e 100644 --- a/native-modules/react-native-splash-screen/package.json +++ b/native-modules/react-native-splash-screen/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-splash-screen", - "version": "3.0.70", + "version": "3.0.71", "description": "react-native-splash-screen", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-split-bundle-loader/package.json b/native-modules/react-native-split-bundle-loader/package.json index b03eabe9..1ae23841 100644 --- a/native-modules/react-native-split-bundle-loader/package.json +++ b/native-modules/react-native-split-bundle-loader/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-split-bundle-loader", - "version": "3.0.70", + "version": "3.0.71", "description": "react-native-split-bundle-loader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-tcp-socket/package.json b/native-modules/react-native-tcp-socket/package.json index dc955441..bb1af72f 100644 --- a/native-modules/react-native-tcp-socket/package.json +++ b/native-modules/react-native-tcp-socket/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-tcp-socket", - "version": "3.0.70", + "version": "3.0.71", "description": "react-native-tcp-socket", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-zip-archive/package.json b/native-modules/react-native-zip-archive/package.json index f6299242..cf6db1c8 100644 --- a/native-modules/react-native-zip-archive/package.json +++ b/native-modules/react-native-zip-archive/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-zip-archive", - "version": "3.0.70", + "version": "3.0.71", "description": "react-native-zip-archive Nitro HybridObject for OneKey", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-auto-size-input/package.json b/native-views/react-native-auto-size-input/package.json index 39a26634..98234734 100644 --- a/native-views/react-native-auto-size-input/package.json +++ b/native-views/react-native-auto-size-input/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-auto-size-input", - "version": "3.0.70", + "version": "3.0.71", "description": "Auto-sizing text input with font scaling, prefix and suffix support", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-chart-webview/package.json b/native-views/react-native-chart-webview/package.json index 34ed9aef..d4d243b5 100644 --- a/native-views/react-native-chart-webview/package.json +++ b/native-views/react-native-chart-webview/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-chart-webview", - "version": "3.0.70", + "version": "3.0.71", "description": "react-native-chart-webview", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-pager-view/package.json b/native-views/react-native-pager-view/package.json index 6df24ca7..11bed23b 100644 --- a/native-views/react-native-pager-view/package.json +++ b/native-views/react-native-pager-view/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-pager-view", - "version": "3.0.70", + "version": "3.0.71", "description": "React Native wrapper for Android and iOS ViewPager", "source": "./src/index.tsx", "main": "./lib/module/index.js", diff --git a/native-views/react-native-perp-depth-bar/package.json b/native-views/react-native-perp-depth-bar/package.json index e90eefe4..5604f832 100644 --- a/native-views/react-native-perp-depth-bar/package.json +++ b/native-views/react-native-perp-depth-bar/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-perp-depth-bar", - "version": "3.0.70", + "version": "3.0.71", "description": "react-native-perp-depth-bar", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-scroll-guard/package.json b/native-views/react-native-scroll-guard/package.json index b0ea29ae..2182a18c 100644 --- a/native-views/react-native-scroll-guard/package.json +++ b/native-views/react-native-scroll-guard/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-scroll-guard", - "version": "3.0.70", + "version": "3.0.71", "description": "A native view wrapper that prevents parent scrollable containers (PagerView/ViewPager2) from intercepting child scroll gestures", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-segment-slider/package.json b/native-views/react-native-segment-slider/package.json index 4926fc92..d75f5ea1 100644 --- a/native-views/react-native-segment-slider/package.json +++ b/native-views/react-native-segment-slider/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-segment-slider", - "version": "3.0.70", + "version": "3.0.71", "description": "react-native-segment-slider", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-skeleton/package.json b/native-views/react-native-skeleton/package.json index 6933f68c..fc5dc546 100644 --- a/native-views/react-native-skeleton/package.json +++ b/native-views/react-native-skeleton/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-skeleton", - "version": "3.0.70", + "version": "3.0.71", "description": "react-native-skeleton", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-tab-view/package.json b/native-views/react-native-tab-view/package.json index 4e2ae85f..e6182c56 100644 --- a/native-views/react-native-tab-view/package.json +++ b/native-views/react-native-tab-view/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-tab-view", - "version": "3.0.70", + "version": "3.0.71", "description": "Native Bottom Tabs for React Native (UIKit implementation)", "source": "./src/index.tsx", "main": "./lib/module/index.js", From 1055abc4b9768ef7fda7bf99bdba8f65838ad6dc Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 30 Jun 2026 01:31:13 +0800 Subject: [PATCH 7/7] fix(sni-connect): enforce proxy preflight spec --- example/react-native/ios/Podfile.lock | 2 +- .../react-native-sni-connect/Package.swift | 31 + .../react-native-sni-connect/README.md | 22 + .../react-native-sni-connect/SPEC.md | 706 ++++++++++++++++++ .../SniConnect.podspec | 4 +- .../android/build.gradle | 1 + .../java/com/sniconnect/SniConnectModule.kt | 267 +++++-- .../com/sniconnect/SniConnectValidation.kt | 241 +++++- .../sniconnect/SniConnectValidationTest.kt | 232 ++++++ .../ios/SniConnect.mm | 15 + .../ios/SniConnect.swift | 65 +- .../ios/SniConnectClient.swift | 329 ++++---- .../ios/SniConnectCore.swift | 93 +++ .../ios/SniConnectValidation.swift | 219 +++++- .../SniConnectValidationTests.swift | 246 ++++++ .../react-native-sni-connect/package.json | 3 + .../src/NativeSniConnect.ts | 6 + .../src/__tests__/index.test.ts | 11 + .../react-native-sni-connect/src/index.tsx | 4 + 19 files changed, 2282 insertions(+), 215 deletions(-) create mode 100644 native-modules/react-native-sni-connect/Package.swift create mode 100644 native-modules/react-native-sni-connect/SPEC.md create mode 100644 native-modules/react-native-sni-connect/android/src/test/java/com/sniconnect/SniConnectValidationTest.kt create mode 100644 native-modules/react-native-sni-connect/ios/SniConnectCore.swift create mode 100644 native-modules/react-native-sni-connect/ios/Tests/SniConnectValidationTests/SniConnectValidationTests.swift diff --git a/example/react-native/ios/Podfile.lock b/example/react-native/ios/Podfile.lock index 75e96689..374fd1e5 100644 --- a/example/react-native/ios/Podfile.lock +++ b/example/react-native/ios/Podfile.lock @@ -4191,7 +4191,7 @@ SPEC CHECKSUMS: ScrollGuard: 64a1131516769199a75321b4f60f108978d96abe SegmentSlider: 8239b5e834ff6cf9f26ecb1de2dab3132d1403ee Skeleton: 914b7c836bbdc0772530e7de9099d4fecfc35502 - SniConnect: 250b0882d087fbed393809964c0462f614528d07 + SniConnect: 9cabebd33b7c53bf66da1be4e34eb6734eee3262 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 SplitBundleLoader: f55fb4e45006219d046a66c6e919e8523562d800 SSZipArchive: 8a6ee5677c8e304bebc109e39cf0da91ccef22ea diff --git a/native-modules/react-native-sni-connect/Package.swift b/native-modules/react-native-sni-connect/Package.swift new file mode 100644 index 00000000..54d66a65 --- /dev/null +++ b/native-modules/react-native-sni-connect/Package.swift @@ -0,0 +1,31 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "SniConnectUnitTests", + platforms: [ + .iOS(.v15), + .macOS(.v13), + ], + targets: [ + .target( + name: "SniConnectValidationCore", + path: "ios", + exclude: [ + "SniConnect-Bridging-Header.h", + "SniConnect.swift", + "SniConnect.mm", + "SniConnectClient.swift", + "SniConnectLog.swift", + "Tests", + ], + sources: ["SniConnectValidation.swift", "SniConnectCore.swift"] + ), + .testTarget( + name: "SniConnectValidationTests", + dependencies: ["SniConnectValidationCore"], + path: "ios/Tests/SniConnectValidationTests" + ), + ] +) diff --git a/native-modules/react-native-sni-connect/README.md b/native-modules/react-native-sni-connect/README.md index 099779c5..52df7956 100644 --- a/native-modules/react-native-sni-connect/README.md +++ b/native-modules/react-native-sni-connect/README.md @@ -24,8 +24,15 @@ import { cancelRequest, cancelAllRequests, clearDNSCache, + isProxyActiveForUrl, } from '@onekeyfe/react-native-sni-connect'; +const proxyActive = await isProxyActiveForUrl('https://example.com/api/v1/ping'); +if (proxyActive) { + // Product adapters should avoid entering SNI mode when a per-URL/system proxy + // is active. Low-level SNI requests still bypass proxies directly. +} + const res = await request({ // requestId is optional; required only if you want to cancel the request. requestId: 'health-check-1', @@ -47,9 +54,24 @@ await cancelAllRequests(); await clearDNSCache(); ``` +## Specification + +The behavior of the SNI connect module is governed by a normative, +platform-agnostic standard that all implementations (iOS, Android, Node/Desktop, +shared JS adapters, and any future platform) must conform to: + +**-> [OneKey SNI Connect Standard (OSCS)](./SPEC.md)** + +Any change to request validation, destination pinning, TLS validation, redirect +handling, cancellation, response shape, or cache behavior must be checked +against OSCS. +When an implementation and the standard disagree, the implementation is wrong. + ### Security notes - The request scheme is always `https` on port `443`; `path` cannot override scheme, host or port. +- `isProxyActiveForUrl(url)` is a preflight probe for adapters. It does not enable proxying; + native SNI transport still bypasses system proxy configuration. - `ip` must be an IPv4/IPv6 literal that routes to a public destination; loopback, private, link-local (incl. cloud metadata), CGNAT, multicast and reserved ranges are rejected. - Header names/values containing CR/LF/control characters are rejected; the `Host` header is diff --git a/native-modules/react-native-sni-connect/SPEC.md b/native-modules/react-native-sni-connect/SPEC.md new file mode 100644 index 00000000..b18d432c --- /dev/null +++ b/native-modules/react-native-sni-connect/SPEC.md @@ -0,0 +1,706 @@ +# OneKey SNI Connect Standard (OSCS) + +- **Version:** 1.2 +- **Status:** Active +- **Last updated:** 2026-06-30 +- **Applies to:** every implementation of OneKey's SNI connect module: iOS + (Swift + EMASCurl), Android (Kotlin + OkHttp), Node/Desktop (Electron main + process + Node HTTPS), the React Native JS surface, app-monorepo shared + adapters, and any future platform. + +This document defines the mandatory behavior expected from the SNI connect +module. It is platform-agnostic: implementations MAY differ in transport and +native APIs, but they MUST be behaviorally equivalent for everything described +here. It is the reference; when an implementation and this document disagree, +the implementation is at fault. + +Normative language: + +- `MUST`, `MUST NOT`, `REQUIRED`, and `SHALL` define mandatory requirements. +- `SHOULD` defines a strong recommendation. Any exception MUST be documented + with a platform reason and accepted by review. +- `MAY` defines optional behavior. + +Acceptance is strict: an implementation that violates any mandatory requirement +is non-conformant and MUST NOT advertise SNI support in production. + +--- + +## 1. Purpose & scope + +The SNI connect module performs one HTTPS request to a caller-selected public IP +address while preserving the caller-selected hostname for: + +- the request URL authority, +- the `Host` header, +- TLS SNI, +- certificate hostname verification. + +This lets callers test or use a specific resolved endpoint without weakening TLS +hostname validation. + +**In scope:** request input validation, URL construction, public-IP filtering, +pinned destination resolution, TLS validation, redirect boundaries, timeout +behavior, cancellation, response shape, client/session/socket caching, cleanup, +and platform equivalence. + +**Out of scope:** choosing the best IP, resolving DNS in JS, retries, cookie +management, certificate pinning, application-level authentication, response +body streaming, and binary response transport. + +General-purpose proxying is out of scope. SNI requests MUST bypass system, +session, PAC, and environment proxy configuration so the socket endpoint remains +the pinned `ip:443`. + +--- + +## 2. Architecture + +``` + caller + | + | request({ ip, hostname, method, path, headers, body, timeout }) + v + +------------------------- SNI Connect -------------------------+ + | | + | validate inputs | + | - ip is a public literal | + | - hostname is a DNS hostname | + | - path is relative | + | - method/header/body are bounded by this spec | + | | + | build HTTPS request | + | url = https:// | + | Host = | + | | + | pinned destination | + | URL authority/Host/SNI/cert name = | + | network endpoint = :443 | + | any attempt to resolve/connect elsewhere fails closed | + | | + | transport | + | connect to :443 | + | send TLS SNI | + | validate certificate for | + | | + +------------------------------+--------------------------------+ + | + v + HTTPS response / platform error +``` + +The module is not a general-purpose HTTP client. It deliberately narrows the +request surface so caller-controlled IPs cannot become an SSRF, cleartext +downgrade, header injection, DNS fallback, redirect escape, or +certificate-bypass primitive. + +--- + +## 3. Public API contract + +This is the canonical contract across iOS, Android, Node/Desktop, and shared +JS adapters. Legacy integration layers MAY temporarily map equivalent field +names such as `body`/`statusCode`, but the boundary exposed to product code MUST +normalize to this shape and MUST NOT drop required information such as repeated +headers. + +### 3.1 Request + +```ts +export type SniConnectRequest = { + requestId?: string; + ip: string; + hostname: string; + method: string; + path: string; + headers: Record; + body?: string | null; + timeout: number; +}; +``` + +- `requestId` is optional. It is required only if the caller wants cancellation. + A caller MUST NOT reuse a request id while a previous request with that id is + still active. When present, it MUST be a non-empty UTF-8 string no longer than + 128 bytes and MUST NOT contain control characters. +- `ip` is a literal IPv4 or IPv6 address. It MUST NOT be a hostname. +- `hostname` is the real DNS hostname used for URL authority, `Host`, SNI, and + certificate validation. It MUST NOT be replaced by `ip`. +- `method` is normalized to uppercase and is limited to the supported method + set in Section 4.3. +- `path` is a relative path/query. It MAY contain a query string but MUST NOT carry + a scheme, authority, host, or port. +- `headers` are caller headers except for module-owned headers described in + Section 4.4. +- `body` is an optional UTF-8 string request body. +- `timeout` is a total request deadline in milliseconds. It includes pinned + destination setup, connect, TLS, request upload, response headers, and + response body read. + The value MUST be a positive finite number. Implementations MUST define a + maximum accepted timeout and reject larger values before network I/O. The + baseline maximum is 120 seconds unless a product flow deliberately raises it + on every supported platform and adds conformance tests. + +### 3.2 Response + +```ts +export type SniConnectResponse = { + data: string; + status: number; + statusText: string; + headers: Record; + multiValueHeaders?: Record; +}; +``` + +- `data` is a string. This module is a text-response API; binary response + transport is out of scope. +- `status` comes from the HTTP response. `statusText` is the reason phrase when + the transport exposes one; otherwise it is an empty string. +- `headers` is a backward-compatible single-value map. Header names are + normalized to lowercase where the platform exposes enough information to do so. + If a response contains repeated headers, `headers[name]` contains the last + observed value. +- `multiValueHeaders`, when the transport exposes enough information, MUST preserve + repeated header values such as `set-cookie` without collapsing them. New + implementations MUST expose it; platforms that cannot MUST document the + degradation at the adapter boundary. +- HTTP `4xx` and `5xx` are resolved as normal responses. The caller inspects + `status`. + +### 3.3 Methods + +- `request(config): Promise` +- `cancelRequest(requestId): Promise<{ success: boolean }>` +- `cancelAllRequests(): Promise<{ success: boolean }>` +- `clearDNSCache(): Promise<{ success: boolean }>` +- `isProxyActiveForUrl(url): Promise` + +Adapters MAY additionally expose `isSupported()`. It is a capability probe only +and does not change the behavior required when SNI is supported. + +`isProxyActiveForUrl(url)` is a preflight probe for shared adapters before they +enter SNI mode. It MUST inspect platform per-URL/system proxy state for the +given HTTP(S) URL and resolve `true` when the URL would use a proxy outside the +SNI module. It MUST NOT change low-level SNI request behavior: actual SNI +transport still bypasses proxy configuration directly. + +--- + +## 4. Request validation & normalization + +### 4.1 IP literal and public destination + +`ip` MUST parse as a literal IPv4 or IPv6 address without DNS resolution. +It MUST NOT include brackets, a port, a zone identifier, whitespace, or any +hostname-like value. + +The module MUST reject: + +- loopback and unspecified addresses, +- private and unique-local ranges, +- link-local ranges, including cloud metadata addresses, +- carrier-grade NAT ranges, +- multicast ranges, +- documentation, benchmark, and reserved ranges, +- IPv4-compatible and IPv4-mapped IPv6 addresses whose embedded IPv4 target is + forbidden, +- IPv6 translation or transition ranges that can hide or route to forbidden IPv4 + destinations, including NAT64 local-use and 6to4, +- NAT64 well-known-prefix addresses whose embedded IPv4 target is forbidden. + +The accepted target is therefore a public/global-unicast literal address. + +### 4.2 Hostname + +`hostname` MUST be a DNS hostname: + +- 1 to 253 characters total, +- labels are 1 to 63 characters, +- labels contain only ASCII letters, digits, and hyphen, +- labels MUST NOT start or end with hyphen. + +IP literals, empty strings, control characters, and URL-like values are invalid +hostnames for this module. + +### 4.3 Method and path + +Supported methods are: + +- `GET` +- `POST` +- `PUT` +- `PATCH` +- `DELETE` +- `HEAD` +- `OPTIONS` + +`path` is normalized by trimming surrounding whitespace and prepending `/` when +missing. It MUST be rejected if it: + +- contains control characters, +- starts with `//`, +- contains `://`, +- starts with a URI scheme such as `http:`, `https:`, `file:`, or `javascript:`. + +The final URL is always `https://` on the implicit +HTTPS port `443`. + +### 4.4 Headers + +Header names and values MUST reject CR, LF, and all other control characters. +Header names MUST follow the HTTP token grammar: + +``` +! # $ % & ' * + - . ^ _ ` | ~ 0-9 A-Z a-z +``` + +Header values MAY be empty but MUST NOT contain control characters. +Implementations MUST reject requests whose caller headers exceed these baseline +limits: + +- header count: 64, +- total caller header bytes: 32 KiB, +- single header name: 128 bytes, +- single header value: 8 KiB. + +The module owns these headers: + +- `Host` +- `Content-Length` +- platform/internal transport headers, including EMASCurl config headers +- transport security and routing headers that can alter connection semantics, + including `Connection`, `Keep-Alive`, `Proxy-*`, `TE`, `Trailer`, + `Transfer-Encoding`, `Upgrade`, `Expect`, HTTP/2 pseudo-headers, and + implementation private headers + +Caller-provided values for module-owned headers MUST NOT override the module's +own values. The module MUST set `Host` to `hostname` after filtering caller +headers. The module MUST compute or omit `Content-Length` according to the final +body it sends. Unsafe routing/security headers MUST be rejected before network +I/O, except for documented compatibility drops of `Host`, `Content-Length`, and +platform-internal headers. Caller-provided values MUST NEVER override +module-owned values. + +### 4.5 Body and size limits + +`body` is a UTF-8 string. Binary request bodies and streaming uploads are out of +scope. Implementations MUST define finite request-body and response-body limits +so a caller-selected endpoint cannot force unbounded memory growth. These +baseline limits are REQUIRED unless a product flow deliberately raises them on +every supported platform and adds conformance tests: + +- request body: 1 MiB, +- response body: 10 MiB, +- path/query: 8 KiB. + +Requests exceeding the request body or path/query limits MUST be rejected before +network I/O. Responses exceeding the response body limit MUST abort transport +work and reject with a response-processing error. + +--- + +## 5. Destination pinning, TLS, and redirects + +### 5.1 Pinned destination + +For each request, the network destination is pinned to exactly one pair: + +``` +hostname -> ip +``` + +The request authority, `Host` header, TLS SNI, and certificate hostname +verification all use `hostname`. The socket endpoint uses `ip:443`. + +Implementations MAY satisfy this in either way: + +- URL-stack transports MAY build `https://` and install a pinned + resolver that returns `ip` only for `hostname`. +- Direct-socket transports MAY connect to the IP literal directly, provided + `Host`, TLS SNI, and certificate verification all remain bound to `hostname`. + +If any transport layer attempts to resolve or connect to a hostname other than +the configured `hostname`, the implementation MUST fail closed instead of +falling back to system DNS. Direct-socket implementations MUST NOT use the +system resolver for the caller-provided `ip`. + +SNI requests MUST bypass all proxy configuration, including system proxy, +environment proxy variables, PAC files, Electron session proxy settings, and +platform URL-session proxy settings. If a platform cannot guarantee proxy +bypass, it MUST return unsupported before starting network I/O. +Shared adapters SHOULD call `isProxyActiveForUrl(url)` before starting SNI and +avoid SNI mode when it returns `true`; this preflight does not relax the native +transport's mandatory direct/no-proxy requirement. + +Pinned destination state MUST be isolated by `(hostname, ip)`. Concurrent +requests for the same hostname but different IPs MUST NOT overwrite each other. +Connection pool keys MUST include at least normalized `hostname`, `ip`, and +port. Connections MUST NOT be reused across different `(hostname, ip, port)` +pairs, even when TLS certificates would allow HTTP/2 connection coalescing. + +### 5.2 TLS + +The module MUST NOT disable TLS certificate validation. + +TLS validation is performed against `hostname`, not `ip`: + +- SNI is `hostname`, +- hostname verification checks the certificate CN/SAN against `hostname`, +- the trust chain is validated by the platform trust manager. + +An expired, untrusted, mismatched, or otherwise invalid certificate MUST reject +the request. + +### 5.3 Redirects + +Redirect handling MUST NOT widen the trust boundary. Best practice is to not +follow redirects automatically and to return the `3xx` response to JS. + +If a platform follows redirects, every hop MUST keep the same scheme +(`https`) and the same hostname, and each hop MUST continue to use the same +pinned destination semantics. Relative redirects are allowed only when they +resolve under the same `https://` authority. + +Redirects to another hostname, another scheme, cleartext HTTP, an absolute URL +that changes authority, or any URL whose host would require system DNS MUST be +rejected. + +### 5.4 Protocol upgrades and alternate services + +Protocol features that can move traffic away from the pinned endpoint MUST be +disabled unless the implementation can prove and test that the effective socket +endpoint remains the same `ip:443` for the entire request. + +Required behavior: + +- HTTP/2 connection coalescing across hostnames MUST be disabled or prevented by + pool isolation. +- Alt-Svc, HTTP/3, QUIC, and connection migration MUST be disabled. +- Cleartext upgrade mechanisms MUST be rejected. +- Platform caches that remember alternate services or protocol routes MUST NOT + affect SNI requests. + +--- + +## 6. Timeout, cancellation, and concurrency + +### 6.1 Timeout + +`timeout` is a total request deadline. It MUST be applied per request rather +than by mutating process-global transport state. + +An implementation MAY use internal connect/read/write timeouts, but those +timeouts MUST NOT cause a request with a larger caller deadline to fail earlier +unless the failure is surfaced as a timeout and documented as a platform limit. +Socket idle timeouts, agent timeouts, and transport defaults MUST either be +disabled for the request or set no shorter than the effective caller deadline. +Timeout state MUST NOT be stored in process-global mutable configuration. + +### 6.2 Cancellation + +Cancellation is request-id based. + +- A request with no `requestId` cannot be cancelled individually. +- `cancelRequest(requestId)` cancels the active platform task/call/request for + that id and resolves `{ success: true }` when one was found. +- It resolves `{ success: false }` when no active request exists for that id. +- `cancelAllRequests()` cancels every request that is active at the time the + cancellation snapshot is taken. + +Every runtime that supports SNI requests, including Node/Desktop, MUST expose +the cancellation API. Unsupported platforms MAY return `null` from a higher +level adapter before a request starts, but MUST NOT start non-cancellable SNI +work while advertising support. + +Completion cleanup MUST be pair-aware: a finishing request MUST NOT unregister a +newer request that reused the same id after it. + +The request MUST remain cancellable until the platform response body has been +fully processed or the request has failed. + +### 6.3 Concurrent requests + +Concurrent requests are allowed. They MUST be isolated by request state, +especially: + +- pinned destination or DNS resolver state, +- transport/session/client cache keys, +- cancellation registration, +- timeout state. + +The implementation MUST avoid per-request unbounded thread pools, unbounded +socket creation, or unbounded client/session growth. Caches and connection pools +MUST be bounded and clearable. + +Baseline concurrency limits: + +- active SNI requests per runtime: 64, +- active SNI requests per `(hostname, ip)` pair: 16, +- queued SNI requests, if supported: 64. + +Implementations MUST either reject work that exceeds these limits with a stable +error or queue it within the bounded queue. They MUST NOT create unbounded native +threads, Node sockets, sessions, clients, promises, or task records. + +--- + +## 7. Cache and cleanup + +The module MAY cache platform clients, sessions, resolver classes, agents, and +underlying connections to avoid connection setup overhead. + +Required properties: + +- cache keys MUST include at least `(hostname, ip)`, +- cache size MUST be bounded, +- cache eviction MUST NOT invalidate unrelated active requests, +- `clearDNSCache()` MUST remove cached pinned destination, DNS resolver, client, + session, and agent state, and MUST evict idle pinned connections, +- memory-pressure cleanup MAY be used when supported by the platform. + +`clearDNSCache()` is a cleanup operation. It is not a substitute for request +cancellation. It MUST NOT cancel active requests unless the platform cannot +evict idle state independently; that limitation MUST be documented and covered +by tests. + +Static resolver or agent registries are cache state. They MUST be bounded, +reused, or clearable; they MUST NOT grow without limit as JS supplies new +`(hostname, ip)` pairs. + +--- + +## 8. Response handling + +The response body is returned as a string after transport decompression, if the +transport performs decompression. The module MUST NOT promise binary fidelity +for arbitrary bytes. + +Compression handling: + +- Implementations MAY enable `gzip`, `deflate`, or `br` decompression. +- If decompression is enabled, implementations MUST count both compressed bytes + and decompressed bytes while streaming or reading. +- The decompressed byte limit is the response body limit in Section 4.5. +- The compressed byte limit MUST NOT exceed the response body limit. +- The decompressed/compressed ratio MUST NOT exceed 20:1 unless a product flow + deliberately raises it on every supported platform and adds conformance tests. +- If an implementation cannot enforce these limits, it MUST disable automatic + decompression for SNI requests. + +Header behavior: + +- single-value `headers` is retained for backward compatibility, +- repeated headers MUST be exposed through `multiValueHeaders` where the + transport exposes repeated values, +- header names MUST be lowercase where the transport exposes header names, +- `headers[name]` uses the last observed value for that lowercased header name, +- response headers MUST NOT be parsed by ad hoc splitting of raw header text. + +The module MUST NOT use automatic platform cookie storage. It MUST NOT read from +the platform cookie jar before a request and MUST NOT write `Set-Cookie` values +from a response into the platform cookie jar. Caller-provided `Cookie` headers +are treated as ordinary explicit caller headers after validation. + +The response body MUST be read with a finite size limit. If the limit is +exceeded, the promise rejects with a response-processing error instead of +continuing to allocate memory. + +--- + +## 9. Error model + +The module rejects the promise for: + +- invalid input, +- pinned destination lookup or connection failure, +- TLS/certificate failure, +- network failure, +- timeout, +- cancellation, +- platform response processing failure. + +The module resolves the promise for HTTP responses, including `3xx`, `4xx`, and +`5xx`, unless the response violates this standard. + +Errors surfaced to JS MUST be stable enough for callers to distinguish: + +- invalid config, +- cancellation, +- timeout, +- pinned destination or DNS failure, +- TLS/certificate failure, +- network failure, +- response processing failure. + +Platform logs MAY contain diagnostic detail, but JS-facing error messages MUST +not leak sensitive headers, request bodies, internal file paths, or transport +configuration ids. + +The low-level `request(config)` API MUST reject on the failures above. A +higher-level product adapter MAY catch those failures and fall back to a normal +domain request, but that fallback is outside this module and MUST report or log +the SNI failure with enough structured detail to debug platform differences. + +Fallback policy: + +- Security policy failures MUST fail closed and MUST NOT fall back to a normal + domain request. This includes invalid input, forbidden IPs, unsafe headers, + proxy-bypass failure, redirect-boundary failure, TLS/certificate failure, + pinned destination escape, protocol-upgrade escape, and response-size limit + violations. +- Capability or routing absence MAY fall back before SNI work starts. Examples: + unsupported platform, disabled IP table, or no selected IP for the hostname. +- Transient network failures MAY fall back only if the product flow explicitly + allows it and logs the SNI failure. Examples: connect timeout, connection + refused, network unreachable, or remote connection reset. +- Cancellation MUST NOT fall back. +- Fallback MUST preserve the original product request semantics and MUST NOT + rewrite unsafe SNI inputs into a new request. + +--- + +## 10. Trust boundary + +This module enforces transport security for a caller-selected endpoint. It does +not decide whether the caller trusts the chosen IP. + +The low-level SNI request API is a privileged transport primitive. Product-level +shared adapters MUST restrict SNI usage to an explicit hostname/root-domain +allowlist owned by the IP table configuration. Arbitrary app code MUST NOT be +able to use SNI connect as a general-purpose public-IP HTTPS client. + +Caller responsibilities: + +- choose `ip` and `hostname` from a trusted source, +- verify that using a specific IP is appropriate for the product flow, +- supply authentication headers or tokens, +- perform application-level retry/backoff if desired, +- interpret HTTP status and response body. + +Module responsibilities: + +- reject unsafe IP literals and malformed request data, +- reject or refuse to advertise support when proxy bypass or pinned destination + guarantees cannot be enforced, +- preserve hostname-based TLS validation, +- prevent redirect and DNS fallback from escaping the pinned endpoint, +- avoid cross-request state leakage, +- provide cancellation and cleanup primitives. + +The public request API does not accept a caller-controlled port. The network +endpoint is `ip:443`. Test-only or internal tooling that needs a custom port +MUST keep the same validation, TLS, redirect, and cancellation guarantees and +MUST NOT be reachable from normal product request flows. + +--- + +## 11. Conformance scenarios + +An implementation is conformant when it passes all of the following: + +| # | Scenario | Expected result | +| --- | --- | --- | +| 1 | Two concurrent requests use the same hostname but different IPs | Each request connects to its own pinned IP; neither resolver overwrites the other | +| 2 | The pinned IP is private, loopback, link-local, metadata, multicast, reserved, or hidden inside an IPv6 transition form | Request is rejected before network I/O | +| 3 | `path` is an absolute URL, protocol-relative URL, cleartext URL, or scheme-like value | Request is rejected before network I/O | +| 4 | Caller supplies `Host`, `Content-Length`, or an internal transport config header | Module-owned value wins; caller cannot override SNI/Host/body framing/config routing | +| 5 | Caller supplies `Connection`, `Proxy-*`, `Transfer-Encoding`, `Expect`, HTTP/2 pseudo-headers, or another routing/security header | Request is rejected or the unsafe header is dropped before network I/O according to documented compatibility policy | +| 6 | Server redirects to another hostname | Redirect is not followed, or request fails closed without system DNS | +| 7 | Server redirects to HTTP | Redirect is not followed, or request fails closed before cleartext I/O | +| 8 | Server returns a same-host relative redirect | Redirect is returned as `3xx`, or followed only with the same pinned destination semantics | +| 9 | TLS certificate is valid for the IP but not the hostname | Request fails certificate validation | +| 10 | TLS certificate is valid for the hostname while the socket connects to the pinned IP | Request succeeds when the server completes HTTPS normally | +| 11 | `cancelRequest()` is called while headers/body are still being processed | Platform work is cancelled and the promise rejects as cancelled | +| 12 | `cancelRequest()` is called for an unknown id | Promise resolves `{ success: false }` | +| 13 | A request completes while a newer request reuses the same request id | Completion cleanup does not unregister or cancel the newer request | +| 14 | Response contains repeated headers such as `Set-Cookie` | Single-value headers remain backward compatible and multi-value headers preserve all values where the transport exposes them | +| 15 | Response body exceeds the documented limit | Request rejects with a response-processing error without unbounded allocation | +| 16 | `clearDNSCache()` is called after requests complete | Cached pinned destination/resolver/client/session/agent state and idle connections are dropped without corrupting future requests | +| 17 | Node/Desktop direct-socket implementation receives a hostname in `ip` or an IP with a port | Request is rejected before `https.request()` or equivalent network I/O | +| 18 | System, environment, PAC, or Electron/session proxy is configured | SNI request bypasses the proxy, or the platform reports unsupported before network I/O | +| 18a | `isProxyActiveForUrl(url)` is called for a URL that would use a system/per-URL proxy | The preflight resolves `true`, and adapters avoid SNI before native request I/O starts | +| 19 | A server advertises Alt-Svc, HTTP/3, QUIC, or cross-host HTTP/2 coalescing is possible | The request remains on the pinned `ip:443` endpoint or the feature is disabled | +| 20 | SNI fails because of invalid input, forbidden IP, unsafe header, proxy-bypass failure, redirect escape, TLS/cert failure, protocol escape, or response-size violation | Higher-level adapters do not fall back to a normal domain request | +| 21 | SNI fails because the platform is unsupported or no selected IP exists | Higher-level adapters MAY fall back before SNI network I/O starts | +| 22 | The response sets cookies and a later SNI request omits an explicit `Cookie` header | The later request does not automatically send cookies from platform storage | +| 23 | Caller explicitly supplies a valid `Cookie` header | The header is sent only for that request and is not stored globally | +| 24 | Compressed response expands past the response limit or ratio limit | Transport is aborted and the promise rejects without unbounded allocation | +| 25 | Caller exceeds requestId, header count, header byte, request body, path/query, active request, or queue limits | Request is rejected or bounded-queued according to this standard; no unbounded resources are created | +| 26 | Product-level shared adapter is asked to use SNI for a hostname outside the IP table allowlist | Adapter refuses SNI and does not expose the low-level primitive as a general-purpose client | +| 27 | A request without `requestId` is active when `cancelAllRequests()` is called | The platform task/call/request is cancelled even though it cannot be cancelled individually | + +Conformance is evaluated per behavior. This document records no implementation's +state; platform-specific gaps are tracked in code review or issue notes until +closed. + +--- + +## 12. Acceptance gate and enforcement + +This document is both the implementation standard and the acceptance checklist. +For a platform implementation or shared adapter to be accepted: + +- Every `MUST` and `MUST NOT` requirement in this document MUST be satisfied. +- Every scenario in Section 11 MUST have automated or documented manual + verification for each supported runtime: iOS, Android, Node/Desktop, and + shared JS adapters where applicable. +- A runtime MUST NOT return supported from `isSupported()` or equivalent + capability checks unless it satisfies all mandatory requirements that apply to + that runtime. +- A shared adapter MUST NOT route production traffic through SNI unless the + underlying runtime is conformant and the target hostname passes the configured + allowlist. +- Any `SHOULD` exception MUST be documented with the platform limitation, risk, + fallback behavior, and reviewer approval. +- Compatibility mappings such as `statusCode`/`body` MUST be tested to preserve + the canonical response contract. +- Security-policy failures MUST be covered by negative tests that prove + fail-closed behavior and no normal-domain fallback. + +Acceptance evidence MUST include formal unit tests for validators and adapters +on every supported implementation: XCTest for iOS validation logic, Gradle/JUnit +for Android validation logic, and Jest for Node/Desktop and shared JS adapters. +Those unit tests MUST cover the negative security cases in Section 11 that can +be proven without network I/O. Acceptance evidence MUST also include integration +tests against controlled HTTPS servers for transport-only behavior, plus platform +build verification. Missing evidence for a mandatory requirement is a release +blocker unless the platform does not advertise SNI support. + +--- + +## Appendix A. Platform constraints (non-normative) + +- **iOS** uses `URLSession` configured through EMASCurl/libcurl. DNS pinning is + implemented with EMASCurl DNS resolver classes. Resolver state MUST be + isolated per `(hostname, ip)` because resolver classes are shared by the + transport. +- **Android** uses OkHttp. DNS pinning is implemented with a custom `Dns` + instance per cached `(hostname, ip)` client. OkHttp redirect defaults MUST be + overridden or constrained so redirects do not bypass the pinned endpoint. +- **Node/Desktop** uses the Electron main process and Node HTTPS stack. It MAY + connect directly to the IP literal instead of installing a DNS hook, but it + MUST set TLS `servername` to `hostname`, verify the certificate against + `hostname`, force `Host` to `hostname`, isolate agent pool keys by + `(hostname, ip, port)`, bypass system/Electron proxies, disable endpoint-moving + protocol features, and expose cancellation plus cache cleanup by destroying + idle pinned sockets. +- **Shared JS adapters** normalize product-level request/response shapes. They + MAY translate legacy field names, but they MUST NOT weaken validation, hide + support status, bypass hostname allowlists, drop repeated headers when + available, or silently convert a low-level SNI failure into success. +- **React Native codegen** limits the portable JS/native type surface. The + public API stays string-body based until a binary-safe response type is added + deliberately on every platform. + +## Appendix B. Change log + +- **1.2** (2026-06-29) - Added normative language, acceptance gate, + proxy-bypass requirements, fallback fail-closed policy, protocol-upgrade and + connection-coalescing constraints, concrete resource limits, compression bomb + protection, cookie-store isolation, product allowlist requirements, and + expanded conformance scenarios. +- **1.1** (2026-06-29) - Added Node/Desktop and shared-adapter scope, replaced + DNS-only wording with pinned destination semantics, tightened header, + timeout, cancellation, cache, response-size, and conformance requirements. +- **1.0** (2026-06-29) - Initial standard. diff --git a/native-modules/react-native-sni-connect/SniConnect.podspec b/native-modules/react-native-sni-connect/SniConnect.podspec index 762713bc..9d36afd8 100644 --- a/native-modules/react-native-sni-connect/SniConnect.podspec +++ b/native-modules/react-native-sni-connect/SniConnect.podspec @@ -16,8 +16,8 @@ Pod::Spec.new do |s| s.static_framework = true s.source_files = [ - "ios/**/*.{swift}", - "ios/**/*.{m,mm}" + "ios/*.{swift}", + "ios/*.{m,mm}" ] s.dependency 'React-Core' diff --git a/native-modules/react-native-sni-connect/android/build.gradle b/native-modules/react-native-sni-connect/android/build.gradle index efd08421..15342af4 100644 --- a/native-modules/react-native-sni-connect/android/build.gradle +++ b/native-modules/react-native-sni-connect/android/build.gradle @@ -65,4 +65,5 @@ dependencies { implementation "com.facebook.react:react-android" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "com.squareup.okhttp3:okhttp:4.12.0" + testImplementation "junit:junit:4.13.2" } diff --git a/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectModule.kt b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectModule.kt index a86da5da..2aed721a 100644 --- a/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectModule.kt +++ b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectModule.kt @@ -1,5 +1,7 @@ package com.sniconnect +import android.content.Context +import android.net.ConnectivityManager import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext @@ -15,20 +17,55 @@ import okhttp3.Dns import okhttp3.Headers import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.OkHttpClient +import okhttp3.Protocol import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import okhttp3.ResponseBody +import okio.Buffer import java.io.IOException import java.net.InetAddress +import java.net.Proxy +import java.net.ProxySelector +import java.net.URI +import java.net.UnknownHostException +import java.nio.charset.StandardCharsets +import java.security.cert.CertificateException +import java.util.Collections import java.util.Locale import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import javax.net.ssl.HttpsURLConnection +import javax.net.ssl.SSLException +import javax.net.ssl.SSLPeerUnverifiedException private const val TAG = "SniConnect" +internal fun classifySniFailureCode(error: Throwable): String { + if (hasCause(error, SSLPeerUnverifiedException::class.java) || + hasCause(error, CertificateException::class.java) + ) { + return "SNI_CERT_FAILED" + } + if (hasCause(error, UnknownHostException::class.java)) { + return "SNI_SECURITY_POLICY_FAILED" + } + if (hasCause(error, SSLException::class.java)) { + return "SNI_TLS_FAILED" + } + return "SNI_REQUEST_FAILED" +} + +private fun hasCause(error: Throwable, type: Class): Boolean { + var current: Throwable? = error + while (current != null) { + if (type.isInstance(current)) return true + current = current.cause + } + return false +} + @ReactModule(name = SniConnectModule.NAME) class SniConnectModule(reactContext: ReactApplicationContext) : NativeSniConnectSpec(reactContext) { @@ -42,7 +79,10 @@ class SniConnectModule(reactContext: ReactApplicationContext) : // A single dispatcher + connection pool shared across all cached clients so we // don't spawn a thread pool / connection pool per (hostname, ip) pair. - private val sharedDispatcher = Dispatcher() + private val sharedDispatcher = Dispatcher().apply { + maxRequests = 64 + maxRequestsPerHost = 64 + } private val sharedConnectionPool = ConnectionPool() } @@ -68,6 +108,9 @@ class SniConnectModule(reactContext: ReactApplicationContext) : } private val activeCalls = ConcurrentHashMap() + private val allActiveCalls = Collections.newSetFromMap(ConcurrentHashMap()) + private val activeCallsLock = Any() + private val requestLimiter = SniConnectRequestLimiter() override fun getName(): String = NAME @@ -83,7 +126,9 @@ class SniConnectModule(reactContext: ReactApplicationContext) : @ReactMethod override fun cancelRequest(requestId: String, promise: Promise) { - val call = activeCalls.remove(requestId) + val call = synchronized(activeCallsLock) { + activeCalls.remove(requestId) + } if (call != null) { call.cancel() SniConnectLogger.info("Cancelled request: $requestId") @@ -95,10 +140,14 @@ class SniConnectModule(reactContext: ReactApplicationContext) : @ReactMethod override fun cancelAllRequests(promise: Promise) { - val count = activeCalls.size - activeCalls.forEach { (_, call) -> call.cancel() } - activeCalls.clear() - SniConnectLogger.info("Cancelled $count active requests") + val calls = synchronized(activeCallsLock) { + val snapshot = allActiveCalls.toList() + activeCalls.clear() + allActiveCalls.clear() + snapshot + } + calls.forEach { call -> call.cancel() } + SniConnectLogger.info("Cancelled ${calls.size} active requests") promise.resolve(Arguments.createMap().apply { putBoolean("success", true) }) } @@ -113,8 +162,20 @@ class SniConnectModule(reactContext: ReactApplicationContext) : promise.resolve(Arguments.createMap().apply { putBoolean("success", true) }) } + @ReactMethod + override fun isProxyActiveForUrl(url: String, promise: Promise) { + try { + promise.resolve(isProxyActiveForUrl(url)) + } catch (error: Exception) { + promise.reject("SNI_INVALID_URL", error.message, error) + } + } + private fun performRequest(config: RequestConfig, promise: Promise) { + var requestSlot: SniConnectRequestLimiter.Token? = null + var registeredCall: Call? = null try { + requestSlot = requestLimiter.acquire(config.hostname, config.ip) val client = getOrCreateClient(config) val request = buildRequest(config) val call = client.newCall(request) @@ -122,59 +183,102 @@ class SniConnectModule(reactContext: ReactApplicationContext) : // Apply per-request timeout call.timeout().timeout(config.timeoutMillis, TimeUnit.MILLISECONDS) - // Register the call if requestId is provided - config.requestId?.let { requestId -> - activeCalls[requestId] = call - } + registerCall(config.requestId, call) + registerActiveCall(call) + registeredCall = call // Guard against double-settling the promise (RN hard-crashes otherwise). val settled = AtomicBoolean(false) call.enqueue(object : Callback { override fun onFailure(call: Call, e: IOException) { - config.requestId?.let { activeCalls.remove(it) } + unregisterCall(config.requestId, call) + unregisterActiveCall(call) + requestSlot?.release() if (!settled.compareAndSet(false, true)) return if (call.isCanceled()) { promise.reject("SNI_CANCELLED", "Request cancelled", null) } else { SniConnectLogger.error("Request failed: ${e.message}") - promise.reject("SNI_REQUEST_FAILED", e.message, e) + promise.reject(classifySniFailureCode(e), e.message, e) } } override fun onResponse(call: Call, response: Response) { - config.requestId?.let { activeCalls.remove(it) } - - val result: WritableMap = try { - response.use { - val bodyString = response.body.safeString() - val headerMap = headersToMap(response.headers) + try { + response.use { currentResponse -> + val bodyString = currentResponse.body.safeString() + val headerMaps = headersToMaps(currentResponse.headers) Arguments.createMap().apply { putString("data", bodyString) - putInt("status", response.code) - putString("statusText", response.message) - putMap("headers", headerMap.toWritableMap()) + putInt("status", currentResponse.code) + putString("statusText", currentResponse.message) + putMap("headers", headerMaps.singleValueHeaders.toWritableMap()) + putMap("multiValueHeaders", headerMaps.multiValueHeaders.toWritableArrayMap()) + }.also { result -> + if (currentResponse.code >= 400) { + SniConnectLogger.warn("HTTP ${currentResponse.code} for ${config.hostname}") + } + if (settled.compareAndSet(false, true)) { + promise.resolve(result) + } } } } catch (error: Exception) { if (!settled.compareAndSet(false, true)) return SniConnectLogger.error("Response processing failed: ${error.message}") promise.reject("SNI_RESPONSE_FAILED", error.message, error) - return - } - - if (response.code >= 400) { - SniConnectLogger.warn("HTTP ${response.code} for ${config.hostname}") - } - if (settled.compareAndSet(false, true)) { - promise.resolve(result) + } finally { + unregisterCall(config.requestId, call) + unregisterActiveCall(call) + requestSlot?.release() } } }) } catch (error: Exception) { + registeredCall?.let { call -> + unregisterCall(config.requestId, call) + unregisterActiveCall(call) + } + requestSlot?.release() SniConnectLogger.error("Request setup failed: ${error.message}") - promise.reject("SNI_REQUEST_FAILED", error.message, error) + val code = if (error is SniConnectValidation.ValidationException) { + "SNI_RESOURCE_LIMIT" + } else { + "SNI_REQUEST_FAILED" + } + promise.reject(code, error.message, error) + } + } + + private fun registerCall(requestId: String?, call: Call) { + if (requestId == null) return + val previousCall = synchronized(activeCallsLock) { + activeCalls.put(requestId, call) + } + if (previousCall != null && previousCall != call) { + previousCall.cancel() + SniConnectLogger.warn("Cancelled previous request with duplicate ID: $requestId") + } + } + + private fun registerActiveCall(call: Call) { + synchronized(activeCallsLock) { + allActiveCalls.add(call) + } + } + + private fun unregisterCall(requestId: String?, call: Call) { + if (requestId == null) return + synchronized(activeCallsLock) { + activeCalls.remove(requestId, call) + } + } + + private fun unregisterActiveCall(call: Call) { + synchronized(activeCallsLock) { + allActiveCalls.remove(call) } } @@ -185,16 +289,17 @@ class SniConnectModule(reactContext: ReactApplicationContext) : synchronized(clientCache) { clientCache[key]?.let { return it } - // 60s defaults at the client level; the real deadline is the per-call timeout. - val defaultTimeout = 60_000L - val client = OkHttpClient.Builder() .dispatcher(sharedDispatcher) .connectionPool(sharedConnectionPool) - .connectTimeout(defaultTimeout, TimeUnit.MILLISECONDS) - .readTimeout(defaultTimeout, TimeUnit.MILLISECONDS) - .writeTimeout(defaultTimeout, TimeUnit.MILLISECONDS) + .proxy(Proxy.NO_PROXY) + .protocols(listOf(Protocol.HTTP_1_1)) + .connectTimeout(0, TimeUnit.MILLISECONDS) + .readTimeout(0, TimeUnit.MILLISECONDS) + .writeTimeout(0, TimeUnit.MILLISECONDS) .callTimeout(0, TimeUnit.MILLISECONDS) + .followRedirects(false) + .followSslRedirects(false) // TLS is validated normally: cert chain via the default trust manager and // hostname verification against the REAL hostname (not the pinned IP). .hostnameVerifier { _, session -> @@ -218,7 +323,7 @@ class SniConnectModule(reactContext: ReactApplicationContext) : return if (requestedHost.lowercase(Locale.US) == expectedHost) { listOf(pinnedAddress) } else { - Dns.SYSTEM.lookup(requestedHost) + throw UnknownHostException("Unexpected host for pinned SNI request: $requestedHost") } } } @@ -232,11 +337,10 @@ class SniConnectModule(reactContext: ReactApplicationContext) : val builder = Request.Builder().url(url) config.headers.forEach { (key, value) -> - if (!key.equals("host", ignoreCase = true)) { - builder.addHeader(key, value) - } + builder.addHeader(key, value) } builder.header("Host", config.hostname) + builder.header("Accept-Encoding", "identity") val method = config.method val bodyContent = config.body ?: "" @@ -267,18 +371,36 @@ class SniConnectModule(reactContext: ReactApplicationContext) : private fun ResponseBody?.safeString(): String { if (this == null) return "" return try { - this.string() + val source = source() + val buffer = Buffer() + var totalBytes = 0L + while (true) { + val read = source.read(buffer, 8 * 1024) + if (read == -1L) break + totalBytes += read + if (totalBytes > SniConnectValidation.MAX_RESPONSE_BODY_BYTES) { + throw IOException("Response body too large") + } + } + val charset = contentType()?.charset(StandardCharsets.UTF_8) ?: StandardCharsets.UTF_8 + buffer.readString(charset) } catch (error: IOException) { throw IOException("Failed to read response body", error) } } - private fun headersToMap(headers: Headers): Map { - val map = mutableMapOf() - for (name in headers.names()) { - map[name] = headers[name] ?: "" + private fun headersToMaps(headers: Headers): HeaderMaps { + val singleValueHeaders = linkedMapOf() + val multiValueHeaders = linkedMapOf>() + + for (index in 0 until headers.size) { + val name = headers.name(index).lowercase(Locale.US) + val value = headers.value(index) + singleValueHeaders[name] = value + multiValueHeaders.getOrPut(name) { mutableListOf() }.add(value) } - return map + + return HeaderMaps(singleValueHeaders, multiValueHeaders) } private fun Map.toWritableMap(): WritableMap { @@ -287,8 +409,18 @@ class SniConnectModule(reactContext: ReactApplicationContext) : } } + private fun Map>.toWritableArrayMap(): WritableMap { + return Arguments.createMap().apply { + forEach { (key, values) -> + val array = Arguments.createArray() + values.forEach { value -> array.pushString(value) } + putArray(key, array) + } + } + } + private fun ReadableMap.toRequestConfig(): RequestConfig { - val headersMap = if (hasKey("headers") && !isNull("headers")) { + val rawHeadersMap = if (hasKey("headers") && !isNull("headers")) { getMap("headers")?.toHashMap() ?.mapValues { (_, value) -> value?.toString() ?: "" } ?: emptyMap() @@ -297,7 +429,7 @@ class SniConnectModule(reactContext: ReactApplicationContext) : } val timeoutMillis = if (hasKey("timeout") && !isNull("timeout")) { - getDouble("timeout").toLong().coerceAtLeast(1L) + SniConnectValidation.parseTimeoutMillis(getDouble("timeout")) } else { 30_000L } @@ -308,13 +440,17 @@ class SniConnectModule(reactContext: ReactApplicationContext) : val hostname = getString("hostname") ?: throw IllegalArgumentException("hostname is required") val method = getString("method") ?: "GET" val path = getString("path") ?: "/" + val body = if (hasKey("body") && !isNull("body")) getString("body") else null // Validate every caller-controlled field at the boundary. + SniConnectValidation.validateRequestId(requestId) SniConnectValidation.validatePublicIp(ip) SniConnectValidation.validateHostname(hostname) - SniConnectValidation.validateHeaders(headersMap) + val headersMap = SniConnectValidation.normalizeHeaders(rawHeadersMap) val normalizedMethod = SniConnectValidation.normalizeMethod(method) val normalizedPath = SniConnectValidation.normalizePath(path) + SniConnectValidation.validateTimeout(timeoutMillis) + SniConnectValidation.validateBody(body) return RequestConfig( requestId = requestId, @@ -323,7 +459,7 @@ class SniConnectModule(reactContext: ReactApplicationContext) : method = normalizedMethod, path = normalizedPath, headers = headersMap, - body = if (hasKey("body") && !isNull("body")) getString("body") else null, + body = body, timeoutMillis = timeoutMillis, ) } @@ -338,4 +474,35 @@ class SniConnectModule(reactContext: ReactApplicationContext) : val body: String?, val timeoutMillis: Long, ) + + private data class HeaderMaps( + val singleValueHeaders: Map, + val multiValueHeaders: Map>, + ) + + private fun isProxyActiveForUrl(url: String): Boolean { + val uri = URI(url) + val scheme = uri.scheme?.lowercase(Locale.US) + ?: throw IllegalArgumentException("URL must include a scheme") + if (scheme != "http" && scheme != "https") { + throw IllegalArgumentException("Only http and https URLs are supported") + } + if (uri.host.isNullOrBlank()) { + throw IllegalArgumentException("URL must include a host") + } + + val selector = ProxySelector.getDefault() + val selectorHasProxy = selector?.select(uri) + ?.any { proxy -> proxy != Proxy.NO_PROXY && proxy.type() != Proxy.Type.DIRECT } + ?: false + if (selectorHasProxy) return true + + val connectivityManager = reactApplicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) + as? ConnectivityManager + ?: return false + val activeNetworkProxy = connectivityManager.activeNetwork + ?.let { network -> connectivityManager.getLinkProperties(network)?.httpProxy } + val proxyInfo = activeNetworkProxy ?: connectivityManager.defaultProxy + return proxyInfo?.host?.isNotBlank() == true && proxyInfo.port > 0 + } } diff --git a/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectValidation.kt b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectValidation.kt index 5d1c9a12..ed3bd853 100644 --- a/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectValidation.kt +++ b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectValidation.kt @@ -2,7 +2,9 @@ package com.sniconnect import java.net.Inet6Address import java.net.InetAddress +import java.nio.charset.StandardCharsets import java.util.Locale +import java.util.concurrent.atomic.AtomicBoolean /** * Boundary validation/normalization for SNI request inputs. @@ -16,16 +18,77 @@ internal object SniConnectValidation { class ValidationException(message: String) : IllegalArgumentException(message) + const val MAX_REQUEST_ID_BYTES = 128 + const val MAX_TIMEOUT_MILLIS = 120_000L + const val MAX_PATH_BYTES = 8 * 1024 + const val MAX_REQUEST_BODY_BYTES = 1024 * 1024 + const val MAX_RESPONSE_BODY_BYTES = 10 * 1024 * 1024L + const val MAX_HEADER_COUNT = 64 + const val MAX_HEADER_NAME_BYTES = 128 + const val MAX_HEADER_VALUE_BYTES = 8 * 1024 + const val MAX_TOTAL_HEADER_BYTES = 32 * 1024 + const val MAX_ACTIVE_REQUESTS = 64 + const val MAX_ACTIVE_REQUESTS_PER_PAIR = 16 + private val ALLOWED_METHODS = setOf("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS") + private val MODULE_OWNED_HEADERS = setOf( + "host", + "content-length", + "accept-encoding", + "x-emascurl-config-id", + ) + private val UNSAFE_HEADERS = setOf( + "connection", + "keep-alive", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "expect", + ) + private val HOSTNAME_REGEX = Regex( "^(?=.{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])?)*\$" ) private val IPV4_REGEX = Regex("^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\$") private val SCHEME_REGEX = Regex("^[A-Za-z][A-Za-z0-9+.-]*:") + private val HEADER_TOKEN_REGEX = Regex("^[!#$%&'*+.^_`|~0-9A-Za-z-]+\$") + + fun validateRequestId(requestId: String?) { + if (requestId == null) return + if (requestId.isEmpty() || containsControlChars(requestId) || byteSize(requestId) > MAX_REQUEST_ID_BYTES) { + throw ValidationException("Invalid requestId") + } + } + + fun validateTimeout(timeoutMillis: Long) { + if (timeoutMillis < 1L || timeoutMillis > MAX_TIMEOUT_MILLIS) { + throw ValidationException("Invalid timeout: $timeoutMillis") + } + } + + fun parseTimeoutMillis(rawTimeoutMillis: Double): Long { + if (!rawTimeoutMillis.isFinite() || + rawTimeoutMillis < 1.0 || + rawTimeoutMillis > MAX_TIMEOUT_MILLIS.toDouble() + ) { + throw ValidationException("Invalid timeout: $rawTimeoutMillis") + } + return rawTimeoutMillis.toLong() + } + + fun validateBody(body: String?) { + if (body != null && byteSize(body) > MAX_REQUEST_BODY_BYTES) { + throw ValidationException("Request body too large") + } + } fun normalizeMethod(method: String): String { + if (containsControlChars(method)) { + throw ValidationException("Invalid method: $method") + } val upper = method.trim().uppercase(Locale.US) if (upper !in ALLOWED_METHODS) { throw ValidationException("Invalid method: $method") @@ -34,7 +97,12 @@ internal object SniConnectValidation { } fun validateHostname(hostname: String) { - if (hostname.isEmpty() || hostname.length > 253 || !HOSTNAME_REGEX.matches(hostname)) { + if ( + hostname.isEmpty() || + hostname.length > 253 || + !HOSTNAME_REGEX.matches(hostname) || + isIpLiteral(hostname) + ) { throw ValidationException("Invalid hostname: $hostname") } } @@ -45,6 +113,9 @@ internal object SniConnectValidation { if (containsControlChars(trimmed)) { throw ValidationException("Invalid path") } + if (byteSize(trimmed) > MAX_PATH_BYTES) { + throw ValidationException("Path too large") + } if (trimmed.contains("://") || trimmed.startsWith("//") || SCHEME_REGEX.containsMatchIn(trimmed.take(64).substringBefore('/'))) { throw ValidationException("Invalid path: absolute URLs are not allowed") } @@ -53,22 +124,78 @@ internal object SniConnectValidation { } fun validateHeaders(headers: Map) { + normalizeHeaders(headers) + } + + fun normalizeHeaders(headers: Map): Map { + if (headers.size > MAX_HEADER_COUNT) { + throw ValidationException("Too many headers") + } + + var totalBytes = 0 + val normalizedHeaders = linkedMapOf() for ((key, value) in headers) { - if (key.isEmpty() || containsControlChars(key) || containsControlChars(value)) { + val keyBytes = byteSize(key) + val valueBytes = byteSize(value) + totalBytes += keyBytes + valueBytes + + if ( + key.isEmpty() || + containsControlChars(key) || + containsControlChars(value) || + keyBytes > MAX_HEADER_NAME_BYTES || + valueBytes > MAX_HEADER_VALUE_BYTES || + !HEADER_TOKEN_REGEX.matches(key) + ) { throw ValidationException("Invalid header: $key") } + + val lowerKey = key.lowercase(Locale.US) + if (lowerKey.startsWith(":") || lowerKey.startsWith("proxy-") || lowerKey in UNSAFE_HEADERS) { + throw ValidationException("Unsafe header: $key") + } + if (lowerKey in MODULE_OWNED_HEADERS) { + continue + } + normalizedHeaders[key] = value + } + if (totalBytes > MAX_TOTAL_HEADER_BYTES) { + throw ValidationException("Headers too large") } + return normalizedHeaders } private fun containsControlChars(s: String): Boolean = s.any { it.code < 0x20 || it.code == 0x7F } + private fun byteSize(s: String): Int = s.toByteArray(StandardCharsets.UTF_8).size + + private fun isIpLiteral(value: String): Boolean { + val octets = IPV4_REGEX.matchEntire(value)?.groupValues?.drop(1)?.map { it.toInt() } + if (octets != null && octets.all { it <= 255 }) return true + if (!value.contains(':')) return false + return try { + InetAddress.getByName(value) is Inet6Address + } catch (_: Exception) { + false + } + } + /** * Validate `ip` is a literal IPv4/IPv6 address (never a hostname) routing to a * public/global-unicast destination. Rejects loopback, private, link-local * (incl. 169.254.169.254 metadata), CGNAT, multicast and reserved ranges. */ fun validatePublicIp(ip: String) { + if ( + ip.isEmpty() || + ip.trim() != ip || + ip.contains('[') || + ip.contains(']') || + ip.contains('%') + ) { + throw ValidationException("Invalid IP: $ip") + } val octets = IPV4_REGEX.matchEntire(ip)?.groupValues?.drop(1)?.map { it.toInt() } if (octets != null) { if (octets.any { it > 255 }) throw ValidationException("Invalid IP: $ip") @@ -117,22 +244,57 @@ internal object SniConnectValidation { } val bytes = addr.address // Unique local fc00::/7 - if ((bytes[0].toInt() and 0xFE) == 0xFC) return true + if ((u(bytes[0]) and 0xFE) == 0xFC) return true + // Discard-only 100::/64 + if (u(bytes[0]) == 0x01 && u(bytes[1]) == 0x00 && (2..7).all { u(bytes[it]) == 0 }) return true + // IETF protocol assignments that should not be accepted as public endpoints. + if (u(bytes[0]) == 0x20 && u(bytes[1]) == 0x01) { + if (u(bytes[2]) == 0x00 && u(bytes[3]) == 0x00) return true // 2001::/32 Teredo + if (u(bytes[2]) == 0x00 && (u(bytes[3]) and 0xF0) == 0x10) return true // 2001:10::/28 ORCHID + if (u(bytes[2]) == 0x00 && u(bytes[3]) == 0x02) return true // 2001:2::/48 benchmarking + if (u(bytes[2]) == 0x0D && u(bytes[3]) == 0xB8) return true // 2001:db8::/32 docs + } + // 6to4 embeds an IPv4 route target and is deprecated; reject it outright. + if (u(bytes[0]) == 0x20 && u(bytes[1]) == 0x02) return true // 2002::/16 + // NAT64 well-known prefix. Allow only when the embedded IPv4 is public. + if (isNat64WellKnown(bytes)) return isForbiddenIpv4(embeddedIpv4(bytes, 12)) + // NAT64 local-use prefix can route through operator-specific private policy. + if (isNat64LocalUse(bytes)) return true + // Deprecated IPv4-compatible IPv6 addresses. + if (isIpv4Compatible(bytes)) return true // IPv4-mapped ::ffff:a.b.c.d — validate the embedded IPv4 - val mappedPrefixZero = (0..9).all { bytes[it].toInt() == 0 } - if (mappedPrefixZero && (bytes[10].toInt() and 0xFF) == 0xFF && (bytes[11].toInt() and 0xFF) == 0xFF) { - return isForbiddenIpv4( - listOf( - bytes[12].toInt() and 0xFF, - bytes[13].toInt() and 0xFF, - bytes[14].toInt() and 0xFF, - bytes[15].toInt() and 0xFF, - ) - ) + if (isIpv4Mapped(bytes)) { + return isForbiddenIpv4(embeddedIpv4(bytes, 12)) } return false } + private fun u(byte: Byte): Int = byte.toInt() and 0xFF + + private fun embeddedIpv4(bytes: ByteArray, offset: Int): List = + listOf(u(bytes[offset]), u(bytes[offset + 1]), u(bytes[offset + 2]), u(bytes[offset + 3])) + + private fun isIpv4Mapped(bytes: ByteArray): Boolean = + (0..9).all { u(bytes[it]) == 0 } && u(bytes[10]) == 0xFF && u(bytes[11]) == 0xFF + + private fun isIpv4Compatible(bytes: ByteArray): Boolean = + (0..11).all { u(bytes[it]) == 0 } + + private fun isNat64WellKnown(bytes: ByteArray): Boolean = + u(bytes[0]) == 0x00 && + u(bytes[1]) == 0x64 && + u(bytes[2]) == 0xFF && + u(bytes[3]) == 0x9B && + (4..11).all { u(bytes[it]) == 0 } + + private fun isNat64LocalUse(bytes: ByteArray): Boolean = + u(bytes[0]) == 0x00 && + u(bytes[1]) == 0x64 && + u(bytes[2]) == 0xFF && + u(bytes[3]) == 0x9B && + u(bytes[4]) == 0x00 && + u(bytes[5]) == 0x01 + /** Parse a validated IPv4/IPv6 literal into an InetAddress without DNS resolution. */ fun literalToInetAddress(ip: String): InetAddress { val octets = IPV4_REGEX.matchEntire(ip)?.groupValues?.drop(1)?.map { it.toInt().toByte() } @@ -142,3 +304,56 @@ internal object SniConnectValidation { return InetAddress.getByName(ip) // safe: already validated as an IPv6 literal } } + +internal class SniConnectRequestLimiter( + private val maxActiveRequests: Int = SniConnectValidation.MAX_ACTIVE_REQUESTS, + private val maxActiveRequestsPerPair: Int = SniConnectValidation.MAX_ACTIVE_REQUESTS_PER_PAIR, +) { + private val lock = Any() + private var activeRequests = 0 + private val activeRequestsByPair = mutableMapOf() + + fun acquire(hostname: String, ip: String): Token { + val key = pairKey(hostname, ip) + synchronized(lock) { + if (activeRequests >= maxActiveRequests) { + throw SniConnectValidation.ValidationException("Too many active SNI requests") + } + val pairCount = activeRequestsByPair[key] ?: 0 + if (pairCount >= maxActiveRequestsPerPair) { + throw SniConnectValidation.ValidationException("Too many active SNI requests for destination") + } + activeRequests += 1 + activeRequestsByPair[key] = pairCount + 1 + } + return Token(this, key) + } + + private fun release(key: String) { + synchronized(lock) { + activeRequests = (activeRequests - 1).coerceAtLeast(0) + val pairCount = activeRequestsByPair[key] ?: return + if (pairCount <= 1) { + activeRequestsByPair.remove(key) + } else { + activeRequestsByPair[key] = pairCount - 1 + } + } + } + + private fun pairKey(hostname: String, ip: String): String = + "${hostname.lowercase(Locale.US)}|$ip" + + class Token internal constructor( + private val limiter: SniConnectRequestLimiter, + private val key: String, + ) { + private val released = AtomicBoolean(false) + + fun release() { + if (released.compareAndSet(false, true)) { + limiter.release(key) + } + } + } +} diff --git a/native-modules/react-native-sni-connect/android/src/test/java/com/sniconnect/SniConnectValidationTest.kt b/native-modules/react-native-sni-connect/android/src/test/java/com/sniconnect/SniConnectValidationTest.kt new file mode 100644 index 00000000..529da10a --- /dev/null +++ b/native-modules/react-native-sni-connect/android/src/test/java/com/sniconnect/SniConnectValidationTest.kt @@ -0,0 +1,232 @@ +package com.sniconnect + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException +import java.net.UnknownHostException +import java.security.cert.CertificateException +import javax.net.ssl.SSLHandshakeException +import javax.net.ssl.SSLPeerUnverifiedException + +class SniConnectValidationTest { + + @Test + fun acceptsValidRequestBoundaryValues() { + SniConnectValidation.validateRequestId("req-1") + SniConnectValidation.validateTimeout(120_000) + SniConnectValidation.validateBody("a".repeat(1024 * 1024)) + SniConnectValidation.validatePublicIp("93.184.216.34") + SniConnectValidation.validatePublicIp("2001:4860:4860::8888") + SniConnectValidation.validateHostname("api.example.com") + + assertEquals("GET", SniConnectValidation.normalizeMethod(" get ")) + assertEquals("/", SniConnectValidation.normalizePath("")) + assertEquals("/v1?q=1", SniConnectValidation.normalizePath("v1?q=1")) + } + + @Test + fun rejectsIpLiteralHostnames() { + assertValidationFails { SniConnectValidation.validateHostname("93.184.216.34") } + assertValidationFails { SniConnectValidation.validateHostname("2001:4860:4860::8888") } + } + + @Test + fun rejectsMalformedHostnames() { + listOf( + "", + "-example.com", + "example-.com", + "example..com", + "bad_host.example", + "https://example.com", + "example.com:443", + "${"a".repeat(64)}.example.com", + "${"a".repeat(250)}.com", + ).forEach { hostname -> + assertValidationFails { SniConnectValidation.validateHostname(hostname) } + } + } + + @Test + fun rejectsUnsafeIpv4Destinations() { + listOf( + "example.com", + "93.184.216.34:443", + " 93.184.216.34", + "10.0.0.1", + "127.0.0.1", + "100.64.0.1", + "169.254.169.254", + "172.16.0.1", + "192.168.1.1", + "192.0.2.1", + "198.18.0.1", + "198.51.100.1", + "203.0.113.1", + "224.0.0.1", + "255.255.255.255", + ).forEach { ip -> + assertValidationFails { SniConnectValidation.validatePublicIp(ip) } + } + } + + @Test + fun rejectsUnsafeIpv6DestinationsAndTransitionForms() { + listOf( + "::", + "::1", + "fe80::1", + "fc00::1", + "ff00::1", + "100::1", + "2001::1", + "2001:2::1", + "2001:db8::1", + "2002:0a00:0001::1", + "::ffff:10.0.0.1", + "64:ff9b::10.0.0.1", + "64:ff9b:1::1", + "2001:4860:4860::8888%en0", + "[2001:4860:4860::8888]", + ).forEach { ip -> + assertValidationFails { SniConnectValidation.validatePublicIp(ip) } + } + } + + @Test + fun rejectsUnsupportedMethodsAndUnsafePaths() { + listOf("TRACE", "CONNECT", "", "GET\n").forEach { method -> + assertValidationFails { SniConnectValidation.normalizeMethod(method) } + } + + listOf( + "https://example.com", + "http://example.com", + "//example.com/path", + "javascript:alert(1)", + "/path\nInjected: yes", + "/${"a".repeat(8192)}", + ).forEach { path -> + assertValidationFails { SniConnectValidation.normalizePath(path) } + } + } + + @Test + fun enforcesRequestIdTimeoutAndBodyLimits() { + assertValidationFails { SniConnectValidation.validateRequestId("") } + assertValidationFails { SniConnectValidation.validateRequestId("x".repeat(129)) } + assertValidationFails { SniConnectValidation.validateRequestId("req\n1") } + assertValidationFails { SniConnectValidation.validateTimeout(0) } + assertValidationFails { SniConnectValidation.validateTimeout(120_001) } + assertEquals(1L, SniConnectValidation.parseTimeoutMillis(1.0)) + assertEquals(120_000L, SniConnectValidation.parseTimeoutMillis(120_000.0)) + assertValidationFails { SniConnectValidation.parseTimeoutMillis(Double.NaN) } + assertValidationFails { SniConnectValidation.parseTimeoutMillis(Double.POSITIVE_INFINITY) } + assertValidationFails { SniConnectValidation.parseTimeoutMillis(Double.NEGATIVE_INFINITY) } + assertValidationFails { SniConnectValidation.parseTimeoutMillis(0.0) } + assertValidationFails { SniConnectValidation.parseTimeoutMillis(0.5) } + assertValidationFails { SniConnectValidation.parseTimeoutMillis(120_000.1) } + assertValidationFails { SniConnectValidation.validateBody("a".repeat(1024 * 1024 + 1)) } + } + + @Test + fun filtersModuleOwnedHeadersAndRejectsUnsafeHeaders() { + val normalized = SniConnectValidation.normalizeHeaders( + mapOf( + "Host" to "evil.example", + "Content-Length" to "9999", + "Accept-Encoding" to "gzip", + "x-emascurl-config-id" to "evil", + "X-Test" to "ok", + ) + ) + + assertFalse(normalized.keys.any { it.equals("host", ignoreCase = true) }) + assertFalse(normalized.keys.any { it.equals("content-length", ignoreCase = true) }) + assertFalse(normalized.keys.any { it.equals("accept-encoding", ignoreCase = true) }) + assertEquals("ok", normalized["X-Test"]) + + listOf( + mapOf("Connection" to "close"), + mapOf("Proxy-Authorization" to "secret"), + mapOf("Transfer-Encoding" to "chunked"), + mapOf("Expect" to "100-continue"), + mapOf(":authority" to "evil.example"), + mapOf("Bad Header" to "x"), + mapOf("X-Test" to "line\nbreak"), + mapOf("X-Test" to "x".repeat(8 * 1024 + 1)), + ).forEach { headers -> + assertValidationFails { SniConnectValidation.normalizeHeaders(headers) } + } + + assertValidationFails { + SniConnectValidation.normalizeHeaders((0..64).associate { "X-$it" to "v" }) + } + assertValidationFails { + SniConnectValidation.normalizeHeaders((0..4).associate { "X-$it" to "x".repeat(7 * 1024) }) + } + } + + @Test + fun requestLimiterEnforcesGlobalAndPerDestinationLimits() { + val limiter = SniConnectRequestLimiter( + maxActiveRequests = 2, + maxActiveRequestsPerPair = 1, + ) + + val firstToken = limiter.acquire("Example.com", "93.184.216.34") + assertValidationFails { + limiter.acquire("example.com", "93.184.216.34") + } + + val secondToken = limiter.acquire("example.com", "93.184.216.35") + assertValidationFails { + limiter.acquire("example.net", "93.184.216.36") + } + + firstToken.release() + val replacementToken = limiter.acquire("example.com", "93.184.216.34") + firstToken.release() + secondToken.release() + replacementToken.release() + + assertTrue(true) + } + + @Test + fun classifiesSecurityFailuresAsFailClosedErrorCodes() { + assertEquals( + "SNI_CERT_FAILED", + classifySniFailureCode(SSLPeerUnverifiedException("hostname mismatch")), + ) + assertEquals( + "SNI_CERT_FAILED", + classifySniFailureCode(SSLHandshakeException("bad cert").apply { + initCause(CertificateException("expired")) + }), + ) + assertEquals( + "SNI_TLS_FAILED", + classifySniFailureCode(SSLHandshakeException("handshake failed")), + ) + assertEquals( + "SNI_SECURITY_POLICY_FAILED", + classifySniFailureCode(UnknownHostException("Unexpected host for pinned SNI request")), + ) + assertEquals( + "SNI_REQUEST_FAILED", + classifySniFailureCode(IOException("connection reset")), + ) + } + + private fun assertValidationFails(block: () -> Unit) { + try { + block() + } catch (_: SniConnectValidation.ValidationException) { + return + } + throw AssertionError("Expected SNI validation failure") + } +} diff --git a/native-modules/react-native-sni-connect/ios/SniConnect.mm b/native-modules/react-native-sni-connect/ios/SniConnect.mm index 68e8d4b9..c59ef66e 100644 --- a/native-modules/react-native-sni-connect/ios/SniConnect.mm +++ b/native-modules/react-native-sni-connect/ios/SniConnect.mm @@ -18,6 +18,9 @@ - (void)cancelAllRequests:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject; - (void)clearDNSCache:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject; +- (void)isProxyActiveForUrl:(NSString *)url + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; @end @interface SniConnect : NSObject @@ -86,6 +89,12 @@ - (void)clearDNSCache:(RCTPromiseResolveBlock)resolve [_implementation clearDNSCache:resolve reject:reject]; } +- (void)isProxyActiveForUrl:(NSString *)url + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { + [_implementation isProxyActiveForUrl:url resolve:resolve reject:reject]; +} + - (std::shared_ptr)getTurboModule: (const facebook::react::ObjCTurboModule::InitParams &)params { @@ -114,6 +123,12 @@ - (void)clearDNSCache:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)rejecter) { [_implementation clearDNSCache:resolver reject:rejecter]; } + +RCT_EXPORT_METHOD(isProxyActiveForUrl:(NSString *)url + resolver:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) { + [_implementation isProxyActiveForUrl:url resolve:resolver reject:rejecter]; +} #endif @end diff --git a/native-modules/react-native-sni-connect/ios/SniConnect.swift b/native-modules/react-native-sni-connect/ios/SniConnect.swift index d1bc8d43..9d9c1407 100644 --- a/native-modules/react-native-sni-connect/ios/SniConnect.swift +++ b/native-modules/react-native-sni-connect/ios/SniConnect.swift @@ -1,4 +1,5 @@ import Foundation +import CFNetwork import React private enum SniConnectError: Error { @@ -44,23 +45,24 @@ final class SniConnectImpl: NSObject { } // Create the task and register it synchronously before JS can cancel it. + // Unregistering is owned by the result waiter below, so even a task that + // completes immediately cannot unregister before it has been registered. let task = Task { () -> SniConnectClient.Response in return try await client.performRequest(config: config) } - // Register task if requestId is provided - if let requestId = config.requestId { - client.registerTask(task, for: requestId) - } + let registrationToken = client.registerTask(task, for: config.requestId) // Handle the task result asynchronously Task { + defer { + client.unregisterTask(requestId: config.requestId, token: registrationToken) + } + do { let result = try await task.value - let responseBody = Self.serializeResponseData(result.data) - resolve([ - "data": responseBody, + "data": result.data, "status": result.status, "statusText": result.statusText, "headers": result.headers, @@ -83,8 +85,8 @@ final class SniConnectImpl: NSObject { resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock ) { - client.cancelRequest(requestId: requestId) - resolve(["success": true]) + let success = client.cancelRequest(requestId: requestId) + resolve(["success": success]) } @objc @@ -105,6 +107,19 @@ final class SniConnectImpl: NSObject { resolve(["success": true]) } + @objc + public func isProxyActiveForUrl( + _ url: String, + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock + ) { + do { + resolve(try Self.isProxyActive(forUrl: url)) + } catch { + reject("SNI_INVALID_URL", "\(error)", error) + } + } + private static func parseDictionary(_ dictionary: NSDictionary) throws -> SniConnectClient.RequestConfig { guard let ip = dictionary["ip"] as? String, !ip.isEmpty else { throw SniConnectError.invalidConfig("Missing ip") @@ -141,22 +156,36 @@ final class SniConnectImpl: NSObject { private static func validate(_ config: SniConnectClient.RequestConfig) throws { try SniConnectValidation.validatePublicIP(config.ip) try SniConnectValidation.validateHostname(config.hostname) - try SniConnectValidation.validateHeaders(config.headers) + _ = try SniConnectValidation.normalizeHeaders(config.headers) _ = try SniConnectValidation.normalizeMethod(config.method) _ = try SniConnectValidation.normalizePath(config.path) + try SniConnectValidation.validateRequestId(config.requestId) + try SniConnectValidation.validateTimeout(config.effectiveTotalTimeout) + try SniConnectValidation.validateBody(config.body) } - private static func serializeResponseData(_ data: Any) -> String { - if let stringValue = data as? String { - return stringValue + private static func isProxyActive(forUrl urlString: String) throws -> Bool { + guard let url = URL(string: urlString), + let scheme = url.scheme?.lowercased(), + ["http", "https"].contains(scheme), + url.host != nil else { + throw SniConnectError.invalidConfig("Invalid URL") } - if JSONSerialization.isValidJSONObject(data), - let jsonData = try? JSONSerialization.data(withJSONObject: data, options: []), - let jsonString = String(data: jsonData, encoding: .utf8) { - return jsonString + guard let settings = CFNetworkCopySystemProxySettings()?.takeRetainedValue() else { + return false } - return String(describing: data) + let proxies = CFNetworkCopyProxiesForURL(url as CFURL, settings).takeRetainedValue() as NSArray + for proxy in proxies { + guard let proxyDictionary = proxy as? NSDictionary, + let type = proxyDictionary[kCFProxyTypeKey] as? String else { + continue + } + if type != (kCFProxyTypeNone as String) { + return true + } + } + return false } } diff --git a/native-modules/react-native-sni-connect/ios/SniConnectClient.swift b/native-modules/react-native-sni-connect/ios/SniConnectClient.swift index c2065ea0..b587fd57 100644 --- a/native-modules/react-native-sni-connect/ios/SniConnectClient.swift +++ b/native-modules/react-native-sni-connect/ios/SniConnectClient.swift @@ -1,13 +1,84 @@ import Foundation +import ObjectiveC import UIKit import EMASCurl +@objc(SniConnectPinnedDNSResolverBase) +private class SniConnectPinnedDNSResolverBase: NSObject, EMASCurlProtocolDNSResolver { + @objc class func resolveDomain(_ domain: String) -> String? { + PinnedDNSResolverFactory.resolve(domain: domain, resolverClass: self) + } +} + +private enum PinnedDNSResolverFactory { + private static let queue = DispatchQueue(label: "com.onekey.sni.connect.pinned-dns-resolvers") + private static var nextClassID = 0 + private static let registry = SniConnectPinnedResolverRegistry() + + static func resolverClass(hostname: String, ip: String) throws -> EMASCurlProtocolDNSResolver.Type { + return try queue.sync { + let resolverClass = try registry.resolverClass( + hostname: hostname, + ip: ip, + allocateClass: allocateResolverClass + ) + return resolverClass as! EMASCurlProtocolDNSResolver.Type + } + } + + static func resolve(domain: String, resolverClass: AnyClass) -> String? { + return queue.sync { + registry.resolve(domain: domain, resolverClass: resolverClass) + } + } + + static func remove(hostname: String, ip: String) { + queue.sync { + registry.remove(hostname: hostname, ip: ip) + } + } + + static func clear() { + queue.sync { + registry.clear() + } + } + + private static func allocateResolverClass() -> AnyClass { + while true { + nextClassID += 1 + let className = "SniConnectPinnedDNSResolver_\(nextClassID)" + if let resolverClass = objc_allocateClassPair( + SniConnectPinnedDNSResolverBase.self, + className, + 0 + ) { + objc_registerClassPair(resolverClass) + return resolverClass + } + } + } +} + /// Core HTTPS client that enforces IP direct connection with SNI. final class SniConnectClient { - // Active requests tracking for cancellation support - private var activeTasks: [String: Task] = [:] + // Active requests tracking for cancellation support. Every task is tracked by + // token so cancelAllRequests() also covers requests that do not have a requestId. + private var activeTasksByToken: [UUID: Task] = [:] + private var requestTokensById: [String: UUID] = [:] private let tasksQueue = DispatchQueue(label: "com.onekey.sni.connect.tasks", attributes: .concurrent) + private let requestLimiter = SniConnectRequestLimiter() + + private struct SessionKey: Hashable { + let hostname: String + let ip: String + } + + private static let maxCachedSessions = SniConnectPinnedResolverRegistry.defaultMaxEntries + private var sessionCache: [SessionKey: URLSession] = [:] + private var sessionAccessOrder: [SessionKey] = [] + private let sessionsQueue = DispatchQueue(label: "com.onekey.sni.connect.sessions") // Token for the memory-warning observer (block-based observers are not removed // by `removeObserver(self)`, so the token must be retained and removed explicitly). @@ -19,9 +90,9 @@ final class SniConnectClient { forName: UIApplication.didReceiveMemoryWarningNotification, object: nil, queue: .main - ) { _ in + ) { [weak self] _ in SniConnectLog.info("Memory warning received, cleaning DNS cache") - DNSResolver.cleanExpiredEntries() + self?.clearDNSCache() } } @@ -55,7 +126,7 @@ final class SniConnectClient { } struct Response { - let data: Any + let data: String let status: Int let statusText: String let headers: [String: String] // Single-value headers (backward compatible) @@ -72,7 +143,9 @@ final class SniConnectClient { case connectionRefused case networkUnreachable case requestTimeout + case resourceLimit(String) case httpError(code: Int, message: String) + case responseProcessingFailed(String) case cancelled case unknown(Error) @@ -87,7 +160,9 @@ final class SniConnectClient { case .connectionRefused: return "SNI_CONNECTION_REFUSED" case .networkUnreachable: return "SNI_NETWORK_UNREACHABLE" case .requestTimeout: return "SNI_REQUEST_TIMEOUT" + case .resourceLimit: return "SNI_RESOURCE_LIMIT" case .httpError: return "SNI_HTTP_ERROR" + case .responseProcessingFailed: return "SNI_RESPONSE_FAILED" case .cancelled: return "SNI_CANCELLED" case .unknown: return "SNI_UNKNOWN_ERROR" } @@ -113,8 +188,12 @@ final class SniConnectClient { return "Network unreachable" case .requestTimeout: return "Request timeout" + case .resourceLimit(let details): + return "Resource limit exceeded: \(details)" case .httpError(let code, let message): return "HTTP error \(code): \(message)" + case .responseProcessingFailed(let details): + return "Response processing failed: \(details)" case .cancelled: return "Request cancelled" case .unknown(let error): @@ -153,19 +232,20 @@ final class SniConnectClient { } } - private static let urlSession: URLSession = { + private static func makeURLSession(for key: SessionKey) throws -> URLSession { let configuration = URLSessionConfiguration.default configuration.requestCachePolicy = .reloadIgnoringLocalCacheData configuration.urlCache = nil configuration.httpCookieStorage = nil configuration.httpShouldSetCookies = false + configuration.connectionProxyDictionary = [:] configuration.shouldUseExtendedBackgroundIdleMode = false let curlConfig = EMASCurlConfiguration.default() - curlConfig.httpVersion = .HTTP2 + curlConfig.httpVersion = .HTTP1 curlConfig.connectTimeoutInterval = 2.5 - curlConfig.enableBuiltInGzip = true - curlConfig.enableBuiltInRedirection = true + curlConfig.enableBuiltInGzip = false + curlConfig.enableBuiltInRedirection = false curlConfig.cacheEnabled = false // Enable full certificate validation for security. @@ -174,140 +254,112 @@ final class SniConnectClient { // original hostname for SNI and certificate CN/SAN matching. curlConfig.certificateValidationEnabled = true curlConfig.domainNameVerificationEnabled = true - curlConfig.dnsResolver = DNSResolver.self + curlConfig.dnsResolver = try PinnedDNSResolverFactory.resolverClass( + hostname: key.hostname, + ip: key.ip + ) EMASCurlProtocol.install(into: configuration, with: curlConfig) return URLSession(configuration: configuration) - }() - - @objc private final class DNSResolver: NSObject, EMASCurlProtocolDNSResolver { - private static let queue = DispatchQueue(label: "com.onekey.sni.connect.dns", attributes: .concurrent) - private static let cache = DNSCache() - - /// Thread-safe hostname -> IP pin with TTL. - /// - /// LIMITATION: EMASCurl only exposes a process-global DNS resolver - /// (`setDNSResolver:`), which receives only the hostname. There is no - /// per-request DNS API, so the pin is keyed by hostname and the most recent - /// IP for a hostname wins. Concurrent requests to the SAME hostname targeting - /// DIFFERENT IPs are therefore not guaranteed to each hit their own IP. Normal - /// usage (one IP per hostname at a time) is unaffected. - private final class DNSCache { - private struct Entry { - let ip: String - let timestamp: TimeInterval - } - - private var hostnameToEntry: [String: Entry] = [:] - private let maxSize = 100 - private let ttl: TimeInterval = 300 // 5 minutes - - func get(_ domain: String) -> String? { - let key = domain.lowercased() - guard let entry = hostnameToEntry[key] else { return nil } - if Date().timeIntervalSince1970 - entry.timestamp > ttl { - return nil - } - return entry.ip - } - - func set(_ ip: String, for domain: String) { - let key = domain.lowercased() - // Evict the oldest entry when at capacity (and this is a new host). - if hostnameToEntry.count >= maxSize && hostnameToEntry[key] == nil { - if let oldest = hostnameToEntry.min(by: { $0.value.timestamp < $1.value.timestamp })?.key { - hostnameToEntry.removeValue(forKey: oldest) - } - } - hostnameToEntry[key] = Entry(ip: ip, timestamp: Date().timeIntervalSince1970) - } - - func clear() { - hostnameToEntry.removeAll() - } + } - func cleanExpired() { - let now = Date().timeIntervalSince1970 - let expired = hostnameToEntry.filter { now - $0.value.timestamp > ttl }.map { $0.key } - for key in expired { - hostnameToEntry.removeValue(forKey: key) - } + private func session(for config: RequestConfig) throws -> URLSession { + let key = SessionKey(hostname: config.hostname.lowercased(), ip: config.ip) + return try sessionsQueue.sync { + if let session = sessionCache[key] { + markSessionUsed(key) + return session } - } - @objc static func resolveDomain(_ domain: String) -> String? { - var result: String? - queue.sync { - result = cache.get(domain) - } - return result - } - - static func setIP(_ ip: String, for host: String) { - queue.sync(flags: .barrier) { - cache.set(ip, for: host) - } + evictSessionsIfNeeded(forPendingInsert: true) + let session = try Self.makeURLSession(for: key) + sessionCache[key] = session + markSessionUsed(key) + return session } + } - /// Clear all DNS cache entries - static func clearCache() { - queue.sync(flags: .barrier) { - cache.clear() - } - } + private func markSessionUsed(_ key: SessionKey) { + sessionAccessOrder.removeAll { $0 == key } + sessionAccessOrder.append(key) + } - /// Clean expired DNS cache entries - static func cleanExpiredEntries() { - queue.sync(flags: .barrier) { - cache.cleanExpired() - } + private func evictSessionsIfNeeded(forPendingInsert: Bool = false) { + let limit = forPendingInsert ? Self.maxCachedSessions - 1 : Self.maxCachedSessions + while sessionCache.count > limit, let evictedKey = sessionAccessOrder.first { + sessionAccessOrder.removeFirst() + sessionCache.removeValue(forKey: evictedKey)?.finishTasksAndInvalidate() + PinnedDNSResolverFactory.remove(hostname: evictedKey.hostname, ip: evictedKey.ip) } } /// Clear all DNS cache entries func clearDNSCache() { - DNSResolver.clearCache() + let sessions = sessionsQueue.sync { () -> [URLSession] in + let sessions = Array(sessionCache.values) + sessionCache.removeAll() + sessionAccessOrder.removeAll() + return sessions + } + PinnedDNSResolverFactory.clear() + for session in sessions { + session.finishTasksAndInvalidate() + } SniConnectLog.info("DNS cache cleared") } /// Cancel a request by ID - func cancelRequest(requestId: String) { - tasksQueue.async(flags: .barrier) { [weak self] in - guard let task = self?.activeTasks[requestId] else { + func cancelRequest(requestId: String) -> Bool { + return tasksQueue.sync(flags: .barrier) { [weak self] in + guard let self = self, let token = self.requestTokensById.removeValue(forKey: requestId), + let task = self.activeTasksByToken.removeValue(forKey: token) else { SniConnectLog.warn("No active request found with ID: \(requestId)") - return + return false } task.cancel() - self?.activeTasks.removeValue(forKey: requestId) SniConnectLog.info("Request cancelled: \(requestId)") + return true } } /// Cancel all active requests func cancelAllRequests() { - tasksQueue.async(flags: .barrier) { [weak self] in + tasksQueue.sync(flags: .barrier) { [weak self] in guard let self = self else { return } - let count = self.activeTasks.count - for (_, task) in self.activeTasks { + let tasks = Array(self.activeTasksByToken.values) + for task in tasks { task.cancel() } - self.activeTasks.removeAll() - SniConnectLog.info("Cancelled \(count) active requests") + self.activeTasksByToken.removeAll() + self.requestTokensById.removeAll() + SniConnectLog.info("Cancelled \(tasks.count) active requests") } } /// Register an active task immediately after creation, before JS can cancel it. - func registerTask(_ task: Task, for requestId: String) { + func registerTask(_ task: Task, for requestId: String?) -> UUID { + let token = UUID() tasksQueue.sync(flags: .barrier) { [weak self] in - self?.activeTasks[requestId] = task + guard let self = self else { return } + if let requestId = requestId, let previousToken = self.requestTokensById[requestId], + let previousTask = self.activeTasksByToken.removeValue(forKey: previousToken) { + previousTask.cancel() + } + self.activeTasksByToken[token] = task + if let requestId = requestId { + self.requestTokensById[requestId] = token + } } + return token } /// Unregister a completed or failed task - private func unregisterTask(requestId: String?) { - guard let requestId = requestId else { return } + func unregisterTask(requestId: String?, token: UUID) { tasksQueue.async(flags: .barrier) { [weak self] in - self?.activeTasks.removeValue(forKey: requestId) + self?.activeTasksByToken.removeValue(forKey: token) + if let requestId = requestId, self?.requestTokensById[requestId] == token { + self?.requestTokensById.removeValue(forKey: requestId) + } } } @@ -315,24 +367,32 @@ final class SniConnectClient { // Check if task is cancelled try Task.checkCancellation() - defer { - unregisterTask(requestId: config.requestId) - } - // Validate every caller-controlled field before it reaches the network layer. let method: String let normalizedPath: String + let normalizedHeaders: [String: String] do { + try SniConnectValidation.validateRequestId(config.requestId) try SniConnectValidation.validatePublicIP(config.ip) try SniConnectValidation.validateHostname(config.hostname) - try SniConnectValidation.validateHeaders(config.headers) + normalizedHeaders = try SniConnectValidation.normalizeHeaders(config.headers) method = try SniConnectValidation.normalizeMethod(config.method) normalizedPath = try SniConnectValidation.normalizePath(config.path) + try SniConnectValidation.validateTimeout(config.effectiveTotalTimeout) + try SniConnectValidation.validateBody(config.body) } catch { throw SniConnectError.invalidConfig("\(error)") } - DNSResolver.setIP(config.ip, for: config.hostname) + let requestSlot: SniConnectRequestLimiter.Token + do { + requestSlot = try requestLimiter.acquire(hostname: config.hostname, ip: config.ip) + } catch { + throw SniConnectError.resourceLimit("\(error)") + } + defer { + requestSlot.release() + } let url = try Self.buildURL(hostname: config.hostname, normalizedPath: normalizedPath) @@ -347,16 +407,11 @@ final class SniConnectClient { mutableRequest.timeoutInterval = totalTimeoutSeconds mutableRequest.cachePolicy = .reloadIgnoringLocalCacheData - // Explicitly set Host header for SNI - mutableRequest.setValue(config.hostname, forHTTPHeaderField: "Host") - - for (key, value) in config.headers { - if key.caseInsensitiveCompare("host") == .orderedSame { - // Host header is already set above, skip duplicate - continue - } + for (key, value) in normalizedHeaders { mutableRequest.setValue(value, forHTTPHeaderField: key) } + mutableRequest.setValue(config.hostname, forHTTPHeaderField: "Host") + mutableRequest.setValue("identity", forHTTPHeaderField: "Accept-Encoding") if let bodyString = config.body, let bodyData = bodyString.data(using: .utf8) { mutableRequest.httpBody = bodyData @@ -365,17 +420,22 @@ final class SniConnectClient { // Per-request connect timeout (avoids the process-global setter race). EMASCurlProtocol.setConnectTimeoutIntervalFor(mutableRequest, connectTimeoutInterval: connectTimeoutSeconds) let request = mutableRequest as URLRequest + let session = try session(for: config) do { - let (data, response) = try await Self.urlSession.data(for: request) + let (bodyBytes, response) = try await session.bytes(for: request) guard let httpResponse = response as? HTTPURLResponse else { let errorMsg = "Invalid HTTP response type" SniConnectLog.error(errorMsg) - throw SniConnectError.invalidConfig(errorMsg) + throw SniConnectError.responseProcessingFailed(errorMsg) } let status = httpResponse.statusCode - let parsedData = Self.parseResponseData(data) + let data = try await Self.readResponseBody( + bodyBytes, + expectedLength: httpResponse.expectedContentLength + ) + let responseText = SniConnectResponseText.decode(data) let (headers, multiValueHeaders) = Self.extractHeaders(from: httpResponse) let statusText = HTTPURLResponse.localizedString(forStatusCode: status) @@ -386,7 +446,7 @@ final class SniConnectClient { } return Response( - data: parsedData, + data: responseText, status: status, statusText: statusText, headers: headers, @@ -425,20 +485,29 @@ final class SniConnectClient { return url } - private static func parseResponseData(_ data: Data) -> Any { - guard !data.isEmpty else { - return "" + private static func readResponseBody( + _ bodyBytes: URLSession.AsyncBytes, + expectedLength: Int64 + ) async throws -> Data { + let maxBytes = SniConnectValidation.maxResponseBodyBytes + if expectedLength > Int64(maxBytes) { + throw SniConnectError.responseProcessingFailed("Response body too large") } - if let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []) { - return jsonObject + var data = Data() + if expectedLength > 0 { + data.reserveCapacity(min(Int(expectedLength), maxBytes)) } - if let text = String(data: data, encoding: .utf8) { - return text + for try await byte in bodyBytes { + try Task.checkCancellation() + if data.count >= maxBytes { + throw SniConnectError.responseProcessingFailed("Response body too large") + } + var nextByte = byte + data.append(&nextByte, count: 1) } - - return data.base64EncodedString() + return data } /// Extract headers from HTTP response diff --git a/native-modules/react-native-sni-connect/ios/SniConnectCore.swift b/native-modules/react-native-sni-connect/ios/SniConnectCore.swift new file mode 100644 index 00000000..6a7598e6 --- /dev/null +++ b/native-modules/react-native-sni-connect/ios/SniConnectCore.swift @@ -0,0 +1,93 @@ +import Foundation + +struct SniConnectResolverConfig: Equatable { + let hostname: String + let ip: String +} + +enum SniConnectCoreError: Error { + case resourceLimit(String) +} + +final class SniConnectPinnedResolverRegistry { + static let defaultMaxEntries = 32 + + private let maxEntries: Int + private var classesByKey: [String: AnyClass] = [:] + private var configsByClassName: [String: SniConnectResolverConfig] = [:] + private var reusableClasses: [AnyClass] = [] + private var allocatedClassNames: Set = [] + + init(maxEntries: Int = SniConnectPinnedResolverRegistry.defaultMaxEntries) { + self.maxEntries = maxEntries + } + + var entryCount: Int { + classesByKey.count + } + + var allocatedClassCount: Int { + allocatedClassNames.count + } + + func resolverClass( + hostname: String, + ip: String, + allocateClass: () -> AnyClass + ) throws -> AnyClass { + let normalizedHost = hostname.lowercased() + let key = Self.key(hostname: normalizedHost, ip: ip) + if let resolverClass = classesByKey[key] { + return resolverClass + } + + guard classesByKey.count < maxEntries else { + throw SniConnectCoreError.resourceLimit("Too many cached SNI resolver entries") + } + + let resolverClass: AnyClass = reusableClasses.popLast() ?? allocateClass() + let className = NSStringFromClass(resolverClass) + allocatedClassNames.insert(className) + classesByKey[key] = resolverClass + configsByClassName[className] = SniConnectResolverConfig(hostname: normalizedHost, ip: ip) + return resolverClass + } + + func resolve(domain: String, resolverClass: AnyClass) -> String? { + guard let config = configsByClassName[NSStringFromClass(resolverClass)] else { + return nil + } + return domain.caseInsensitiveCompare(config.hostname) == .orderedSame ? config.ip : nil + } + + func remove(hostname: String, ip: String) { + let key = Self.key(hostname: hostname.lowercased(), ip: ip) + guard let resolverClass = classesByKey.removeValue(forKey: key) else { + return + } + configsByClassName.removeValue(forKey: NSStringFromClass(resolverClass)) + reusableClasses.append(resolverClass) + } + + func clear() { + reusableClasses.append(contentsOf: classesByKey.values) + classesByKey.removeAll() + configsByClassName.removeAll() + } + + private static func key(hostname: String, ip: String) -> String { + return "\(hostname)|\(ip)" + } +} + +enum SniConnectResponseText { + static func decode(_ data: Data) -> String { + guard !data.isEmpty else { + return "" + } + if let text = String(data: data, encoding: .utf8) { + return text + } + return String(decoding: data, as: UTF8.self) + } +} diff --git a/native-modules/react-native-sni-connect/ios/SniConnectValidation.swift b/native-modules/react-native-sni-connect/ios/SniConnectValidation.swift index 82645647..e90b4f77 100644 --- a/native-modules/react-native-sni-connect/ios/SniConnectValidation.swift +++ b/native-modules/react-native-sni-connect/ios/SniConnectValidation.swift @@ -15,15 +15,74 @@ enum SniConnectValidation { case invalidMethod(String) case invalidPath(String) case invalidHeader(String) + case invalidRequestId(String) + case invalidTimeout(Double) + case invalidBody + case resourceLimit(String) } + static let maxRequestIdBytes = 128 + static let maxTimeoutMillis = 120_000.0 + static let maxPathBytes = 8 * 1024 + static let maxRequestBodyBytes = 1024 * 1024 + static let maxResponseBodyBytes = 10 * 1024 * 1024 + static let maxHeaderCount = 64 + static let maxHeaderNameBytes = 128 + static let maxHeaderValueBytes = 8 * 1024 + static let maxTotalHeaderBytes = 32 * 1024 + static let maxActiveRequests = 64 + static let maxActiveRequestsPerPair = 16 + /// HTTP methods the module is allowed to issue. private static let allowedMethods: Set = [ "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", ] + private static let moduleOwnedHeaders: Set = [ + "host", + "content-length", + "accept-encoding", + "x-emascurl-config-id", + ] + + private static let unsafeHeaders: Set = [ + "connection", + "keep-alive", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "expect", + ] + + private static let headerTokenPattern = "^[!#$%&'*+.^_`|~0-9A-Za-z-]+$" + + static func validateRequestId(_ requestId: String?) throws { + guard let requestId = requestId else { return } + if requestId.isEmpty || + containsControlCharacters(requestId) || + byteCount(requestId) > maxRequestIdBytes { + throw ValidationError.invalidRequestId(requestId) + } + } + + static func validateTimeout(_ timeout: Double) throws { + if !timeout.isFinite || timeout < 1 || timeout > maxTimeoutMillis { + throw ValidationError.invalidTimeout(timeout) + } + } + + static func validateBody(_ body: String?) throws { + if let body = body, byteCount(body) > maxRequestBodyBytes { + throw ValidationError.invalidBody + } + } + /// Validate and uppercase the HTTP method. static func normalizeMethod(_ method: String) throws -> String { + if containsControlCharacters(method) { + throw ValidationError.invalidMethod(method) + } let upper = method.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() guard allowedMethods.contains(upper) else { throw ValidationError.invalidMethod(method) @@ -41,6 +100,9 @@ enum SniConnectValidation { guard hostname.range(of: pattern, options: .regularExpression) != nil else { throw ValidationError.invalidHostname(hostname) } + if parseIPv4(hostname) != nil || parseIPv6(hostname) != nil { + throw ValidationError.invalidHostname(hostname) + } } /// Validate `path`: must be a relative path/query only. Reject absolute URLs @@ -51,6 +113,9 @@ enum SniConnectValidation { if containsControlCharacters(trimmed) { throw ValidationError.invalidPath(path) } + if byteCount(trimmed) > maxPathBytes { + throw ValidationError.invalidPath(path) + } // Reject anything that looks like it carries a scheme or authority. if trimmed.contains("://") || trimmed.hasPrefix("//") { throw ValidationError.invalidPath(path) @@ -67,23 +132,66 @@ enum SniConnectValidation { /// Validate header names/values: reject CR/LF/control characters (header /// injection) and the Host header (the module sets Host itself). static func validateHeaders(_ headers: [String: String]) throws { + _ = try normalizeHeaders(headers) + } + + static func normalizeHeaders(_ headers: [String: String]) throws -> [String: String] { + if headers.count > maxHeaderCount { + throw ValidationError.invalidHeader("too many headers") + } + + var totalBytes = 0 + var normalizedHeaders: [String: String] = [:] for (key, value) in headers { - if key.isEmpty || containsControlCharacters(key) || containsControlCharacters(value) { + let keyBytes = byteCount(key) + let valueBytes = byteCount(value) + totalBytes += keyBytes + valueBytes + + if key.isEmpty || + containsControlCharacters(key) || + containsControlCharacters(value) || + keyBytes > maxHeaderNameBytes || + valueBytes > maxHeaderValueBytes || + key.range(of: headerTokenPattern, options: .regularExpression) == nil { + throw ValidationError.invalidHeader(key) + } + + let lowerKey = key.lowercased() + if lowerKey.hasPrefix(":") || lowerKey.hasPrefix("proxy-") || unsafeHeaders.contains(lowerKey) { throw ValidationError.invalidHeader(key) } + if moduleOwnedHeaders.contains(lowerKey) { + continue + } + normalizedHeaders[key] = value } + if totalBytes > maxTotalHeaderBytes { + throw ValidationError.invalidHeader("headers too large") + } + return normalizedHeaders } static func containsControlCharacters(_ s: String) -> Bool { return s.unicodeScalars.contains { $0.value < 0x20 || $0.value == 0x7F } } + private static func byteCount(_ s: String) -> Int { + return s.lengthOfBytes(using: .utf8) + } + // MARK: - IP validation /// Validate `ip` is a literal IPv4/IPv6 address (never a hostname) and routes /// to a public/global-unicast destination. Rejects loopback, private, /// link-local (incl. 169.254.169.254 metadata), CGNAT, multicast and reserved. static func validatePublicIP(_ ip: String) throws { + if ip.isEmpty || + ip.trimmingCharacters(in: .whitespacesAndNewlines) != ip || + ip.contains("[") || + ip.contains("]") || + ip.contains("%") { + throw ValidationError.invalidIP(ip) + } if let v4 = parseIPv4(ip) { if isForbiddenIPv4(v4) { throw ValidationError.forbiddenIP(ip) } return @@ -142,10 +250,119 @@ enum SniConnectValidation { if b[0] == 0xFE && (b[1] & 0xC0) == 0x80 { return true } // Unique local fc00::/7 if (b[0] & 0xFE) == 0xFC { return true } + // Discard-only 100::/64 + if b[0] == 0x01 && b[1] == 0x00 && b[2...7].allSatisfy({ $0 == 0 }) { return true } + // IETF protocol assignments that should not be accepted as public endpoints. + if b[0] == 0x20 && b[1] == 0x01 { + if b[2] == 0x00 && b[3] == 0x00 { return true } // 2001::/32 Teredo + if b[2] == 0x00 && (b[3] & 0xF0) == 0x10 { return true } // 2001:10::/28 ORCHID + if b[2] == 0x00 && b[3] == 0x02 { return true } // 2001:2::/48 benchmarking + if b[2] == 0x0D && b[3] == 0xB8 { return true } // 2001:db8::/32 docs + } + // 6to4 embeds an IPv4 route target and is deprecated. + if b[0] == 0x20 && b[1] == 0x02 { return true } + // NAT64 well-known prefix. Allow only when the embedded IPv4 is public. + if isNat64WellKnown(b) { return isForbiddenIPv4([b[12], b[13], b[14], b[15]]) } + // NAT64 local-use prefix can route through operator-specific private policy. + if isNat64LocalUse(b) { return true } + // Deprecated IPv4-compatible IPv6 addresses. + if b[0...11].allSatisfy({ $0 == 0 }) { return true } // IPv4-mapped ::ffff:0:0/96 — validate the embedded IPv4 if b[0...9].allSatisfy({ $0 == 0 }) && b[10] == 0xFF && b[11] == 0xFF { return isForbiddenIPv4([b[12], b[13], b[14], b[15]]) } return false } + + private static func isNat64WellKnown(_ b: [UInt8]) -> Bool { + return b[0] == 0x00 && + b[1] == 0x64 && + b[2] == 0xFF && + b[3] == 0x9B && + b[4...11].allSatisfy({ $0 == 0 }) + } + + private static func isNat64LocalUse(_ b: [UInt8]) -> Bool { + return b[0] == 0x00 && + b[1] == 0x64 && + b[2] == 0xFF && + b[3] == 0x9B && + b[4] == 0x00 && + b[5] == 0x01 + } +} + +final class SniConnectRequestLimiter { + final class Token { + private weak var limiter: SniConnectRequestLimiter? + private let key: String + private let lock = NSLock() + private var released = false + + fileprivate init(limiter: SniConnectRequestLimiter, key: String) { + self.limiter = limiter + self.key = key + } + + func release() { + lock.lock() + if released { + lock.unlock() + return + } + released = true + lock.unlock() + limiter?.release(key: key) + } + + deinit { + release() + } + } + + private let maxActiveRequests: Int + private let maxActiveRequestsPerPair: Int + private let queue = DispatchQueue(label: "com.onekey.sni.connect.request-limiter") + private var activeRequests = 0 + private var activeRequestsByPair: [String: Int] = [:] + + init( + maxActiveRequests: Int = SniConnectValidation.maxActiveRequests, + maxActiveRequestsPerPair: Int = SniConnectValidation.maxActiveRequestsPerPair + ) { + self.maxActiveRequests = maxActiveRequests + self.maxActiveRequestsPerPair = maxActiveRequestsPerPair + } + + func acquire(hostname: String, ip: String) throws -> Token { + let key = pairKey(hostname: hostname, ip: ip) + return try queue.sync { + if activeRequests >= maxActiveRequests { + throw SniConnectValidation.ValidationError.resourceLimit("Too many active SNI requests") + } + let pairCount = activeRequestsByPair[key] ?? 0 + if pairCount >= maxActiveRequestsPerPair { + throw SniConnectValidation.ValidationError.resourceLimit("Too many active SNI requests for destination") + } + activeRequests += 1 + activeRequestsByPair[key] = pairCount + 1 + return Token(limiter: self, key: key) + } + } + + private func release(key: String) { + queue.sync { + activeRequests = max(0, activeRequests - 1) + guard let pairCount = activeRequestsByPair[key] else { return } + if pairCount <= 1 { + activeRequestsByPair.removeValue(forKey: key) + } else { + activeRequestsByPair[key] = pairCount - 1 + } + } + } + + private func pairKey(hostname: String, ip: String) -> String { + return "\(hostname.lowercased())|\(ip)" + } } diff --git a/native-modules/react-native-sni-connect/ios/Tests/SniConnectValidationTests/SniConnectValidationTests.swift b/native-modules/react-native-sni-connect/ios/Tests/SniConnectValidationTests/SniConnectValidationTests.swift new file mode 100644 index 00000000..4aab2af9 --- /dev/null +++ b/native-modules/react-native-sni-connect/ios/Tests/SniConnectValidationTests/SniConnectValidationTests.swift @@ -0,0 +1,246 @@ +import XCTest +@testable import SniConnectValidationCore + +final class SniConnectValidationTests: XCTestCase { + + func testAcceptsValidRequestBoundaryValues() throws { + XCTAssertNoThrow(try SniConnectValidation.validateRequestId("req-1")) + XCTAssertNoThrow(try SniConnectValidation.validateTimeout(120_000)) + XCTAssertNoThrow(try SniConnectValidation.validateBody(String(repeating: "a", count: 1024 * 1024))) + XCTAssertNoThrow(try SniConnectValidation.validatePublicIP("93.184.216.34")) + XCTAssertNoThrow(try SniConnectValidation.validatePublicIP("2001:4860:4860::8888")) + XCTAssertNoThrow(try SniConnectValidation.validateHostname("api.example.com")) + + XCTAssertEqual(try SniConnectValidation.normalizeMethod(" get "), "GET") + XCTAssertEqual(try SniConnectValidation.normalizePath(""), "/") + XCTAssertEqual(try SniConnectValidation.normalizePath("v1?q=1"), "/v1?q=1") + } + + func testRejectsIpLiteralHostnames() { + assertValidationFails { + try SniConnectValidation.validateHostname("93.184.216.34") + } + assertValidationFails { + try SniConnectValidation.validateHostname("2001:4860:4860::8888") + } + } + + func testRejectsMalformedHostnames() { + [ + "", + "-example.com", + "example-.com", + "example..com", + "bad_host.example", + "https://example.com", + "example.com:443", + String(repeating: "a", count: 64) + ".example.com", + String(repeating: "a", count: 250) + ".com", + ].forEach { hostname in + assertValidationFails { + try SniConnectValidation.validateHostname(hostname) + } + } + } + + func testRejectsUnsafeIpv4Destinations() { + [ + "example.com", + "93.184.216.34:443", + " 93.184.216.34", + "10.0.0.1", + "127.0.0.1", + "100.64.0.1", + "169.254.169.254", + "172.16.0.1", + "192.168.1.1", + "192.0.2.1", + "198.18.0.1", + "198.51.100.1", + "203.0.113.1", + "224.0.0.1", + "255.255.255.255", + ].forEach { ip in + assertValidationFails { + try SniConnectValidation.validatePublicIP(ip) + } + } + } + + func testRejectsUnsafeIpv6DestinationsAndTransitionForms() { + [ + "::", + "::1", + "fe80::1", + "fc00::1", + "ff00::1", + "100::1", + "2001::1", + "2001:2::1", + "2001:db8::1", + "2002:0a00:0001::1", + "::ffff:10.0.0.1", + "64:ff9b::10.0.0.1", + "64:ff9b:1::1", + "2001:4860:4860::8888%en0", + "[2001:4860:4860::8888]", + ].forEach { ip in + assertValidationFails { + try SniConnectValidation.validatePublicIP(ip) + } + } + } + + func testRejectsUnsupportedMethodsAndUnsafePaths() { + ["TRACE", "CONNECT", "", "GET\n"].forEach { method in + assertValidationFails { + _ = try SniConnectValidation.normalizeMethod(method) + } + } + + [ + "https://example.com", + "http://example.com", + "//example.com/path", + "javascript:alert(1)", + "/path\nInjected: yes", + "/" + String(repeating: "a", count: 8192), + ].forEach { path in + assertValidationFails { + _ = try SniConnectValidation.normalizePath(path) + } + } + } + + func testEnforcesRequestIdTimeoutAndBodyLimits() { + assertValidationFails { + try SniConnectValidation.validateRequestId("") + } + assertValidationFails { + try SniConnectValidation.validateRequestId(String(repeating: "x", count: 129)) + } + assertValidationFails { + try SniConnectValidation.validateRequestId("req\n1") + } + assertValidationFails { + try SniConnectValidation.validateTimeout(0) + } + assertValidationFails { + try SniConnectValidation.validateTimeout(120_001) + } + assertValidationFails { + try SniConnectValidation.validateTimeout(.nan) + } + assertValidationFails { + try SniConnectValidation.validateBody(String(repeating: "a", count: 1024 * 1024 + 1)) + } + } + + func testFiltersModuleOwnedHeadersAndRejectsUnsafeHeaders() throws { + let normalized = try SniConnectValidation.normalizeHeaders([ + "Host": "evil.example", + "Content-Length": "9999", + "Accept-Encoding": "gzip", + "x-emascurl-config-id": "evil", + "X-Test": "ok", + ]) + + XCTAssertFalse(normalized.keys.contains { $0.caseInsensitiveCompare("host") == .orderedSame }) + XCTAssertFalse(normalized.keys.contains { $0.caseInsensitiveCompare("content-length") == .orderedSame }) + XCTAssertFalse(normalized.keys.contains { $0.caseInsensitiveCompare("accept-encoding") == .orderedSame }) + XCTAssertEqual(normalized["X-Test"], "ok") + + [ + ["Connection": "close"], + ["Proxy-Authorization": "secret"], + ["Transfer-Encoding": "chunked"], + ["Expect": "100-continue"], + [":authority": "evil.example"], + ["Bad Header": "x"], + ["X-Test": "line\nbreak"], + ["X-Test": String(repeating: "x", count: 8 * 1024 + 1)], + ].forEach { headers in + assertValidationFails { + _ = try SniConnectValidation.normalizeHeaders(headers) + } + } + + assertValidationFails { + _ = try SniConnectValidation.normalizeHeaders(Dictionary(uniqueKeysWithValues: (0...64).map { ("X-\($0)", "v") })) + } + assertValidationFails { + _ = try SniConnectValidation.normalizeHeaders(Dictionary(uniqueKeysWithValues: (0...4).map { ("X-\($0)", String(repeating: "x", count: 7 * 1024)) })) + } + } + + func testRequestLimiterEnforcesGlobalAndPerDestinationLimits() throws { + let limiter = SniConnectRequestLimiter(maxActiveRequests: 2, maxActiveRequestsPerPair: 1) + let firstToken = try limiter.acquire(hostname: "Example.com", ip: "93.184.216.34") + + assertValidationFails { + _ = try limiter.acquire(hostname: "example.com", ip: "93.184.216.34") + } + + let secondToken = try limiter.acquire(hostname: "example.com", ip: "93.184.216.35") + assertValidationFails { + _ = try limiter.acquire(hostname: "example.net", ip: "93.184.216.36") + } + + firstToken.release() + let replacementToken = try limiter.acquire(hostname: "example.com", ip: "93.184.216.34") + firstToken.release() + secondToken.release() + replacementToken.release() + } + + func testResolverRegistryIsBoundedReusedAndClearable() throws { + final class ResolverA: NSObject {} + final class ResolverB: NSObject {} + var classes: [AnyClass] = [ResolverA.self, ResolverB.self] + let registry = SniConnectPinnedResolverRegistry(maxEntries: 2) + + let first: AnyClass = try registry.resolverClass(hostname: "Example.com", ip: "93.184.216.34") { + classes.removeFirst() + } + let same: AnyClass = try registry.resolverClass(hostname: "example.com", ip: "93.184.216.34") { + XCTFail("Expected resolver class reuse for identical hostname/ip") + return ResolverB.self + } + XCTAssertTrue(first === same) + XCTAssertEqual(registry.resolve(domain: "example.com", resolverClass: first), "93.184.216.34") + XCTAssertEqual(registry.entryCount, 1) + XCTAssertEqual(registry.allocatedClassCount, 1) + + _ = try registry.resolverClass(hostname: "example.com", ip: "93.184.216.35") { + classes.removeFirst() + } + XCTAssertEqual(registry.entryCount, 2) + XCTAssertEqual(registry.allocatedClassCount, 2) + assertValidationFails { + _ = try registry.resolverClass(hostname: "example.net", ip: "93.184.216.36") { + XCTFail("Bounded registry must not allocate past capacity") + return ResolverB.self + } + } + + registry.clear() + XCTAssertEqual(registry.entryCount, 0) + let reused: AnyClass = try registry.resolverClass(hostname: "example.net", ip: "93.184.216.36") { + XCTFail("Expected cleared resolver class to be reused") + return ResolverB.self + } + XCTAssertTrue(reused === ResolverA.self || reused === ResolverB.self) + XCTAssertEqual(registry.allocatedClassCount, 2) + XCTAssertEqual(registry.resolve(domain: "example.net", resolverClass: reused), "93.184.216.36") + } + + func testResponseTextDecodePreservesOriginalJsonText() throws { + let json = "{ \"b\": 1, \"a\": [true, null] }\n" + XCTAssertEqual(SniConnectResponseText.decode(Data(json.utf8)), json) + XCTAssertEqual(SniConnectResponseText.decode(Data()), "") + } + + private func assertValidationFails(_ block: () throws -> Void, file: StaticString = #filePath, line: UInt = #line) { + XCTAssertThrowsError(try block(), file: file, line: line) + } +} diff --git a/native-modules/react-native-sni-connect/package.json b/native-modules/react-native-sni-connect/package.json index f083f989..96f9ebd5 100644 --- a/native-modules/react-native-sni-connect/package.json +++ b/native-modules/react-native-sni-connect/package.json @@ -36,6 +36,9 @@ "typecheck": "tsc", "lint": "eslint \"**/*.{js,ts,tsx}\"", "test": "jest", + "test:android": "cd ../../example/react-native/android && ./gradlew :onekeyfe_react-native-sni-connect:testDebugUnitTest --no-daemon", + "test:ios": "swift test", + "test:native": "yarn test:ios && yarn test:android", "release": "yarn prepare && npm whoami && npm publish --access public" }, "keywords": [ diff --git a/native-modules/react-native-sni-connect/src/NativeSniConnect.ts b/native-modules/react-native-sni-connect/src/NativeSniConnect.ts index 9d93ebd2..5d9a29a1 100644 --- a/native-modules/react-native-sni-connect/src/NativeSniConnect.ts +++ b/native-modules/react-native-sni-connect/src/NativeSniConnect.ts @@ -10,6 +10,10 @@ type HeaderMap = { [key: string]: string; }; +type MultiValueHeaderMap = { + [key: string]: string[]; +}; + export type SniConnectRequest = { requestId?: string; ip: string; @@ -26,6 +30,7 @@ export type SniConnectResponse = { status: Int32; statusText: string; headers: HeaderMap; + multiValueHeaders?: MultiValueHeaderMap; }; export interface Spec extends TurboModule { @@ -33,6 +38,7 @@ export interface Spec extends TurboModule { cancelRequest(requestId: string): Promise<{ success: boolean }>; cancelAllRequests(): Promise<{ success: boolean }>; clearDNSCache(): Promise<{ success: boolean }>; + isProxyActiveForUrl(url: string): Promise; } const LINKING_ERROR = diff --git a/native-modules/react-native-sni-connect/src/__tests__/index.test.ts b/native-modules/react-native-sni-connect/src/__tests__/index.test.ts index cc56b008..31d73a22 100644 --- a/native-modules/react-native-sni-connect/src/__tests__/index.test.ts +++ b/native-modules/react-native-sni-connect/src/__tests__/index.test.ts @@ -5,6 +5,7 @@ jest.mock('../NativeSniConnect', () => ({ cancelRequest: jest.fn(), cancelAllRequests: jest.fn(), clearDNSCache: jest.fn(), + isProxyActiveForUrl: jest.fn(), }, })); @@ -12,6 +13,7 @@ import { cancelAllRequests, cancelRequest, clearDNSCache, + isProxyActiveForUrl, request, } from '../index'; import NativeSniConnect, { @@ -23,6 +25,7 @@ const mockNativeModule = NativeSniConnect as unknown as { cancelRequest: jest.Mock; cancelAllRequests: jest.Mock; clearDNSCache: jest.Mock; + isProxyActiveForUrl: jest.Mock; }; describe('react-native-sni-connect public API', () => { @@ -63,4 +66,12 @@ describe('react-native-sni-connect public API', () => { await expect(clearDNSCache()).resolves.toEqual({ success: true }); expect(mockNativeModule.clearDNSCache).toHaveBeenCalledTimes(1); }); + + it('forwards isProxyActiveForUrl()', async () => { + mockNativeModule.isProxyActiveForUrl.mockResolvedValue(false); + await expect(isProxyActiveForUrl('https://example.com')).resolves.toBe(false); + expect(mockNativeModule.isProxyActiveForUrl).toHaveBeenCalledWith( + 'https://example.com' + ); + }); }); diff --git a/native-modules/react-native-sni-connect/src/index.tsx b/native-modules/react-native-sni-connect/src/index.tsx index e75deb12..8352c9b6 100644 --- a/native-modules/react-native-sni-connect/src/index.tsx +++ b/native-modules/react-native-sni-connect/src/index.tsx @@ -23,4 +23,8 @@ export function clearDNSCache(): Promise<{ success: boolean }> { return NativeSniConnect.clearDNSCache(); } +export function isProxyActiveForUrl(url: string): Promise { + return NativeSniConnect.isProxyActiveForUrl(url); +} + export type { SniConnectRequest, SniConnectResponse } from './NativeSniConnect';