@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.
- 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 +104 -123
- 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 +88 -113
- 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,88 +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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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 {
|
|
186
197
|
let ip: String
|
|
187
198
|
let timestamp: TimeInterval
|
|
188
199
|
}
|
|
189
200
|
|
|
190
|
-
private var
|
|
191
|
-
private var accessOrder: [String] = []
|
|
192
|
-
|
|
193
|
-
// Mapping from hostname to IP for DNS resolution
|
|
194
|
-
// This stores the most recently set IP for each hostname
|
|
195
|
-
private var hostnameToIP: [String: String] = [:]
|
|
196
|
-
|
|
197
|
-
// Maximum cache entries (100 as specified in requirements)
|
|
201
|
+
private var hostnameToEntry: [String: Entry] = [:]
|
|
198
202
|
private let maxSize = 100
|
|
199
|
-
|
|
200
|
-
private let ttl: TimeInterval = 300
|
|
201
|
-
|
|
202
|
-
/// Generate cache key using hostname:IP format
|
|
203
|
-
/// This ensures different IPs for the same hostname are isolated (accurate speed testing)
|
|
204
|
-
private func makeCacheKey(hostname: String, ip: String) -> String {
|
|
205
|
-
return "\(hostname.lowercased()):\(ip)"
|
|
206
|
-
}
|
|
203
|
+
private let ttl: TimeInterval = 300 // 5 minutes
|
|
207
204
|
|
|
208
205
|
func get(_ domain: String) -> String? {
|
|
209
|
-
let
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
206
|
+
let key = domain.lowercased()
|
|
207
|
+
guard let entry = hostnameToEntry[key] else { return nil }
|
|
208
|
+
if Date().timeIntervalSince1970 - entry.timestamp > ttl {
|
|
209
|
+
return nil
|
|
210
|
+
}
|
|
211
|
+
return entry.ip
|
|
213
212
|
}
|
|
214
213
|
|
|
215
214
|
func set(_ ip: String, for domain: String) {
|
|
216
|
-
let
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
// Evict oldest entry if cache is full
|
|
223
|
-
if storage.count >= maxSize && storage[key] == nil {
|
|
224
|
-
if let oldest = accessOrder.first {
|
|
225
|
-
storage.removeValue(forKey: oldest)
|
|
226
|
-
accessOrder.removeFirst()
|
|
215
|
+
let key = domain.lowercased()
|
|
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)
|
|
227
220
|
}
|
|
228
221
|
}
|
|
229
|
-
|
|
230
|
-
// Add or update entry
|
|
231
|
-
let entry = CacheEntry(hostname: normalizedDomain, ip: ip, timestamp: Date().timeIntervalSince1970)
|
|
232
|
-
storage[key] = entry
|
|
233
|
-
|
|
234
|
-
// Update access order
|
|
235
|
-
if let index = accessOrder.firstIndex(of: key) {
|
|
236
|
-
accessOrder.remove(at: index)
|
|
237
|
-
}
|
|
238
|
-
accessOrder.append(key)
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
func remove(_ key: String) {
|
|
242
|
-
storage.removeValue(forKey: key)
|
|
243
|
-
if let index = accessOrder.firstIndex(of: key) {
|
|
244
|
-
accessOrder.remove(at: index)
|
|
245
|
-
}
|
|
222
|
+
hostnameToEntry[key] = Entry(ip: ip, timestamp: Date().timeIntervalSince1970)
|
|
246
223
|
}
|
|
247
224
|
|
|
248
225
|
func clear() {
|
|
249
|
-
|
|
250
|
-
accessOrder.removeAll()
|
|
251
|
-
hostnameToIP.removeAll()
|
|
226
|
+
hostnameToEntry.removeAll()
|
|
252
227
|
}
|
|
253
228
|
|
|
254
229
|
func cleanExpired() {
|
|
255
230
|
let now = Date().timeIntervalSince1970
|
|
256
|
-
let
|
|
257
|
-
for key in
|
|
258
|
-
|
|
259
|
-
// Also remove from hostnameToIP if this was the active mapping
|
|
260
|
-
if let entry = storage[key], hostnameToIP[entry.hostname] == entry.ip {
|
|
261
|
-
hostnameToIP.removeValue(forKey: entry.hostname)
|
|
262
|
-
}
|
|
231
|
+
let expired = hostnameToEntry.filter { now - $0.value.timestamp > ttl }.map { $0.key }
|
|
232
|
+
for key in expired {
|
|
233
|
+
hostnameToEntry.removeValue(forKey: key)
|
|
263
234
|
}
|
|
264
235
|
}
|
|
265
236
|
}
|
|
@@ -296,19 +267,19 @@ final class SniConnectClient {
|
|
|
296
267
|
/// Clear all DNS cache entries
|
|
297
268
|
func clearDNSCache() {
|
|
298
269
|
DNSResolver.clearCache()
|
|
299
|
-
|
|
270
|
+
SniConnectLog.info("DNS cache cleared")
|
|
300
271
|
}
|
|
301
272
|
|
|
302
273
|
/// Cancel a request by ID
|
|
303
274
|
func cancelRequest(requestId: String) {
|
|
304
275
|
tasksQueue.async(flags: .barrier) { [weak self] in
|
|
305
276
|
guard let task = self?.activeTasks[requestId] else {
|
|
306
|
-
|
|
277
|
+
SniConnectLog.warn("No active request found with ID: \(requestId)")
|
|
307
278
|
return
|
|
308
279
|
}
|
|
309
280
|
task.cancel()
|
|
310
281
|
self?.activeTasks.removeValue(forKey: requestId)
|
|
311
|
-
|
|
282
|
+
SniConnectLog.info("Request cancelled: \(requestId)")
|
|
312
283
|
}
|
|
313
284
|
}
|
|
314
285
|
|
|
@@ -321,15 +292,14 @@ final class SniConnectClient {
|
|
|
321
292
|
task.cancel()
|
|
322
293
|
}
|
|
323
294
|
self.activeTasks.removeAll()
|
|
324
|
-
|
|
295
|
+
SniConnectLog.info("Cancelled \(count) active requests")
|
|
325
296
|
}
|
|
326
297
|
}
|
|
327
298
|
|
|
328
|
-
/// Register an active task
|
|
299
|
+
/// Register an active task immediately after creation, before JS can cancel it.
|
|
329
300
|
func registerTask(_ task: Task<Response, Error>, for requestId: String) {
|
|
330
301
|
tasksQueue.sync(flags: .barrier) { [weak self] in
|
|
331
302
|
self?.activeTasks[requestId] = task
|
|
332
|
-
self?.onLog?("info", "Registered request: \(requestId), total active: \(self?.activeTasks.count ?? 0)")
|
|
333
303
|
}
|
|
334
304
|
}
|
|
335
305
|
|
|
@@ -338,7 +308,6 @@ final class SniConnectClient {
|
|
|
338
308
|
guard let requestId = requestId else { return }
|
|
339
309
|
tasksQueue.async(flags: .barrier) { [weak self] in
|
|
340
310
|
self?.activeTasks.removeValue(forKey: requestId)
|
|
341
|
-
self?.onLog?("info", "Unregistered request: \(requestId)")
|
|
342
311
|
}
|
|
343
312
|
}
|
|
344
313
|
|
|
@@ -350,12 +319,25 @@ final class SniConnectClient {
|
|
|
350
319
|
unregisterTask(requestId: config.requestId)
|
|
351
320
|
}
|
|
352
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
|
+
|
|
353
335
|
DNSResolver.setIP(config.ip, for: config.hostname)
|
|
354
336
|
|
|
355
|
-
let url = try Self.buildURL(hostname: config.hostname,
|
|
337
|
+
let url = try Self.buildURL(hostname: config.hostname, normalizedPath: normalizedPath)
|
|
356
338
|
|
|
357
339
|
let mutableRequest = NSMutableURLRequest(url: url)
|
|
358
|
-
mutableRequest.httpMethod =
|
|
340
|
+
mutableRequest.httpMethod = method
|
|
359
341
|
|
|
360
342
|
// Convert milliseconds to seconds for timeout values
|
|
361
343
|
let totalTimeoutSeconds = config.effectiveTotalTimeout / 1000.0
|
|
@@ -380,15 +362,15 @@ final class SniConnectClient {
|
|
|
380
362
|
mutableRequest.httpBody = bodyData
|
|
381
363
|
}
|
|
382
364
|
|
|
383
|
-
//
|
|
384
|
-
EMASCurlProtocol.
|
|
365
|
+
// Per-request connect timeout (avoids the process-global setter race).
|
|
366
|
+
EMASCurlProtocol.setConnectTimeoutIntervalFor(mutableRequest, connectTimeoutInterval: connectTimeoutSeconds)
|
|
385
367
|
let request = mutableRequest as URLRequest
|
|
386
368
|
|
|
387
369
|
do {
|
|
388
370
|
let (data, response) = try await Self.urlSession.data(for: request)
|
|
389
371
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
390
372
|
let errorMsg = "Invalid HTTP response type"
|
|
391
|
-
|
|
373
|
+
SniConnectLog.error(errorMsg)
|
|
392
374
|
throw SniConnectError.invalidConfig(errorMsg)
|
|
393
375
|
}
|
|
394
376
|
|
|
@@ -397,11 +379,10 @@ final class SniConnectClient {
|
|
|
397
379
|
let (headers, multiValueHeaders) = Self.extractHeaders(from: httpResponse)
|
|
398
380
|
let statusText = HTTPURLResponse.localizedString(forStatusCode: status)
|
|
399
381
|
|
|
400
|
-
//
|
|
382
|
+
// 4xx/5xx are returned to JS as a normal response (the caller inspects
|
|
383
|
+
// `status`); we only record it for diagnostics.
|
|
401
384
|
if status >= 400 {
|
|
402
|
-
|
|
403
|
-
onLog?("error", "HTTP error: \(error.message)")
|
|
404
|
-
// Still return the response for client to handle
|
|
385
|
+
SniConnectLog.warn("HTTP \(status) for \(config.hostname)")
|
|
405
386
|
}
|
|
406
387
|
|
|
407
388
|
return Response(
|
|
@@ -412,33 +393,33 @@ final class SniConnectClient {
|
|
|
412
393
|
multiValueHeaders: multiValueHeaders
|
|
413
394
|
)
|
|
414
395
|
} catch let error as SniConnectError {
|
|
415
|
-
|
|
396
|
+
SniConnectLog.error("[\(error.code)] \(error.message)")
|
|
416
397
|
throw error
|
|
417
398
|
} catch {
|
|
418
399
|
// Convert generic errors to specific SniConnectError types
|
|
419
400
|
let sniError = SniConnectError.from(error)
|
|
420
|
-
|
|
401
|
+
SniConnectLog.error("[\(sniError.code)] \(sniError.message)")
|
|
421
402
|
throw sniError
|
|
422
403
|
}
|
|
423
404
|
}
|
|
424
405
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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...])
|
|
437
419
|
} else {
|
|
438
|
-
|
|
420
|
+
components.percentEncodedPath = normalizedPath
|
|
439
421
|
}
|
|
440
|
-
|
|
441
|
-
guard let url = URL(string: "https://\(hostname)\(normalizedPath)") else {
|
|
422
|
+
guard let url = components.url else {
|
|
442
423
|
throw SniConnectError.invalidURL
|
|
443
424
|
}
|
|
444
425
|
return url
|
|
@@ -446,7 +427,7 @@ final class SniConnectClient {
|
|
|
446
427
|
|
|
447
428
|
private static func parseResponseData(_ data: Data) -> Any {
|
|
448
429
|
guard !data.isEmpty else {
|
|
449
|
-
return
|
|
430
|
+
return ""
|
|
450
431
|
}
|
|
451
432
|
|
|
452
433
|
if let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []) {
|
|
@@ -483,13 +464,7 @@ final class SniConnectClient {
|
|
|
483
464
|
for (key, values) in headerGroups {
|
|
484
465
|
// For backward compatibility, single-value headers use the last value
|
|
485
466
|
singleValueHeaders[key] = values.last
|
|
486
|
-
|
|
487
|
-
// Multi-value headers contain all values
|
|
488
|
-
if values.count > 1 {
|
|
489
|
-
multiValueHeaders[key] = values
|
|
490
|
-
} else {
|
|
491
|
-
multiValueHeaders[key] = values
|
|
492
|
-
}
|
|
467
|
+
multiValueHeaders[key] = values
|
|
493
468
|
}
|
|
494
469
|
|
|
495
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;
|