@onekeyfe/react-native-sni-connect 1.1.0 → 3.0.70

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,144 @@
1
+ package com.sniconnect
2
+
3
+ import java.net.Inet6Address
4
+ import java.net.InetAddress
5
+ import java.util.Locale
6
+
7
+ /**
8
+ * Boundary validation/normalization for SNI request inputs.
9
+ *
10
+ * The module connects to a caller-supplied IP while preserving the TLS SNI/Host of
11
+ * `hostname`. Because the connect target is caller-controlled, every field that
12
+ * reaches the network layer is validated here to prevent SSRF, scheme/host/port
13
+ * override, cleartext downgrade and CR/LF header injection.
14
+ */
15
+ internal object SniConnectValidation {
16
+
17
+ class ValidationException(message: String) : IllegalArgumentException(message)
18
+
19
+ private val ALLOWED_METHODS =
20
+ setOf("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS")
21
+
22
+ private val HOSTNAME_REGEX = Regex(
23
+ "^(?=.{1,253}\$)([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)(\\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\$"
24
+ )
25
+ private val IPV4_REGEX = Regex("^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\$")
26
+ private val SCHEME_REGEX = Regex("^[A-Za-z][A-Za-z0-9+.-]*:")
27
+
28
+ fun normalizeMethod(method: String): String {
29
+ val upper = method.trim().uppercase(Locale.US)
30
+ if (upper !in ALLOWED_METHODS) {
31
+ throw ValidationException("Invalid method: $method")
32
+ }
33
+ return upper
34
+ }
35
+
36
+ fun validateHostname(hostname: String) {
37
+ if (hostname.isEmpty() || hostname.length > 253 || !HOSTNAME_REGEX.matches(hostname)) {
38
+ throw ValidationException("Invalid hostname: $hostname")
39
+ }
40
+ }
41
+
42
+ /** Must be a relative path/query only — reject absolute/protocol-relative URLs and control chars. */
43
+ fun normalizePath(path: String): String {
44
+ val trimmed = path.trim()
45
+ if (containsControlChars(trimmed)) {
46
+ throw ValidationException("Invalid path")
47
+ }
48
+ if (trimmed.contains("://") || trimmed.startsWith("//") || SCHEME_REGEX.containsMatchIn(trimmed.take(64).substringBefore('/'))) {
49
+ throw ValidationException("Invalid path: absolute URLs are not allowed")
50
+ }
51
+ if (trimmed.isEmpty()) return "/"
52
+ return if (trimmed.startsWith("/")) trimmed else "/$trimmed"
53
+ }
54
+
55
+ fun validateHeaders(headers: Map<String, String>) {
56
+ for ((key, value) in headers) {
57
+ if (key.isEmpty() || containsControlChars(key) || containsControlChars(value)) {
58
+ throw ValidationException("Invalid header: $key")
59
+ }
60
+ }
61
+ }
62
+
63
+ private fun containsControlChars(s: String): Boolean =
64
+ s.any { it.code < 0x20 || it.code == 0x7F }
65
+
66
+ /**
67
+ * Validate `ip` is a literal IPv4/IPv6 address (never a hostname) routing to a
68
+ * public/global-unicast destination. Rejects loopback, private, link-local
69
+ * (incl. 169.254.169.254 metadata), CGNAT, multicast and reserved ranges.
70
+ */
71
+ fun validatePublicIp(ip: String) {
72
+ val octets = IPV4_REGEX.matchEntire(ip)?.groupValues?.drop(1)?.map { it.toInt() }
73
+ if (octets != null) {
74
+ if (octets.any { it > 255 }) throw ValidationException("Invalid IP: $ip")
75
+ if (isForbiddenIpv4(octets)) throw ValidationException("Forbidden IP: $ip")
76
+ return
77
+ }
78
+ // IPv6: only treat as literal if it contains ':' (avoids any DNS lookup).
79
+ if (ip.contains(':')) {
80
+ val addr: InetAddress = try {
81
+ InetAddress.getByName(ip)
82
+ } catch (e: Exception) {
83
+ throw ValidationException("Invalid IP: $ip")
84
+ }
85
+ if (addr !is Inet6Address) throw ValidationException("Invalid IP: $ip")
86
+ if (isForbiddenIpv6(addr)) throw ValidationException("Forbidden IP: $ip")
87
+ return
88
+ }
89
+ throw ValidationException("Invalid IP: $ip")
90
+ }
91
+
92
+ private fun isForbiddenIpv4(o: List<Int>): Boolean {
93
+ val a = o[0]; val b = o[1]; val c = o[2]; val d = o[3]
94
+ return when {
95
+ a == 0 -> true // 0.0.0.0/8
96
+ a == 10 -> true // 10/8 private
97
+ a == 127 -> true // 127/8 loopback
98
+ a == 100 && (b and 0xC0) == 0x40 -> true // 100.64/10 CGNAT
99
+ a == 169 && b == 254 -> true // 169.254/16 link-local + metadata
100
+ a == 172 && b in 16..31 -> true // 172.16/12 private
101
+ a == 192 && b == 168 -> true // 192.168/16 private
102
+ a == 192 && b == 0 && c == 0 -> true // 192.0.0/24
103
+ a == 192 && b == 0 && c == 2 -> true // 192.0.2/24 TEST-NET-1
104
+ a == 198 && (b == 18 || b == 19) -> true // 198.18/15 benchmarking
105
+ a == 198 && b == 51 && c == 100 -> true // 198.51.100/24 TEST-NET-2
106
+ a == 203 && b == 0 && c == 113 -> true // 203.0.113/24 TEST-NET-3
107
+ a >= 224 -> true // 224/4 multicast + 240/4 reserved + broadcast
108
+ else -> false
109
+ }
110
+ }
111
+
112
+ private fun isForbiddenIpv6(addr: Inet6Address): Boolean {
113
+ if (addr.isAnyLocalAddress || addr.isLoopbackAddress || addr.isLinkLocalAddress ||
114
+ addr.isSiteLocalAddress || addr.isMulticastAddress
115
+ ) {
116
+ return true
117
+ }
118
+ val bytes = addr.address
119
+ // Unique local fc00::/7
120
+ if ((bytes[0].toInt() and 0xFE) == 0xFC) return true
121
+ // IPv4-mapped ::ffff:a.b.c.d — validate the embedded IPv4
122
+ val mappedPrefixZero = (0..9).all { bytes[it].toInt() == 0 }
123
+ if (mappedPrefixZero && (bytes[10].toInt() and 0xFF) == 0xFF && (bytes[11].toInt() and 0xFF) == 0xFF) {
124
+ return isForbiddenIpv4(
125
+ listOf(
126
+ bytes[12].toInt() and 0xFF,
127
+ bytes[13].toInt() and 0xFF,
128
+ bytes[14].toInt() and 0xFF,
129
+ bytes[15].toInt() and 0xFF,
130
+ )
131
+ )
132
+ }
133
+ return false
134
+ }
135
+
136
+ /** Parse a validated IPv4/IPv6 literal into an InetAddress without DNS resolution. */
137
+ fun literalToInetAddress(ip: String): InetAddress {
138
+ val octets = IPV4_REGEX.matchEntire(ip)?.groupValues?.drop(1)?.map { it.toInt().toByte() }
139
+ if (octets != null) {
140
+ return InetAddress.getByAddress(byteArrayOf(octets[0], octets[1], octets[2], octets[3]))
141
+ }
142
+ return InetAddress.getByName(ip) // safe: already validated as an IPv6 literal
143
+ }
144
+ }
@@ -6,7 +6,6 @@
6
6
  //
