@onekeyfe/react-native-sni-connect 1.0.0 → 3.0.69
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -19
- package/SniConnect.podspec +2 -2
- package/android/build.gradle +0 -21
- package/android/src/main/java/com/sniconnect/SniConnectLogger.kt +55 -0
- package/android/src/main/java/com/sniconnect/SniConnectModule.kt +116 -121
- package/android/src/main/java/com/sniconnect/SniConnectValidation.kt +144 -0
- package/ios/SniConnect-Bridging-Header.h +0 -1
- package/ios/SniConnect.mm +19 -41
- package/ios/SniConnect.swift +32 -55
- package/ios/SniConnectClient.swift +83 -101
- package/ios/SniConnectLog.swift +29 -0
- package/ios/SniConnectValidation.swift +151 -0
- package/lib/module/index.js +0 -15
- package/lib/typescript/src/NativeSniConnect.d.ts +1 -14
- package/lib/typescript/src/index.d.ts +0 -6
- package/package.json +56 -66
- package/src/NativeSniConnect.ts +1 -10
- package/src/index.tsx +0 -20
- package/LICENSE +0 -20
- package/lib/module/@types/react-native-codegen.d.js.map +0 -1
- package/lib/module/NativeSniConnect.js.map +0 -1
- package/lib/module/index.js.map +0 -1
- package/lib/typescript/src/NativeSniConnect.d.ts.map +0 -1
- package/lib/typescript/src/index.d.ts.map +0 -1
|
@@ -1,30 +1,34 @@
|
|
|
1
1
|
import Foundation
|
|
2
|
+
import UIKit
|
|
2
3
|
import EMASCurl
|
|
3
4
|
|
|
4
5
|
/// Core HTTPS client that enforces IP direct connection with SNI.
|
|
5
6
|
final class SniConnectClient {
|
|
6
7
|
|
|
7
|
-
// Logging callback
|
|
8
|
-
var onLog: ((String, String) -> Void)?
|
|
9
|
-
|
|
10
8
|
// Active requests tracking for cancellation support
|
|
11
9
|
private var activeTasks: [String: Task<Response, Error>] = [:]
|
|
12
10
|
private let tasksQueue = DispatchQueue(label: "com.onekey.sni.connect.tasks", attributes: .concurrent)
|
|
13
11
|
|
|
12
|
+
// Token for the memory-warning observer (block-based observers are not removed
|
|
13
|
+
// by `removeObserver(self)`, so the token must be retained and removed explicitly).
|
|
14
|
+
private var memoryWarningObserver: NSObjectProtocol?
|
|
15
|
+
|
|
14
16
|
init() {
|
|
15
17
|
// Register for memory warnings to clean cache
|
|
16
|
-
NotificationCenter.default.addObserver(
|
|
18
|
+
memoryWarningObserver = NotificationCenter.default.addObserver(
|
|
17
19
|
forName: UIApplication.didReceiveMemoryWarningNotification,
|
|
18
20
|
object: nil,
|
|
19
21
|
queue: .main
|
|
20
|
-
) {
|
|
21
|
-
|
|
22
|
+
) { _ in
|
|
23
|
+
SniConnectLog.info("Memory warning received, cleaning DNS cache")
|
|
22
24
|
DNSResolver.cleanExpiredEntries()
|
|
23
25
|
}
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
deinit {
|
|
27
|
-
|
|
29
|
+
if let observer = memoryWarningObserver {
|
|
30
|
+
NotificationCenter.default.removeObserver(observer)
|
|
31
|
+
}
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
struct RequestConfig {
|
|
@@ -164,8 +168,10 @@ final class SniConnectClient {
|
|
|
164
168
|
curlConfig.enableBuiltInRedirection = true
|
|
165
169
|
curlConfig.cacheEnabled = false
|
|
166
170
|
|
|
167
|
-
// Enable full certificate validation for security
|
|
168
|
-
// The certificate
|
|
171
|
+
// Enable full certificate validation for security.
|
|
172
|
+
// The certificate is validated against the SNI hostname, not the IP, because
|
|
173
|
+
// the custom DNS resolver only overrides address resolution — libcurl keeps the
|
|
174
|
+
// original hostname for SNI and certificate CN/SAN matching.
|
|
169
175
|
curlConfig.certificateValidationEnabled = true
|
|
170
176
|
curlConfig.domainNameVerificationEnabled = true
|
|
171
177
|
curlConfig.dnsResolver = DNSResolver.self
|
|
@@ -178,81 +184,53 @@ final class SniConnectClient {
|
|
|
178
184
|
private static let queue = DispatchQueue(label: "com.onekey.sni.connect.dns", attributes: .concurrent)
|
|
179
185
|
private static let cache = DNSCache()
|
|
180
186
|
|
|
181
|
-
///
|
|
182
|
-
|
|
183
|
-
|
|
187
|
+
/// Thread-safe hostname -> IP pin with TTL.
|
|
188
|
+
///
|
|
189
|
+
/// LIMITATION: EMASCurl only exposes a process-global DNS resolver
|
|
190
|
+
/// (`setDNSResolver:`), which receives only the hostname. There is no
|
|
191
|
+
/// per-request DNS API, so the pin is keyed by hostname and the most recent
|
|
192
|
+
/// IP for a hostname wins. Concurrent requests to the SAME hostname targeting
|
|
193
|
+
/// DIFFERENT IPs are therefore not guaranteed to each hit their own IP. Normal
|
|
194
|
+
/// usage (one IP per hostname at a time) is unaffected.
|
|
195
|
+
private final class DNSCache {
|
|
196
|
+
private struct Entry {
|
|
184
197
|
let ip: String
|
|
185
198
|
let timestamp: TimeInterval
|
|
186
199
|
}
|
|
187
200
|
|
|
188
|
-
private var
|
|
189
|
-
private var accessOrder: [String] = []
|
|
190
|
-
|
|
191
|
-
// Maximum cache entries (100 as specified in requirements)
|
|
201
|
+
private var hostnameToEntry: [String: Entry] = [:]
|
|
192
202
|
private let maxSize = 100
|
|
193
|
-
|
|
194
|
-
private let ttl: TimeInterval = 300
|
|
203
|
+
private let ttl: TimeInterval = 300 // 5 minutes
|
|
195
204
|
|
|
196
205
|
func get(_ domain: String) -> String? {
|
|
197
206
|
let key = domain.lowercased()
|
|
198
|
-
guard let entry =
|
|
199
|
-
|
|
200
|
-
// Check if entry has expired
|
|
201
|
-
let now = Date().timeIntervalSince1970
|
|
202
|
-
if now - entry.timestamp > ttl {
|
|
203
|
-
remove(key)
|
|
207
|
+
guard let entry = hostnameToEntry[key] else { return nil }
|
|
208
|
+
if Date().timeIntervalSince1970 - entry.timestamp > ttl {
|
|
204
209
|
return nil
|
|
205
210
|
}
|
|
206
|
-
|
|
207
|
-
// Update access order (LRU)
|
|
208
|
-
if let index = accessOrder.firstIndex(of: key) {
|
|
209
|
-
accessOrder.remove(at: index)
|
|
210
|
-
}
|
|
211
|
-
accessOrder.append(key)
|
|
212
|
-
|
|
213
211
|
return entry.ip
|
|
214
212
|
}
|
|
215
213
|
|
|
216
214
|
func set(_ ip: String, for domain: String) {
|
|
217
215
|
let key = domain.lowercased()
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
storage.removeValue(forKey: oldest)
|
|
223
|
-
accessOrder.removeFirst()
|
|
216
|
+
// Evict the oldest entry when at capacity (and this is a new host).
|
|
217
|
+
if hostnameToEntry.count >= maxSize && hostnameToEntry[key] == nil {
|
|
218
|
+
if let oldest = hostnameToEntry.min(by: { $0.value.timestamp < $1.value.timestamp })?.key {
|
|
219
|
+
hostnameToEntry.removeValue(forKey: oldest)
|
|
224
220
|
}
|
|
225
221
|
}
|
|
226
|
-
|
|
227
|
-
// Add or update entry
|
|
228
|
-
let entry = CacheEntry(ip: ip, timestamp: Date().timeIntervalSince1970)
|
|
229
|
-
storage[key] = entry
|
|
230
|
-
|
|
231
|
-
// Update access order
|
|
232
|
-
if let index = accessOrder.firstIndex(of: key) {
|
|
233
|
-
accessOrder.remove(at: index)
|
|
234
|
-
}
|
|
235
|
-
accessOrder.append(key)
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
func remove(_ domain: String) {
|
|
239
|
-
let key = domain.lowercased()
|
|
240
|
-
storage.removeValue(forKey: key)
|
|
241
|
-
if let index = accessOrder.firstIndex(of: key) {
|
|
242
|
-
accessOrder.remove(at: index)
|
|
243
|
-
}
|
|
222
|
+
hostnameToEntry[key] = Entry(ip: ip, timestamp: Date().timeIntervalSince1970)
|
|
244
223
|
}
|
|
245
224
|
|
|
246
225
|
func clear() {
|
|
247
|
-
|
|
248
|
-
accessOrder.removeAll()
|
|
226
|
+
hostnameToEntry.removeAll()
|
|
249
227
|
}
|
|
250
228
|
|
|
251
229
|
func cleanExpired() {
|
|
252
230
|
let now = Date().timeIntervalSince1970
|
|
253
|
-
let
|
|
254
|
-
for key in
|
|
255
|
-
|
|
231
|
+
let expired = hostnameToEntry.filter { now - $0.value.timestamp > ttl }.map { $0.key }
|
|
232
|
+
for key in expired {
|
|
233
|
+
hostnameToEntry.removeValue(forKey: key)
|
|
256
234
|
}
|
|
257
235
|
}
|
|
258
236
|
}
|
|
@@ -289,19 +267,19 @@ final class SniConnectClient {
|
|
|
289
267
|
/// Clear all DNS cache entries
|
|
290
268
|
func clearDNSCache() {
|
|
291
269
|
DNSResolver.clearCache()
|
|
292
|
-
|
|
270
|
+
SniConnectLog.info("DNS cache cleared")
|
|
293
271
|
}
|
|
294
272
|
|
|
295
273
|
/// Cancel a request by ID
|
|
296
274
|
func cancelRequest(requestId: String) {
|
|
297
275
|
tasksQueue.async(flags: .barrier) { [weak self] in
|
|
298
276
|
guard let task = self?.activeTasks[requestId] else {
|
|
299
|
-
|
|
277
|
+
SniConnectLog.warn("No active request found with ID: \(requestId)")
|
|
300
278
|
return
|
|
301
279
|
}
|
|
302
280
|
task.cancel()
|
|
303
281
|
self?.activeTasks.removeValue(forKey: requestId)
|
|
304
|
-
|
|
282
|
+
SniConnectLog.info("Request cancelled: \(requestId)")
|
|
305
283
|
}
|
|
306
284
|
}
|
|
307
285
|
|
|
@@ -314,15 +292,14 @@ final class SniConnectClient {
|
|
|
314
292
|
task.cancel()
|
|
315
293
|
}
|
|
316
294
|
self.activeTasks.removeAll()
|
|
317
|
-
|
|
295
|
+
SniConnectLog.info("Cancelled \(count) active requests")
|
|
318
296
|
}
|
|
319
297
|
}
|
|
320
298
|
|
|
321
|
-
/// Register an active task
|
|
299
|
+
/// Register an active task immediately after creation, before JS can cancel it.
|
|
322
300
|
func registerTask(_ task: Task<Response, Error>, for requestId: String) {
|
|
323
301
|
tasksQueue.sync(flags: .barrier) { [weak self] in
|
|
324
302
|
self?.activeTasks[requestId] = task
|
|
325
|
-
self?.onLog?("info", "Registered request: \(requestId), total active: \(self?.activeTasks.count ?? 0)")
|
|
326
303
|
}
|
|
327
304
|
}
|
|
328
305
|
|
|
@@ -331,7 +308,6 @@ final class SniConnectClient {
|
|
|
331
308
|
guard let requestId = requestId else { return }
|
|
332
309
|
tasksQueue.async(flags: .barrier) { [weak self] in
|
|
333
310
|
self?.activeTasks.removeValue(forKey: requestId)
|
|
334
|
-
self?.onLog?("info", "Unregistered request: \(requestId)")
|
|
335
311
|
}
|
|
336
312
|
}
|
|
337
313
|
|
|
@@ -343,12 +319,25 @@ final class SniConnectClient {
|
|
|
343
319
|
unregisterTask(requestId: config.requestId)
|
|
344
320
|
}
|
|
345
321
|
|
|
322
|
+
// Validate every caller-controlled field before it reaches the network layer.
|
|
323
|
+
let method: String
|
|
324
|
+
let normalizedPath: String
|
|
325
|
+
do {
|
|
326
|
+
try SniConnectValidation.validatePublicIP(config.ip)
|
|
327
|
+
try SniConnectValidation.validateHostname(config.hostname)
|
|
328
|
+
try SniConnectValidation.validateHeaders(config.headers)
|
|
329
|
+
method = try SniConnectValidation.normalizeMethod(config.method)
|
|
330
|
+
normalizedPath = try SniConnectValidation.normalizePath(config.path)
|
|
331
|
+
} catch {
|
|
332
|
+
throw SniConnectError.invalidConfig("\(error)")
|
|
333
|
+
}
|
|
334
|
+
|
|
346
335
|
DNSResolver.setIP(config.ip, for: config.hostname)
|
|
347
336
|
|
|
348
|
-
let url = try Self.buildURL(hostname: config.hostname,
|
|
337
|
+
let url = try Self.buildURL(hostname: config.hostname, normalizedPath: normalizedPath)
|
|
349
338
|
|
|
350
339
|
let mutableRequest = NSMutableURLRequest(url: url)
|
|
351
|
-
mutableRequest.httpMethod =
|
|
340
|
+
mutableRequest.httpMethod = method
|
|
352
341
|
|
|
353
342
|
// Convert milliseconds to seconds for timeout values
|
|
354
343
|
let totalTimeoutSeconds = config.effectiveTotalTimeout / 1000.0
|
|
@@ -373,15 +362,15 @@ final class SniConnectClient {
|
|
|
373
362
|
mutableRequest.httpBody = bodyData
|
|
374
363
|
}
|
|
375
364
|
|
|
376
|
-
//
|
|
377
|
-
EMASCurlProtocol.
|
|
365
|
+
// Per-request connect timeout (avoids the process-global setter race).
|
|
366
|
+
EMASCurlProtocol.setConnectTimeoutIntervalFor(mutableRequest, connectTimeoutInterval: connectTimeoutSeconds)
|
|
378
367
|
let request = mutableRequest as URLRequest
|
|
379
368
|
|
|
380
369
|
do {
|
|
381
370
|
let (data, response) = try await Self.urlSession.data(for: request)
|
|
382
371
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
383
372
|
let errorMsg = "Invalid HTTP response type"
|
|
384
|
-
|
|
373
|
+
SniConnectLog.error(errorMsg)
|
|
385
374
|
throw SniConnectError.invalidConfig(errorMsg)
|
|
386
375
|
}
|
|
387
376
|
|
|
@@ -390,11 +379,10 @@ final class SniConnectClient {
|
|
|
390
379
|
let (headers, multiValueHeaders) = Self.extractHeaders(from: httpResponse)
|
|
391
380
|
let statusText = HTTPURLResponse.localizedString(forStatusCode: status)
|
|
392
381
|
|
|
393
|
-
//
|
|
382
|
+
// 4xx/5xx are returned to JS as a normal response (the caller inspects
|
|
383
|
+
// `status`); we only record it for diagnostics.
|
|
394
384
|
if status >= 400 {
|
|
395
|
-
|
|
396
|
-
onLog?("error", "HTTP error: \(error.message)")
|
|
397
|
-
// Still return the response for client to handle
|
|
385
|
+
SniConnectLog.warn("HTTP \(status) for \(config.hostname)")
|
|
398
386
|
}
|
|
399
387
|
|
|
400
388
|
return Response(
|
|
@@ -405,33 +393,33 @@ final class SniConnectClient {
|
|
|
405
393
|
multiValueHeaders: multiValueHeaders
|
|
406
394
|
)
|
|
407
395
|
} catch let error as SniConnectError {
|
|
408
|
-
|
|
396
|
+
SniConnectLog.error("[\(error.code)] \(error.message)")
|
|
409
397
|
throw error
|
|
410
398
|
} catch {
|
|
411
399
|
// Convert generic errors to specific SniConnectError types
|
|
412
400
|
let sniError = SniConnectError.from(error)
|
|
413
|
-
|
|
401
|
+
SniConnectLog.error("[\(sniError.code)] \(sniError.message)")
|
|
414
402
|
throw sniError
|
|
415
403
|
}
|
|
416
404
|
}
|
|
417
405
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
406
|
+
/// Build the request URL. Always `https://<hostname><path>` on port 443 — the
|
|
407
|
+
/// path has already been validated as relative (no scheme/authority), so the
|
|
408
|
+
/// caller cannot override scheme, host or port.
|
|
409
|
+
private static func buildURL(hostname: String, normalizedPath: String) throws -> URL {
|
|
410
|
+
var components = URLComponents()
|
|
411
|
+
components.scheme = "https"
|
|
412
|
+
components.host = hostname
|
|
413
|
+
// `normalizedPath` is "/...optional?query". Split off the query so URLComponents
|
|
414
|
+
// percent-encodes each part correctly.
|
|
415
|
+
if let queryIndex = normalizedPath.firstIndex(of: "?") {
|
|
416
|
+
components.percentEncodedPath = String(normalizedPath[..<queryIndex])
|
|
417
|
+
let queryStart = normalizedPath.index(after: queryIndex)
|
|
418
|
+
components.percentEncodedQuery = String(normalizedPath[queryStart...])
|
|
430
419
|
} else {
|
|
431
|
-
|
|
420
|
+
components.percentEncodedPath = normalizedPath
|
|
432
421
|
}
|
|
433
|
-
|
|
434
|
-
guard let url = URL(string: "https://\(hostname)\(normalizedPath)") else {
|
|
422
|
+
guard let url = components.url else {
|
|
435
423
|
throw SniConnectError.invalidURL
|
|
436
424
|
}
|
|
437
425
|
return url
|
|
@@ -439,7 +427,7 @@ final class SniConnectClient {
|
|
|
439
427
|
|
|
440
428
|
private static func parseResponseData(_ data: Data) -> Any {
|
|
441
429
|
guard !data.isEmpty else {
|
|
442
|
-
return
|
|
430
|
+
return ""
|
|
443
431
|
}
|
|
444
432
|
|
|
445
433
|
if let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []) {
|
|
@@ -476,13 +464,7 @@ final class SniConnectClient {
|
|
|
476
464
|
for (key, values) in headerGroups {
|
|
477
465
|
// For backward compatibility, single-value headers use the last value
|
|
478
466
|
singleValueHeaders[key] = values.last
|
|
479
|
-
|
|
480
|
-
// Multi-value headers contain all values
|
|
481
|
-
if values.count > 1 {
|
|
482
|
-
multiValueHeaders[key] = values
|
|
483
|
-
} else {
|
|
484
|
-
multiValueHeaders[key] = values
|
|
485
|
-
}
|
|
467
|
+
multiValueHeaders[key] = values
|
|
486
468
|
}
|
|
487
469
|
|
|
488
470
|
return (singleValueHeaders, multiValueHeaders)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Lightweight logging wrapper that dynamically dispatches to OneKeyLog through
|
|
4
|
+
/// the Objective-C runtime.
|
|
5
|
+
///
|
|
6
|
+
/// Using reflection (instead of `import ReactNativeNativeLogger`) keeps this
|
|
7
|
+
/// TurboModule from hard-linking the nitro-based native-logger module. When
|
|
8
|
+
/// OneKeyLog is unavailable the logs are silently dropped. Mirrors the Android
|
|
9
|
+
/// `SniConnectLogger` and the existing `BTLogger` / `SBLLogger`.
|
|
10
|
+
enum SniConnectLog {
|
|
11
|
+
private static let tag = "SniConnect"
|
|
12
|
+
|
|
13
|
+
// Swift classes are exposed to the ObjC runtime as `Module.ClassName`.
|
|
14
|
+
private static let logClass: AnyObject? =
|
|
15
|
+
(NSClassFromString("ReactNativeNativeLogger.OneKeyLog")
|
|
16
|
+
?? NSClassFromString("OneKeyLog")) as AnyObject?
|
|
17
|
+
|
|
18
|
+
static func debug(_ message: String) { dispatch("debug::", message) }
|
|
19
|
+
static func info(_ message: String) { dispatch("info::", message) }
|
|
20
|
+
static func warn(_ message: String) { dispatch("warn::", message) }
|
|
21
|
+
static func error(_ message: String) { dispatch("error::", message) }
|
|
22
|
+
|
|
23
|
+
private static func dispatch(_ selectorName: String, _ message: String) {
|
|
24
|
+
guard let cls = logClass else { return }
|
|
25
|
+
let sel = NSSelectorFromString(selectorName)
|
|
26
|
+
guard cls.responds(to: sel) else { return }
|
|
27
|
+
_ = cls.perform(sel, with: tag, with: message)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Boundary validation/normalization for SNI request inputs.
|
|
4
|
+
///
|
|
5
|
+
/// The module connects to a caller-supplied IP while preserving the TLS SNI/Host
|
|
6
|
+
/// of `hostname`. Because the connect target is caller-controlled, every field that
|
|
7
|
+
/// reaches the network layer is validated here to prevent SSRF, scheme/host/port
|
|
8
|
+
/// override, cleartext downgrade and CR/LF header injection.
|
|
9
|
+
enum SniConnectValidation {
|
|
10
|
+
|
|
11
|
+
enum ValidationError: Error {
|
|
12
|
+
case invalidIP(String)
|
|
13
|
+
case forbiddenIP(String)
|
|
14
|
+
case invalidHostname(String)
|
|
15
|
+
case invalidMethod(String)
|
|
16
|
+
case invalidPath(String)
|
|
17
|
+
case invalidHeader(String)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/// HTTP methods the module is allowed to issue.
|
|
21
|
+
private static let allowedMethods: Set<String> = [
|
|
22
|
+
"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
/// Validate and uppercase the HTTP method.
|
|
26
|
+
static func normalizeMethod(_ method: String) throws -> String {
|
|
27
|
+
let upper = method.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
|
28
|
+
guard allowedMethods.contains(upper) else {
|
|
29
|
+
throw ValidationError.invalidMethod(method)
|
|
30
|
+
}
|
|
31
|
+
return upper
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/// Validate `hostname` as a DNS host (used for SNI, Host header and cert matching).
|
|
35
|
+
static func validateHostname(_ hostname: String) throws {
|
|
36
|
+
guard hostname.count <= 253, !hostname.isEmpty else {
|
|
37
|
+
throw ValidationError.invalidHostname(hostname)
|
|
38
|
+
}
|
|
39
|
+
// Labels: 1-63 chars, alphanumeric + hyphen, not starting/ending with hyphen.
|
|
40
|
+
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])?)*$"
|
|
41
|
+
guard hostname.range(of: pattern, options: .regularExpression) != nil else {
|
|
42
|
+
throw ValidationError.invalidHostname(hostname)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/// Validate `path`: must be a relative path/query only. Reject absolute URLs
|
|
47
|
+
/// (scheme/authority), protocol-relative URLs and control characters so the
|
|
48
|
+
/// caller cannot override scheme/host/port or downgrade to cleartext.
|
|
49
|
+
static func normalizePath(_ path: String) throws -> String {
|
|
50
|
+
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
51
|
+
if containsControlCharacters(trimmed) {
|
|
52
|
+
throw ValidationError.invalidPath(path)
|
|
53
|
+
}
|
|
54
|
+
// Reject anything that looks like it carries a scheme or authority.
|
|
55
|
+
if trimmed.contains("://") || trimmed.hasPrefix("//") {
|
|
56
|
+
throw ValidationError.invalidPath(path)
|
|
57
|
+
}
|
|
58
|
+
// A bare scheme like "javascript:..." has no "//"; reject any leading scheme.
|
|
59
|
+
if let schemeRange = trimmed.range(of: "^[A-Za-z][A-Za-z0-9+.-]*:", options: .regularExpression),
|
|
60
|
+
schemeRange.lowerBound == trimmed.startIndex {
|
|
61
|
+
throw ValidationError.invalidPath(path)
|
|
62
|
+
}
|
|
63
|
+
if trimmed.isEmpty { return "/" }
|
|
64
|
+
return trimmed.hasPrefix("/") ? trimmed : "/" + trimmed
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/// Validate header names/values: reject CR/LF/control characters (header
|
|
68
|
+
/// injection) and the Host header (the module sets Host itself).
|
|
69
|
+
static func validateHeaders(_ headers: [String: String]) throws {
|
|
70
|
+
for (key, value) in headers {
|
|
71
|
+
if key.isEmpty || containsControlCharacters(key) || containsControlCharacters(value) {
|
|
72
|
+
throw ValidationError.invalidHeader(key)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
static func containsControlCharacters(_ s: String) -> Bool {
|
|
78
|
+
return s.unicodeScalars.contains { $0.value < 0x20 || $0.value == 0x7F }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// MARK: - IP validation
|
|
82
|
+
|
|
83
|
+
/// Validate `ip` is a literal IPv4/IPv6 address (never a hostname) and routes
|
|
84
|
+
/// to a public/global-unicast destination. Rejects loopback, private,
|
|
85
|
+
/// link-local (incl. 169.254.169.254 metadata), CGNAT, multicast and reserved.
|
|
86
|
+
static func validatePublicIP(_ ip: String) throws {
|
|
87
|
+
if let v4 = parseIPv4(ip) {
|
|
88
|
+
if isForbiddenIPv4(v4) { throw ValidationError.forbiddenIP(ip) }
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
if let v6 = parseIPv6(ip) {
|
|
92
|
+
if isForbiddenIPv6(v6) { throw ValidationError.forbiddenIP(ip) }
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
throw ValidationError.invalidIP(ip)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private static func parseIPv4(_ ip: String) -> [UInt8]? {
|
|
99
|
+
var addr = in_addr()
|
|
100
|
+
guard ip.withCString({ inet_pton(AF_INET, $0, &addr) }) == 1 else { return nil }
|
|
101
|
+
let raw = addr.s_addr.bigEndian
|
|
102
|
+
return [
|
|
103
|
+
UInt8((raw >> 24) & 0xFF),
|
|
104
|
+
UInt8((raw >> 16) & 0xFF),
|
|
105
|
+
UInt8((raw >> 8) & 0xFF),
|
|
106
|
+
UInt8(raw & 0xFF),
|
|
107
|
+
]
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private static func parseIPv6(_ ip: String) -> [UInt8]? {
|
|
111
|
+
var addr = in6_addr()
|
|
112
|
+
guard ip.withCString({ inet_pton(AF_INET6, $0, &addr) }) == 1 else { return nil }
|
|
113
|
+
return withUnsafeBytes(of: &addr) { Array($0.bindMemory(to: UInt8.self)) }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private static func isForbiddenIPv4(_ b: [UInt8]) -> Bool {
|
|
117
|
+
let a = b[0], c = b[1], d = b[2]
|
|
118
|
+
if a == 0 { return true } // 0.0.0.0/8 "this network"
|
|
119
|
+
if a == 10 { return true } // 10/8 private
|
|
120
|
+
if a == 127 { return true } // 127/8 loopback
|
|
121
|
+
if a == 100 && (c & 0xC0) == 0x40 { return true } // 100.64/10 CGNAT
|
|
122
|
+
if a == 169 && c == 254 { return true } // 169.254/16 link-local + metadata
|
|
123
|
+
if a == 172 && c >= 16 && c <= 31 { return true } // 172.16/12 private
|
|
124
|
+
if a == 192 && c == 168 { return true } // 192.168/16 private
|
|
125
|
+
if a == 192 && c == 0 && d == 0 { return true } // 192.0.0/24
|
|
126
|
+
if a == 192 && c == 0 && d == 2 { return true } // 192.0.2/24 TEST-NET-1
|
|
127
|
+
if a == 198 && (c == 18 || c == 19) { return true } // 198.18/15 benchmarking
|
|
128
|
+
if a == 198 && c == 51 && d == 100 { return true } // 198.51.100/24 TEST-NET-2
|
|
129
|
+
if a == 203 && c == 0 && d == 113 { return true } // 203.0.113/24 TEST-NET-3
|
|
130
|
+
if a >= 224 { return true } // 224/4 multicast + 240/4 reserved + broadcast
|
|
131
|
+
return false
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private static func isForbiddenIPv6(_ b: [UInt8]) -> Bool {
|
|
135
|
+
// Unspecified ::
|
|
136
|
+
if b.allSatisfy({ $0 == 0 }) { return true }
|
|
137
|
+
// Loopback ::1
|
|
138
|
+
if b[0...14].allSatisfy({ $0 == 0 }) && b[15] == 1 { return true }
|
|
139
|
+
// Multicast ff00::/8
|
|
140
|
+
if b[0] == 0xFF { return true }
|
|
141
|
+
// Link-local fe80::/10
|
|
142
|
+
if b[0] == 0xFE && (b[1] & 0xC0) == 0x80 { return true }
|
|
143
|
+
// Unique local fc00::/7
|
|
144
|
+
if (b[0] & 0xFE) == 0xFC { return true }
|
|
145
|
+
// IPv4-mapped ::ffff:0:0/96 — validate the embedded IPv4
|
|
146
|
+
if b[0...9].allSatisfy({ $0 == 0 }) && b[10] == 0xFF && b[11] == 0xFF {
|
|
147
|
+
return isForbiddenIPv4([b[12], b[13], b[14], b[15]])
|
|
148
|
+
}
|
|
149
|
+
return false
|
|
150
|
+
}
|
|
151
|
+
}
|
package/lib/module/index.js
CHANGED
|
@@ -1,21 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
-
import { NativeEventEmitter } from 'react-native';
|
|
4
3
|
import NativeSniConnect from "./NativeSniConnect.js";
|
|
5
|
-
|
|
6
|
-
// Log entry type definition
|
|
7
|
-
|
|
8
|
-
// Create event emitter instance
|
|
9
|
-
const eventEmitter = new NativeEventEmitter(NativeSniConnect);
|
|
10
|
-
|
|
11
|
-
// Simple log subscription function
|
|
12
|
-
export function subscribeToLogs(callback) {
|
|
13
|
-
const subscription = eventEmitter.addListener('SniConnectLog', log => {
|
|
14
|
-
// Type assertion since we know the structure
|
|
15
|
-
callback(log);
|
|
16
|
-
});
|
|
17
|
-
return () => subscription.remove();
|
|
18
|
-
}
|
|
19
4
|
export function request(config) {
|
|
20
5
|
return NativeSniConnect.request(config);
|
|
21
6
|
}
|
|
@@ -30,21 +30,8 @@ export interface Spec extends TurboModule {
|
|
|
30
30
|
clearDNSCache(): Promise<{
|
|
31
31
|
success: boolean;
|
|
32
32
|
}>;
|
|
33
|
-
addListener(eventType: string): void;
|
|
34
|
-
removeListeners(count: Int32): void;
|
|
35
33
|
}
|
|
36
|
-
export type SniConnectModule = Spec
|
|
37
|
-
request(config: SniConnectRequest): Promise<SniConnectResponse>;
|
|
38
|
-
cancelRequest(requestId: string): Promise<{
|
|
39
|
-
success: boolean;
|
|
40
|
-
}>;
|
|
41
|
-
cancelAllRequests(): Promise<{
|
|
42
|
-
success: boolean;
|
|
43
|
-
}>;
|
|
44
|
-
clearDNSCache(): Promise<{
|
|
45
|
-
success: boolean;
|
|
46
|
-
}>;
|
|
47
|
-
};
|
|
34
|
+
export type SniConnectModule = Spec;
|
|
48
35
|
declare const NativeSniConnect: SniConnectModule;
|
|
49
36
|
export default NativeSniConnect;
|
|
50
37
|
//# sourceMappingURL=NativeSniConnect.d.ts.map
|
|
@@ -1,10 +1,4 @@
|
|
|
1
1
|
import { type SniConnectRequest, type SniConnectResponse } from './NativeSniConnect';
|
|
2
|
-
export type LogEntry = {
|
|
3
|
-
level: string;
|
|
4
|
-
message: string;
|
|
5
|
-
timestamp: number;
|
|
6
|
-
};
|
|
7
|
-
export declare function subscribeToLogs(callback: (log: LogEntry) => void): () => void;
|
|
8
2
|
export declare function request(config: SniConnectRequest): Promise<SniConnectResponse>;
|
|
9
3
|
export declare function cancelRequest(requestId: string): Promise<{
|
|
10
4
|
success: boolean;
|