@onekeyfe/react-native-sni-connect 3.0.71 → 3.0.72
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 -1
- package/SniConnect.podspec +2 -2
- package/android/build.gradle +1 -0
- package/android/src/main/java/com/sniconnect/SniConnectLogger.kt +32 -1
- package/android/src/main/java/com/sniconnect/SniConnectModule.kt +508 -68
- package/android/src/main/java/com/sniconnect/SniConnectValidation.kt +259 -13
- package/android/src/test/java/com/sniconnect/SniConnectValidationTest.kt +257 -0
- package/ios/SniConnect.mm +15 -0
- package/ios/SniConnect.swift +119 -22
- package/ios/SniConnectClient.swift +511 -174
- package/ios/SniConnectCore.swift +228 -0
- package/ios/SniConnectLog.swift +39 -0
- package/ios/SniConnectValidation.swift +243 -1
- package/ios/Tests/SniConnectValidationTests/SniConnectValidationTests.swift +325 -0
- package/lib/module/index.js +3 -0
- package/lib/typescript/src/NativeSniConnect.d.ts +25 -4
- package/lib/typescript/src/index.d.ts +2 -1
- package/package.json +9 -1
- package/src/NativeSniConnect.ts +35 -4
- package/src/index.tsx +12 -1
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
enum SniConnectCoreDiagnostics {
|
|
4
|
+
static var warnSink: ((String) -> Void)?
|
|
5
|
+
|
|
6
|
+
static func warn(_ message: String) {
|
|
7
|
+
warnSink?(message)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
static func event(_ name: String, _ fields: [(String, Any?)]) -> String {
|
|
11
|
+
let normalizedFields: [(String, Any?)] = [("event", name)] + fields
|
|
12
|
+
return normalizedFields
|
|
13
|
+
.map { key, value in "\(key)=\(sanitize(value))" }
|
|
14
|
+
.joined(separator: " ")
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
static func shortHash(_ value: String?) -> String {
|
|
18
|
+
guard let value, !value.isEmpty else {
|
|
19
|
+
return "none"
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
var hash: UInt64 = 1_469_598_103_934_665_603
|
|
23
|
+
for byte in value.utf8 {
|
|
24
|
+
hash ^= UInt64(byte)
|
|
25
|
+
hash &*= 1_099_511_628_211
|
|
26
|
+
}
|
|
27
|
+
return String(String(format: "%016llx", hash).prefix(12))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private static func sanitize(_ value: Any?) -> String {
|
|
31
|
+
guard let value else {
|
|
32
|
+
return "none"
|
|
33
|
+
}
|
|
34
|
+
return String(describing: value)
|
|
35
|
+
.replacingOccurrences(of: "\n", with: "_")
|
|
36
|
+
.replacingOccurrences(of: "\r", with: "_")
|
|
37
|
+
.replacingOccurrences(of: " ", with: "_")
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
struct SniConnectResolverConfig: Equatable {
|
|
42
|
+
let hostname: String
|
|
43
|
+
let ip: String
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
enum SniConnectCoreError: Error {
|
|
47
|
+
case resourceLimit(String)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
final class SniConnectPinnedResolverRegistry {
|
|
51
|
+
static let defaultMaxEntries = 32
|
|
52
|
+
|
|
53
|
+
private let maxEntries: Int
|
|
54
|
+
private var classesByKey: [String: AnyClass] = [:]
|
|
55
|
+
private var configsByClassName: [String: SniConnectResolverConfig] = [:]
|
|
56
|
+
private var referenceCountsByKey: [String: Int] = [:]
|
|
57
|
+
private var reusableClasses: [AnyClass] = []
|
|
58
|
+
private var allocatedClassNames: Set<String> = []
|
|
59
|
+
|
|
60
|
+
init(maxEntries: Int = SniConnectPinnedResolverRegistry.defaultMaxEntries) {
|
|
61
|
+
self.maxEntries = maxEntries
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
var entryCount: Int {
|
|
65
|
+
classesByKey.count
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
var allocatedClassCount: Int {
|
|
69
|
+
allocatedClassNames.count
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
func resolverClass(
|
|
73
|
+
hostname: String,
|
|
74
|
+
ip: String,
|
|
75
|
+
allocateClass: () -> AnyClass
|
|
76
|
+
) throws -> AnyClass {
|
|
77
|
+
let normalizedHost = hostname.lowercased()
|
|
78
|
+
let key = Self.key(hostname: normalizedHost, ip: ip)
|
|
79
|
+
if let resolverClass = classesByKey[key] {
|
|
80
|
+
referenceCountsByKey[key, default: 0] += 1
|
|
81
|
+
return resolverClass
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
guard classesByKey.count < maxEntries else {
|
|
85
|
+
throw SniConnectCoreError.resourceLimit("Too many cached SNI resolver entries")
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let resolverClass: AnyClass = reusableClasses.popLast() ?? allocateClass()
|
|
89
|
+
let className = NSStringFromClass(resolverClass)
|
|
90
|
+
allocatedClassNames.insert(className)
|
|
91
|
+
classesByKey[key] = resolverClass
|
|
92
|
+
configsByClassName[className] = SniConnectResolverConfig(hostname: normalizedHost, ip: ip)
|
|
93
|
+
referenceCountsByKey[key] = 1
|
|
94
|
+
return resolverClass
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
func resolve(domain: String, resolverClass: AnyClass) -> String? {
|
|
98
|
+
guard let config = configsByClassName[NSStringFromClass(resolverClass)] else {
|
|
99
|
+
return nil
|
|
100
|
+
}
|
|
101
|
+
guard domain.caseInsensitiveCompare(config.hostname) == .orderedSame else {
|
|
102
|
+
SniConnectCoreDiagnostics.warn(SniConnectCoreDiagnostics.event("sni_pinned_dns_unexpected_host", [
|
|
103
|
+
("expectedHost", config.hostname),
|
|
104
|
+
("requestedHostHash", SniConnectCoreDiagnostics.shortHash(domain.lowercased())),
|
|
105
|
+
("result", "fail_closed"),
|
|
106
|
+
]))
|
|
107
|
+
return nil
|
|
108
|
+
}
|
|
109
|
+
return config.ip
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
func release(hostname: String, ip: String) {
|
|
113
|
+
let key = Self.key(hostname: hostname.lowercased(), ip: ip)
|
|
114
|
+
guard let referenceCount = referenceCountsByKey[key] else {
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
if referenceCount > 1 {
|
|
118
|
+
referenceCountsByKey[key] = referenceCount - 1
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
referenceCountsByKey.removeValue(forKey: key)
|
|
123
|
+
guard let resolverClass = classesByKey.removeValue(forKey: key) else {
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
configsByClassName.removeValue(forKey: NSStringFromClass(resolverClass))
|
|
127
|
+
reusableClasses.append(resolverClass)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
func clear() {
|
|
131
|
+
reusableClasses.append(contentsOf: classesByKey.values)
|
|
132
|
+
classesByKey.removeAll()
|
|
133
|
+
configsByClassName.removeAll()
|
|
134
|
+
referenceCountsByKey.removeAll()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private static func key(hostname: String, ip: String) -> String {
|
|
138
|
+
return "\(hostname)|\(ip)"
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
enum SniConnectResponseText {
|
|
143
|
+
static func decode(_ data: Data) -> String {
|
|
144
|
+
guard !data.isEmpty else {
|
|
145
|
+
return ""
|
|
146
|
+
}
|
|
147
|
+
if let text = String(data: data, encoding: .utf8) {
|
|
148
|
+
return text
|
|
149
|
+
}
|
|
150
|
+
return String(decoding: data, as: UTF8.self)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
enum SniConnectTimeout: Error, Equatable {
|
|
155
|
+
case deadlineExceeded
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
enum SniConnectWallClockDeadline {
|
|
159
|
+
static func run<T>(
|
|
160
|
+
timeoutMilliseconds: TimeInterval,
|
|
161
|
+
operation: @escaping @Sendable () async throws -> T
|
|
162
|
+
) async throws -> T {
|
|
163
|
+
let timeoutNanoseconds = UInt64(max(1.0, timeoutMilliseconds) * 1_000_000.0)
|
|
164
|
+
return try await withThrowingTaskGroup(of: T.self) { group in
|
|
165
|
+
group.addTask {
|
|
166
|
+
try await operation()
|
|
167
|
+
}
|
|
168
|
+
group.addTask {
|
|
169
|
+
try await Task.sleep(nanoseconds: timeoutNanoseconds)
|
|
170
|
+
throw SniConnectTimeout.deadlineExceeded
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
do {
|
|
174
|
+
guard let result = try await group.next() else {
|
|
175
|
+
throw SniConnectTimeout.deadlineExceeded
|
|
176
|
+
}
|
|
177
|
+
group.cancelAll()
|
|
178
|
+
return result
|
|
179
|
+
} catch {
|
|
180
|
+
group.cancelAll()
|
|
181
|
+
throw error
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
struct SniConnectHeaderMaps: Equatable {
|
|
188
|
+
let singleValueHeaders: [String: String]
|
|
189
|
+
let multiValueHeaders: [String: [String]]
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
enum SniConnectResponseHeaders {
|
|
193
|
+
static func make(rawHeaderFields: [(name: String, value: String)]) -> SniConnectHeaderMaps {
|
|
194
|
+
var singleValueHeaders: [String: String] = [:]
|
|
195
|
+
var multiValueHeaders: [String: [String]] = [:]
|
|
196
|
+
|
|
197
|
+
for header in rawHeaderFields {
|
|
198
|
+
let name = header.name.lowercased()
|
|
199
|
+
guard !name.isEmpty else { continue }
|
|
200
|
+
|
|
201
|
+
singleValueHeaders[name] = header.value
|
|
202
|
+
multiValueHeaders[name, default: []].append(header.value)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return SniConnectHeaderMaps(
|
|
206
|
+
singleValueHeaders: singleValueHeaders,
|
|
207
|
+
multiValueHeaders: multiValueHeaders
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
static func make(from headerFields: [AnyHashable: Any]) -> SniConnectHeaderMaps {
|
|
212
|
+
let rawHeaderFields = headerFields.flatMap { key, value -> [(name: String, value: String)] in
|
|
213
|
+
let name = String(describing: key)
|
|
214
|
+
return values(from: value).map { (name: name, value: $0) }
|
|
215
|
+
}
|
|
216
|
+
return make(rawHeaderFields: rawHeaderFields)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private static func values(from value: Any) -> [String] {
|
|
220
|
+
if let values = value as? [String] {
|
|
221
|
+
return values
|
|
222
|
+
}
|
|
223
|
+
if let values = value as? NSArray {
|
|
224
|
+
return values.map { String(describing: $0) }
|
|
225
|
+
}
|
|
226
|
+
return [String(describing: value)]
|
|
227
|
+
}
|
|
228
|
+
}
|
package/ios/SniConnectLog.swift
CHANGED
|
@@ -20,10 +20,49 @@ enum SniConnectLog {
|
|
|
20
20
|
static func warn(_ message: String) { dispatch("warn::", message) }
|
|
21
21
|
static func error(_ message: String) { dispatch("error::", message) }
|
|
22
22
|
|
|
23
|
+
static func event(_ name: String, _ fields: [(String, Any?)]) -> String {
|
|
24
|
+
let normalizedFields: [(String, Any?)] = [("event", name)] + fields
|
|
25
|
+
let pairs = normalizedFields.map { key, value in
|
|
26
|
+
"\(key)=\(sanitize(value))"
|
|
27
|
+
}
|
|
28
|
+
return pairs.joined(separator: " ")
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
static func shortHash(_ value: String?) -> String {
|
|
32
|
+
guard let value, !value.isEmpty else {
|
|
33
|
+
return "none"
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
var hash: UInt64 = 1_469_598_103_934_665_603
|
|
37
|
+
for byte in value.utf8 {
|
|
38
|
+
hash ^= UInt64(byte)
|
|
39
|
+
hash &*= 1_099_511_628_211
|
|
40
|
+
}
|
|
41
|
+
return String(String(format: "%016llx", hash).prefix(12))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
static func elapsedMs(since startedAt: Date) -> Int {
|
|
45
|
+
max(0, Int(Date().timeIntervalSince(startedAt) * 1000))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
static func ipFamily(_ ip: String) -> String {
|
|
49
|
+
ip.contains(":") ? "ipv6" : "ipv4"
|
|
50
|
+
}
|
|
51
|
+
|
|
23
52
|
private static func dispatch(_ selectorName: String, _ message: String) {
|
|
24
53
|
guard let cls = logClass else { return }
|
|
25
54
|
let sel = NSSelectorFromString(selectorName)
|
|
26
55
|
guard cls.responds(to: sel) else { return }
|
|
27
56
|
_ = cls.perform(sel, with: tag, with: message)
|
|
28
57
|
}
|
|
58
|
+
|
|
59
|
+
private static func sanitize(_ value: Any?) -> String {
|
|
60
|
+
guard let value else {
|
|
61
|
+
return "none"
|
|
62
|
+
}
|
|
63
|
+
return String(describing: value)
|
|
64
|
+
.replacingOccurrences(of: "\n", with: "_")
|
|
65
|
+
.replacingOccurrences(of: "\r", with: "_")
|
|
66
|
+
.replacingOccurrences(of: " ", with: "_")
|
|
67
|
+
}
|
|
29
68
|
}
|
|
@@ -15,15 +15,83 @@ enum SniConnectValidation {
|
|
|
15
15
|
case invalidMethod(String)
|
|
16
16
|
case invalidPath(String)
|
|
17
17
|
case invalidHeader(String)
|
|
18
|
+
case invalidRequestId(String)
|
|
19
|
+
case invalidTimeout(Double)
|
|
20
|
+
case invalidBody
|
|
21
|
+
case resourceLimit(String)
|
|
18
22
|
}
|
|
19
23
|
|
|
24
|
+
static let maxRequestIdBytes = 128
|
|
25
|
+
static let maxTimeoutMillis = 120_000.0
|
|
26
|
+
static let maxPathBytes = 8 * 1024
|
|
27
|
+
static let maxRequestBodyBytes = 1024 * 1024
|
|
28
|
+
static let maxResponseBodyBytes = 10 * 1024 * 1024
|
|
29
|
+
static let maxHeaderCount = 64
|
|
30
|
+
static let maxHeaderNameBytes = 128
|
|
31
|
+
static let maxHeaderValueBytes = 8 * 1024
|
|
32
|
+
static let maxTotalHeaderBytes = 32 * 1024
|
|
33
|
+
static let maxActiveRequests = 64
|
|
34
|
+
static let maxActiveRequestsPerPair = 16
|
|
35
|
+
|
|
20
36
|
/// HTTP methods the module is allowed to issue.
|
|
21
37
|
private static let allowedMethods: Set<String> = [
|
|
22
38
|
"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS",
|
|
23
39
|
]
|
|
24
40
|
|
|
41
|
+
private static let moduleOwnedHeaders: Set<String> = [
|
|
42
|
+
"host",
|
|
43
|
+
"content-length",
|
|
44
|
+
"accept-encoding",
|
|
45
|
+
"x-emascurl-config-id",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
private static let unsafeHeaders: Set<String> = [
|
|
49
|
+
"connection",
|
|
50
|
+
"keep-alive",
|
|
51
|
+
"te",
|
|
52
|
+
"trailer",
|
|
53
|
+
"transfer-encoding",
|
|
54
|
+
"upgrade",
|
|
55
|
+
"expect",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
private static let headerTokenPattern = "^[!#$%&'*+.^_`|~0-9A-Za-z-]+$"
|
|
59
|
+
|
|
60
|
+
static func validateRequestId(_ requestId: String?) throws {
|
|
61
|
+
guard let requestId = requestId else { return }
|
|
62
|
+
if requestId.isEmpty ||
|
|
63
|
+
containsControlCharacters(requestId) ||
|
|
64
|
+
byteCount(requestId) > maxRequestIdBytes {
|
|
65
|
+
throw ValidationError.invalidRequestId(requestId)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
static func validateTimeout(_ timeout: Double) throws {
|
|
70
|
+
if !timeout.isFinite || timeout < 1 || timeout > maxTimeoutMillis {
|
|
71
|
+
throw ValidationError.invalidTimeout(timeout)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
static func validateBody(_ body: String?) throws {
|
|
76
|
+
if let body = body, byteCount(body) > maxRequestBodyBytes {
|
|
77
|
+
throw ValidationError.invalidBody
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
static func validateMethodBody(method: String, body: String?) throws {
|
|
82
|
+
if (method == "GET" || method == "HEAD") && body != nil {
|
|
83
|
+
throw ValidationError.invalidBody
|
|
84
|
+
}
|
|
85
|
+
if (method == "POST" || method == "PUT" || method == "PATCH") && body == nil {
|
|
86
|
+
throw ValidationError.invalidBody
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
25
90
|
/// Validate and uppercase the HTTP method.
|
|
26
91
|
static func normalizeMethod(_ method: String) throws -> String {
|
|
92
|
+
if containsControlCharacters(method) {
|
|
93
|
+
throw ValidationError.invalidMethod(method)
|
|
94
|
+
}
|
|
27
95
|
let upper = method.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
|
28
96
|
guard allowedMethods.contains(upper) else {
|
|
29
97
|
throw ValidationError.invalidMethod(method)
|
|
@@ -41,6 +109,9 @@ enum SniConnectValidation {
|
|
|
41
109
|
guard hostname.range(of: pattern, options: .regularExpression) != nil else {
|
|
42
110
|
throw ValidationError.invalidHostname(hostname)
|
|
43
111
|
}
|
|
112
|
+
if parseIPv4(hostname) != nil || parseIPv6(hostname) != nil {
|
|
113
|
+
throw ValidationError.invalidHostname(hostname)
|
|
114
|
+
}
|
|
44
115
|
}
|
|
45
116
|
|
|
46
117
|
/// Validate `path`: must be a relative path/query only. Reject absolute URLs
|
|
@@ -51,6 +122,9 @@ enum SniConnectValidation {
|
|
|
51
122
|
if containsControlCharacters(trimmed) {
|
|
52
123
|
throw ValidationError.invalidPath(path)
|
|
53
124
|
}
|
|
125
|
+
if byteCount(trimmed) > maxPathBytes {
|
|
126
|
+
throw ValidationError.invalidPath(path)
|
|
127
|
+
}
|
|
54
128
|
// Reject anything that looks like it carries a scheme or authority.
|
|
55
129
|
if trimmed.contains("://") || trimmed.hasPrefix("//") {
|
|
56
130
|
throw ValidationError.invalidPath(path)
|
|
@@ -67,23 +141,66 @@ enum SniConnectValidation {
|
|
|
67
141
|
/// Validate header names/values: reject CR/LF/control characters (header
|
|
68
142
|
/// injection) and the Host header (the module sets Host itself).
|
|
69
143
|
static func validateHeaders(_ headers: [String: String]) throws {
|
|
144
|
+
_ = try normalizeHeaders(headers)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
static func normalizeHeaders(_ headers: [String: String]) throws -> [String: String] {
|
|
148
|
+
if headers.count > maxHeaderCount {
|
|
149
|
+
throw ValidationError.invalidHeader("too many headers")
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
var totalBytes = 0
|
|
153
|
+
var normalizedHeaders: [String: String] = [:]
|
|
70
154
|
for (key, value) in headers {
|
|
71
|
-
|
|
155
|
+
let keyBytes = byteCount(key)
|
|
156
|
+
let valueBytes = byteCount(value)
|
|
157
|
+
totalBytes += keyBytes + valueBytes
|
|
158
|
+
|
|
159
|
+
if key.isEmpty ||
|
|
160
|
+
containsControlCharacters(key) ||
|
|
161
|
+
containsControlCharacters(value) ||
|
|
162
|
+
keyBytes > maxHeaderNameBytes ||
|
|
163
|
+
valueBytes > maxHeaderValueBytes ||
|
|
164
|
+
key.range(of: headerTokenPattern, options: .regularExpression) == nil {
|
|
72
165
|
throw ValidationError.invalidHeader(key)
|
|
73
166
|
}
|
|
167
|
+
|
|
168
|
+
let lowerKey = key.lowercased()
|
|
169
|
+
if lowerKey.hasPrefix(":") || lowerKey.hasPrefix("proxy-") || unsafeHeaders.contains(lowerKey) {
|
|
170
|
+
throw ValidationError.invalidHeader(key)
|
|
171
|
+
}
|
|
172
|
+
if moduleOwnedHeaders.contains(lowerKey) {
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
175
|
+
normalizedHeaders[key] = value
|
|
74
176
|
}
|
|
177
|
+
if totalBytes > maxTotalHeaderBytes {
|
|
178
|
+
throw ValidationError.invalidHeader("headers too large")
|
|
179
|
+
}
|
|
180
|
+
return normalizedHeaders
|
|
75
181
|
}
|
|
76
182
|
|
|
77
183
|
static func containsControlCharacters(_ s: String) -> Bool {
|
|
78
184
|
return s.unicodeScalars.contains { $0.value < 0x20 || $0.value == 0x7F }
|
|
79
185
|
}
|
|
80
186
|
|
|
187
|
+
private static func byteCount(_ s: String) -> Int {
|
|
188
|
+
return s.lengthOfBytes(using: .utf8)
|
|
189
|
+
}
|
|
190
|
+
|
|
81
191
|
// MARK: - IP validation
|
|
82
192
|
|
|
83
193
|
/// Validate `ip` is a literal IPv4/IPv6 address (never a hostname) and routes
|
|
84
194
|
/// to a public/global-unicast destination. Rejects loopback, private,
|
|
85
195
|
/// link-local (incl. 169.254.169.254 metadata), CGNAT, multicast and reserved.
|
|
86
196
|
static func validatePublicIP(_ ip: String) throws {
|
|
197
|
+
if ip.isEmpty ||
|
|
198
|
+
ip.trimmingCharacters(in: .whitespacesAndNewlines) != ip ||
|
|
199
|
+
ip.contains("[") ||
|
|
200
|
+
ip.contains("]") ||
|
|
201
|
+
ip.contains("%") {
|
|
202
|
+
throw ValidationError.invalidIP(ip)
|
|
203
|
+
}
|
|
87
204
|
if let v4 = parseIPv4(ip) {
|
|
88
205
|
if isForbiddenIPv4(v4) { throw ValidationError.forbiddenIP(ip) }
|
|
89
206
|
return
|
|
@@ -142,10 +259,135 @@ enum SniConnectValidation {
|
|
|
142
259
|
if b[0] == 0xFE && (b[1] & 0xC0) == 0x80 { return true }
|
|
143
260
|
// Unique local fc00::/7
|
|
144
261
|
if (b[0] & 0xFE) == 0xFC { return true }
|
|
262
|
+
// Discard-only 100::/64
|
|
263
|
+
if b[0] == 0x01 && b[1] == 0x00 && b[2...7].allSatisfy({ $0 == 0 }) { return true }
|
|
264
|
+
// IETF protocol assignments that should not be accepted as public endpoints.
|
|
265
|
+
if b[0] == 0x20 && b[1] == 0x01 {
|
|
266
|
+
if b[2] == 0x00 && b[3] == 0x00 { return true } // 2001::/32 Teredo
|
|
267
|
+
if b[2] == 0x00 && (b[3] & 0xF0) == 0x10 { return true } // 2001:10::/28 ORCHID
|
|
268
|
+
if b[2] == 0x00 && b[3] == 0x02 { return true } // 2001:2::/48 benchmarking
|
|
269
|
+
if b[2] == 0x0D && b[3] == 0xB8 { return true } // 2001:db8::/32 docs
|
|
270
|
+
}
|
|
271
|
+
// 6to4 embeds an IPv4 route target and is deprecated.
|
|
272
|
+
if b[0] == 0x20 && b[1] == 0x02 { return true }
|
|
273
|
+
// NAT64 well-known prefix. Allow only when the embedded IPv4 is public.
|
|
274
|
+
if isNat64WellKnown(b) { return isForbiddenIPv4([b[12], b[13], b[14], b[15]]) }
|
|
275
|
+
// NAT64 local-use prefix can route through operator-specific private policy.
|
|
276
|
+
if isNat64LocalUse(b) { return true }
|
|
277
|
+
// Deprecated IPv4-compatible IPv6 addresses.
|
|
278
|
+
if b[0...11].allSatisfy({ $0 == 0 }) { return true }
|
|
145
279
|
// IPv4-mapped ::ffff:0:0/96 — validate the embedded IPv4
|
|
146
280
|
if b[0...9].allSatisfy({ $0 == 0 }) && b[10] == 0xFF && b[11] == 0xFF {
|
|
147
281
|
return isForbiddenIPv4([b[12], b[13], b[14], b[15]])
|
|
148
282
|
}
|
|
149
283
|
return false
|
|
150
284
|
}
|
|
285
|
+
|
|
286
|
+
private static func isNat64WellKnown(_ b: [UInt8]) -> Bool {
|
|
287
|
+
return b[0] == 0x00 &&
|
|
288
|
+
b[1] == 0x64 &&
|
|
289
|
+
b[2] == 0xFF &&
|
|
290
|
+
b[3] == 0x9B &&
|
|
291
|
+
b[4...11].allSatisfy({ $0 == 0 })
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private static func isNat64LocalUse(_ b: [UInt8]) -> Bool {
|
|
295
|
+
return b[0] == 0x00 &&
|
|
296
|
+
b[1] == 0x64 &&
|
|
297
|
+
b[2] == 0xFF &&
|
|
298
|
+
b[3] == 0x9B &&
|
|
299
|
+
b[4] == 0x00 &&
|
|
300
|
+
b[5] == 0x01
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
final class SniConnectRequestLimiter {
|
|
305
|
+
final class Token {
|
|
306
|
+
private weak var limiter: SniConnectRequestLimiter?
|
|
307
|
+
private let key: String
|
|
308
|
+
private let lock = NSLock()
|
|
309
|
+
private var released = false
|
|
310
|
+
|
|
311
|
+
fileprivate init(limiter: SniConnectRequestLimiter, key: String) {
|
|
312
|
+
self.limiter = limiter
|
|
313
|
+
self.key = key
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
func release() {
|
|
317
|
+
lock.lock()
|
|
318
|
+
if released {
|
|
319
|
+
lock.unlock()
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
released = true
|
|
323
|
+
lock.unlock()
|
|
324
|
+
limiter?.release(key: key)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
deinit {
|
|
328
|
+
release()
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private let maxActiveRequests: Int
|
|
333
|
+
private let maxActiveRequestsPerPair: Int
|
|
334
|
+
private let queue = DispatchQueue(label: "com.onekey.sni.connect.request-limiter")
|
|
335
|
+
private var activeRequests = 0
|
|
336
|
+
private var activeRequestsByPair: [String: Int] = [:]
|
|
337
|
+
|
|
338
|
+
init(
|
|
339
|
+
maxActiveRequests: Int = SniConnectValidation.maxActiveRequests,
|
|
340
|
+
maxActiveRequestsPerPair: Int = SniConnectValidation.maxActiveRequestsPerPair
|
|
341
|
+
) {
|
|
342
|
+
self.maxActiveRequests = maxActiveRequests
|
|
343
|
+
self.maxActiveRequestsPerPair = maxActiveRequestsPerPair
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
func acquire(hostname: String, ip: String) throws -> Token {
|
|
347
|
+
let key = pairKey(hostname: hostname, ip: ip)
|
|
348
|
+
return try queue.sync {
|
|
349
|
+
if activeRequests >= maxActiveRequests {
|
|
350
|
+
SniConnectCoreDiagnostics.warn(SniConnectCoreDiagnostics.event("sni_resource_limit", [
|
|
351
|
+
("activeCount", activeRequests),
|
|
352
|
+
("pairCount", activeRequestsByPair[key] ?? 0),
|
|
353
|
+
("limit", maxActiveRequests),
|
|
354
|
+
("reason", "max_active_requests"),
|
|
355
|
+
("hostname", hostname.lowercased()),
|
|
356
|
+
("ipHash", SniConnectCoreDiagnostics.shortHash(ip)),
|
|
357
|
+
]))
|
|
358
|
+
throw SniConnectValidation.ValidationError.resourceLimit("Too many active SNI requests")
|
|
359
|
+
}
|
|
360
|
+
let pairCount = activeRequestsByPair[key] ?? 0
|
|
361
|
+
if pairCount >= maxActiveRequestsPerPair {
|
|
362
|
+
SniConnectCoreDiagnostics.warn(SniConnectCoreDiagnostics.event("sni_resource_limit", [
|
|
363
|
+
("activeCount", activeRequests),
|
|
364
|
+
("pairCount", pairCount),
|
|
365
|
+
("limit", maxActiveRequestsPerPair),
|
|
366
|
+
("reason", "max_active_requests_per_pair"),
|
|
367
|
+
("hostname", hostname.lowercased()),
|
|
368
|
+
("ipHash", SniConnectCoreDiagnostics.shortHash(ip)),
|
|
369
|
+
]))
|
|
370
|
+
throw SniConnectValidation.ValidationError.resourceLimit("Too many active SNI requests for destination")
|
|
371
|
+
}
|
|
372
|
+
activeRequests += 1
|
|
373
|
+
activeRequestsByPair[key] = pairCount + 1
|
|
374
|
+
return Token(limiter: self, key: key)
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private func release(key: String) {
|
|
379
|
+
queue.sync {
|
|
380
|
+
activeRequests = max(0, activeRequests - 1)
|
|
381
|
+
guard let pairCount = activeRequestsByPair[key] else { return }
|
|
382
|
+
if pairCount <= 1 {
|
|
383
|
+
activeRequestsByPair.removeValue(forKey: key)
|
|
384
|
+
} else {
|
|
385
|
+
activeRequestsByPair[key] = pairCount - 1
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private func pairKey(hostname: String, ip: String) -> String {
|
|
391
|
+
return "\(hostname.lowercased())|\(ip)"
|
|
392
|
+
}
|
|
151
393
|
}
|