7
7
 
8
8
  #import <React/RCTBridgeModule.h>
9
- #import <React/RCTEventEmitter.h>
10
9
 
11
10
  #ifdef RCT_NEW_ARCH_ENABLED
12
11
  #import <SniConnectSpec/SniConnectSpec.h>
package/ios/SniConnect.mm CHANGED
@@ -1,5 +1,4 @@
1
1
  #import <React/RCTBridgeModule.h>
2
- #import <React/RCTEventEmitter.h>
3
2
  #import <React/RCTUtils.h>
4
3
 
5
4
  #ifdef RCT_NEW_ARCH_ENABLED
@@ -8,7 +7,7 @@
8
7
 
9
8
  // Forward declaration of the Swift implementation
10
9
  @interface SniConnectImpl : NSObject
11
- - (instancetype)initWithEventSender:(id)eventSender;
10
+ - (instancetype)init;
12
11
  - (void)request:(NSDictionary *)config
13
12
  resolve:(RCTPromiseResolveBlock)resolve
14
13
  reject:(RCTPromiseRejectBlock)reject;
@@ -21,7 +20,7 @@
21
20
  reject:(RCTPromiseRejectBlock)reject;
22
21
  @end
23
22
 
24
- @interface SniConnect : RCTEventEmitter
23
+ @interface SniConnect : NSObject
25
24
  #ifdef RCT_NEW_ARCH_ENABLED
26
25
  <NativeSniConnectSpec>
27
26
  #else
@@ -31,63 +30,42 @@
31
30
 
32
31
  @implementation SniConnect {
33
32
  SniConnectImpl *_implementation;
34
- BOOL _hasListeners;
35
33
  }
36
34
 
37
35
  RCT_EXPORT_MODULE(SniConnect)
38
36
 
39
- // Expose hasListeners as a property for Swift access
40
- - (BOOL)hasListeners {
41
- return _hasListeners;
42
- }
43
-
44
37
  + (BOOL)requiresMainQueueSetup {
45
38
  return NO;
46
39
  }
47
40
 
48
41
  - (instancetype)init {
49
42
  if (self = [super init]) {
50
- _implementation = [[SniConnectImpl alloc] initWithEventSender:self];
51
- _hasListeners = NO;
43
+ _implementation = [[SniConnectImpl alloc] init];
52
44
  }
53
45
  return self;
54
46
  }
55
47
 
56
- // Event emitter methods
57
- - (NSArray<NSString *> *)supportedEvents {
58
- return @[@"SniConnectLog"];
59
- }
60
-
61
- - (void)startObserving {
62
- _hasListeners = YES;
63
- }
64
-
65
- - (void)stopObserving {
66
- _hasListeners = NO;
67
- }
68
-
69
- // Method to send log event to JS
70
- - (void)sendLogEvent:(NSDictionary *)logData {
71
- if (_hasListeners) {
72
- [self sendEventWithName:@"SniConnectLog" body:logData];
73
- }
74
- }
75
-
76
48
  #ifdef RCT_NEW_ARCH_ENABLED
77
49
  // TurboModule interface implementation
78
50
  - (void)request:(JS::NativeSniConnect::SniConnectRequest &)config
79
51
  resolve:(RCTPromiseResolveBlock)resolve
80
52
  reject:(RCTPromiseRejectBlock)reject {
81
- // Convert Codegen struct to NSDictionary for Swift implementation
82
- NSDictionary *configDict = @{
83
- @"ip": config.ip(),
84
- @"hostname": config.hostname(),
85
- @"method": config.method(),
86
- @"path": config.path(),
87
- @"headers": config.headers(),
88
- @"body": config.body() ?: [NSNull null],
89
- @"timeout": @(config.timeout())
90
- };
53
+ // Convert Codegen struct to NSDictionary for the Swift implementation.
54
+ // Null-guard every field so a nil value never crashes the dictionary literal,
55
+ // and forward requestId so cancellation works under the new architecture.
56
+ NSMutableDictionary *configDict = [NSMutableDictionary dictionary];
57
+ configDict[@"ip"] = config.ip() ?: @"";
58
+ configDict[@"hostname"] = config.hostname() ?: @"";
59
+ configDict[@"method"] = config.method() ?: @"GET";
60
+ configDict[@"path"] = config.path() ?: @"/";
61
+ configDict[@"headers"] = config.headers() ?: @{};
62
+ configDict[@"timeout"] = @(config.timeout());
63
+ if (config.requestId()) {
64
+ configDict[@"requestId"] = config.requestId();
65
+ }
66
+ if (config.body()) {
67
+ configDict[@"body"] = config.body();
68
+ }
91
69
 
92
70
  [_implementation request:configDict resolve:resolve reject:reject];
93
71
  }
@@ -8,52 +8,25 @@ private enum SniConnectError: Error {
8
8
  @objc(SniConnectImpl)
9
9
  final class SniConnectImpl: NSObject {
10
10
  private let client: SniConnectClient
11
- private weak var eventSender: AnyObject?
12
11
 
13
12
  @objc
14
- init(eventSender: AnyObject) {
15
- self.eventSender = eventSender
13
+ override init() {
16
14
  self.client = SniConnectClient()
17
15
  super.init()
18
-
19
- // Set up logging closure for the client
20
- self.client.onLog = { [weak self] level, message in
21
- self?.sendLogEvent(level: level, message: message)
22
- }
23
- }
24
-
25
- private func sendLogEvent(level: String, message: String) {
26
- // Send log event to the Objective-C bridge
27
- guard let eventSender = eventSender else {
28
- return
29
- }
30
-
31
- let logData: [String: Any] = [
32
- "level": level,
33
- "message": message,
34
- "timestamp": Int(Date().timeIntervalSince1970 * 1000)
35
- ]
36
-
37
- // Call the sendLogEvent method on the bridge
38
- let selector = NSSelectorFromString("sendLogEvent:")
39
- if eventSender.responds(to: selector) {
40
- eventSender.perform(selector, with: logData)
41
- }
42
16
  }
43
17
 
44
18
  @objc
45
19
  public func request(
46
20
  _ config: NSDictionary,
47
- resolve resolve: @escaping RCTPromiseResolveBlock,
48
- reject reject: @escaping RCTPromiseRejectBlock
21
+ resolve: @escaping RCTPromiseResolveBlock,
22
+ reject: @escaping RCTPromiseRejectBlock
49
23
  ) {
50
24
  do {
51
25
  let parsed = try Self.parseDictionary(config)
52
26
  handleRequest(config: parsed, resolve: resolve, reject: reject)
53
27
  } catch {
54
- NSLog("[SniConnect] ❌ Config parsing failed: \(error)")
55
- sendLogEvent(level: "error", message: "Config parsing failed: \(error)")
56
- reject("SNI_REQUEST_FAILED", error.localizedDescription, error)
28
+ SniConnectLog.error("Config parsing failed: \(error)")
29
+ reject("SNI_INVALID_CONFIG", "\(error)", error)
57
30
  }
58
31
  }
59
32
 
@@ -62,12 +35,20 @@ final class SniConnectImpl: NSObject {
62
35
  resolve: @escaping RCTPromiseResolveBlock,
63
36
  reject: @escaping RCTPromiseRejectBlock
64
37
  ) {
65
- // Create task and register it synchronously before it starts executing
38
+ do {
39
+ try Self.validate(config)
40
+ } catch {
41
+ SniConnectLog.error("Config validation failed: \(error)")
42
+ reject("SNI_INVALID_CONFIG", "\(error)", error)
43
+ return
44
+ }
45
+
46
+ // Create the task and register it synchronously before JS can cancel it.
66
47
  let task = Task { () -> SniConnectClient.Response in
67
48
  return try await client.performRequest(config: config)
68
49
  }
69
50
 
70
- // Register task synchronously if requestId is provided
51
+ // Register task if requestId is provided
71
52
  if let requestId = config.requestId {
72
53
  client.registerTask(task, for: requestId)
73
54
  }
@@ -86,16 +67,11 @@ final class SniConnectImpl: NSObject {
86
67
  "multiValueHeaders": result.multiValueHeaders,
87
68
  ])
88
69
  } catch let error as SniConnectClient.SniConnectError {
89
- NSLog("[SniConnect] ❌ [\(error.code)] \(error.message)")
90
- sendLogEvent(level: "error", message: "[\(error.code)] \(error.message)")
91
70
  reject(error.code, error.message, error)
92
71
  } catch is CancellationError {
93
- NSLog("[SniConnect] ❌ Request cancelled")
94
- sendLogEvent(level: "info", message: "Request cancelled")
95
72
  reject("SNI_CANCELLED", "Request cancelled", nil)
96
73
  } catch {
97
- NSLog("[SniConnect] ❌ Request failed: \(error.localizedDescription)")
98
- sendLogEvent(level: "error", message: "Request failed: \(error.localizedDescription)")
74
+ SniConnectLog.error("Request failed: \(error.localizedDescription)")
99
75
  reject("SNI_UNKNOWN_ERROR", error.localizedDescription, error)
100
76
  }
101
77
  }
@@ -104,8 +80,8 @@ final class SniConnectImpl: NSObject {
104
80
  @objc
105
81
  public func cancelRequest(
106
82
  _ requestId: String,
107
- resolve resolve: @escaping RCTPromiseResolveBlock,
108
- reject reject: @escaping RCTPromiseRejectBlock
83
+ resolve: @escaping RCTPromiseResolveBlock,
84
+ reject: @escaping RCTPromiseRejectBlock
109
85
  ) {
110
86
  client.cancelRequest(requestId: requestId)
111
87
  resolve(["success": true])
@@ -114,7 +90,7 @@ final class SniConnectImpl: NSObject {
114
90
  @objc
115
91
  public func cancelAllRequests(
116
92
  _ resolve: @escaping RCTPromiseResolveBlock,
117
- reject reject: @escaping RCTPromiseRejectBlock
93
+ reject: @escaping RCTPromiseRejectBlock
118
94
  ) {
119
95
  client.cancelAllRequests()
120
96
  resolve(["success": true])
@@ -123,7 +99,7 @@ final class SniConnectImpl: NSObject {
123
99
  @objc
124
100
  public func clearDNSCache(
125
101
  _ resolve: @escaping RCTPromiseResolveBlock,
126
- reject reject: @escaping RCTPromiseRejectBlock
102
+ reject: @escaping RCTPromiseRejectBlock
127
103
  ) {
128
104
  client.clearDNSCache()
129
105
  resolve(["success": true])
@@ -162,24 +138,25 @@ final class SniConnectImpl: NSObject {
162
138
  )
163
139
  }
164
140
 
141
+ private static func validate(_ config: SniConnectClient.RequestConfig) throws {
142
+ try SniConnectValidation.validatePublicIP(config.ip)
143
+ try SniConnectValidation.validateHostname(config.hostname)
144
+ try SniConnectValidation.validateHeaders(config.headers)
145
+ _ = try SniConnectValidation.normalizeMethod(config.method)
146
+ _ = try SniConnectValidation.normalizePath(config.path)
147
+ }
148
+
165
149
  private static func serializeResponseData(_ data: Any) -> String {
166
- if let dict = data as? [String: Any],
167
- let jsonData = try? JSONSerialization.data(withJSONObject: dict, options: []),
168
- let jsonString = String(data: jsonData, encoding: .utf8) {
169
- return jsonString
150
+ if let stringValue = data as? String {
151
+ return stringValue
170
152
  }
171
153
 
172
- if let array = data as? [[String: Any]],
173
- let jsonData = try? JSONSerialization.data(withJSONObject: array, options: []),
154
+ if JSONSerialization.isValidJSONObject(data),
155
+ let jsonData = try? JSONSerialization.data(withJSONObject: data, options: []),
174
156
  let jsonString = String(data: jsonData, encoding: .utf8) {
175
157
  return jsonString
176
158
  }
177
159
 
178
- if let stringValue = data as? String {
179
- return stringValue
180
- }
181
-
182
160
  return String(describing: data)
183
161
  }
184
162
  }
185
-