@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 CHANGED
@@ -1,37 +1,62 @@
1
- # react-native-sni-connect
1
+ # @onekeyfe/react-native-sni-connect
2
2
 
3
- onekey sni http client
3
+ OneKey SNI HTTP client for React Native. Performs HTTPS requests to a caller-supplied
4
+ IP address while preserving the original TLS SNI / `Host` of a hostname, so certificate
5
+ chain and hostname verification are still enforced against the real hostname (not the IP).
6
+
7
+ Backed by EMASCurl (libcurl) on iOS and OkHttp on Android.
4
8
 
5
9
  ## Installation
6
10
 
11
+ This package is published as part of the OneKey `app-modules` workspace:
7
12
 
8
13
  ```sh
9
- npm install react-native-sni-connect
14
+ yarn add @onekeyfe/react-native-sni-connect
10
15
  ```
11
16
 
17
+ iOS: run `pod install`. Android autolinks.
12
18
 
13
19
  ## Usage
14
20
 
15
-
16
- ```js
17
- import { multiply } from 'react-native-sni-connect';
18
-
19
- // ...
20
-
21
- const result = multiply(3, 7);
21
+ ```ts
22
+ import {
23
+ request,
24
+ cancelRequest,
25
+ cancelAllRequests,
26
+ clearDNSCache,
27
+ } from '@onekeyfe/react-native-sni-connect';
28
+
29
+ const res = await request({
30
+ // requestId is optional; required only if you want to cancel the request.
31
+ requestId: 'health-check-1',
32
+ ip: '93.184.216.34', // must be a public IP literal (private/loopback/metadata are rejected)
33
+ hostname: 'example.com', // used for SNI, Host header and certificate validation
34
+ method: 'GET',
35
+ path: '/api/v1/ping', // relative path only — absolute URLs are rejected
36
+ headers: { 'Content-Type': 'application/json' },
37
+ timeout: 30_000,
38
+ });
39
+
40
+ console.log(res.status, res.headers, res.data);
41
+
42
+ // Cancellation (requires requestId on the request)
43
+ await cancelRequest('health-check-1');
44
+ await cancelAllRequests();
45
+
46
+ // Drop pinned-IP connections / cached clients
47
+ await clearDNSCache();
22
48
  ```
23
49
 
50
+ ### Security notes
24
51
 
25
- ## Contributing
26
-
27
- - [Development workflow](CONTRIBUTING.md#development-workflow)
28
- - [Sending a pull request](CONTRIBUTING.md#sending-a-pull-request)
29
- - [Code of conduct](CODE_OF_CONDUCT.md)
52
+ - The request scheme is always `https` on port `443`; `path` cannot override scheme, host or port.
53
+ - `ip` must be an IPv4/IPv6 literal that routes to a public destination; loopback, private,
54
+ link-local (incl. cloud metadata), CGNAT, multicast and reserved ranges are rejected.
55
+ - Header names/values containing CR/LF/control characters are rejected; the `Host` header is
56
+ managed by the module.
57
+ - Native logs go through OneKey's native logger (with sensitive-data redaction); there is no
58
+ JS log event channel.
30
59
 
31
60
  ## License
32
61
 
33
62
  MIT
34
-
35
- ---
36
-
37
- Made with [create-react-native-library](https://github.com/callstack/react-native-builder-bob)
@@ -11,7 +11,7 @@ Pod::Spec.new do |s|
11
11
  s.authors = package["author"]
12
12
 
13
13
  s.platforms = { :ios => min_ios_version_supported }
14
- s.source = { :git => "https://github.com/OneKeyHQ/react-native-sni-connect.git", :tag => "#{s.version}" }
14
+ s.source = { :git => "https://github.com/OneKeyHQ/app-modules/react-native-sni-connect.git", :tag => "#{s.version}" }
15
15
 
16
16
  s.static_framework = true
17
17
 
@@ -24,7 +24,7 @@ Pod::Spec.new do |s|
24
24
  s.dependency 'React-jsi'
25
25
  s.dependency 'React-callinvoker'
26
26
  s.dependency 'React-Codegen'
27
- s.dependency 'EMASCurl', '1.4.1'
27
+ s.dependency 'EMASCurl', '1.5.5'
28
28
 
29
29
  s.pod_target_xcconfig = {
30
30
  "HEADER_SEARCH_PATHS" => "\"$(PODS_ROOT)/Headers/Private/React-Core\"",
@@ -34,27 +34,6 @@ android {
34
34
  targetSdkVersion getExtOrIntegerDefault("targetSdkVersion")
35
35
  }
36
36
 
37
- packagingOptions {
38
- excludes = [
39
- "META-INF",
40
- "META-INF/**",
41
- "**/libc++_shared.so",
42
- "**/libfbjni.so",
43
- "**/libjsi.so",
44
- "**/libfolly_json.so",
45
- "**/libfolly_runtime.so",
46
- "**/libglog.so",
47
- "**/libhermes.so",
48
- "**/libhermes-executor-debug.so",
49
- "**/libhermes_executor.so",
50
- "**/libreactnative.so",
51
- "**/libreactnativejni.so",
52
- "**/libturbomodulejsijni.so",
53
- "**/libreact_nativemodule_core.so",
54
- "**/libjscexecutor.so"
55
- ]
56
- }
57
-
58
37
  buildFeatures {
59
38
  buildConfig true
60
39
  }
@@ -0,0 +1,55 @@
1
+ package com.sniconnect
2
+
3
+ /**
4
+ * Lightweight logging wrapper that dynamically dispatches to OneKeyLog.
5
+ * Uses reflection to avoid a hard dependency on the (nitro-based) native-logger
6
+ * module. Falls back to android.util.Log when OneKeyLog is not available.
7
+ *
8
+ * Mirrors iOS SniConnectLog and the existing BTLogger / SBLLogger.
9
+ */
10
+ internal object SniConnectLogger {
11
+ private const val TAG = "SniConnect"
12
+
13
+ private val logClass: Class<*>? by lazy {
14
+ try {
15
+ Class.forName("com.margelo.nitro.nativelogger.OneKeyLog")
16
+ } catch (_: ClassNotFoundException) {
17
+ null
18
+ }
19
+ }
20
+
21
+ private val methods by lazy {
22
+ val cls = logClass ?: return@lazy null
23
+ mapOf(
24
+ "debug" to cls.getMethod("debug", String::class.java, String::class.java),
25
+ "info" to cls.getMethod("info", String::class.java, String::class.java),
26
+ "warn" to cls.getMethod("warn", String::class.java, String::class.java),
27
+ "error" to cls.getMethod("error", String::class.java, String::class.java),
28
+ )
29
+ }
30
+
31
+ @JvmStatic
32
+ fun debug(message: String) = log("debug", message, android.util.Log.DEBUG)
33
+
34
+ @JvmStatic
35
+ fun info(message: String) = log("info", message, android.util.Log.INFO)
36
+
37
+ @JvmStatic
38
+ fun warn(message: String) = log("warn", message, android.util.Log.WARN)
39
+
40
+ @JvmStatic
41
+ fun error(message: String) = log("error", message, android.util.Log.ERROR)
42
+
43
+ private fun log(level: String, message: String, androidLogLevel: Int) {
44
+ val method = methods?.get(level)
45
+ if (method != null) {
46
+ try {
47
+ method.invoke(null, TAG, message)
48
+ return
49
+ } catch (_: Exception) {
50
+ // Fall through to android.util.Log
51
+ }
52
+ }
53
+ android.util.Log.println(androidLogLevel, TAG, message)
54
+ }
55
+ }
@@ -1,16 +1,16 @@
1
1
  package com.sniconnect
2
2
 
3
- import android.util.Log
4
3
  import com.facebook.react.bridge.Arguments
5
4
  import com.facebook.react.bridge.Promise
6
5
  import com.facebook.react.bridge.ReactApplicationContext
7
6
  import com.facebook.react.bridge.ReactMethod
8
7
  import com.facebook.react.bridge.ReadableMap
9
8
  import com.facebook.react.bridge.WritableMap
10
- import com.facebook.react.modules.core.DeviceEventManagerModule
11
9
  import com.facebook.react.module.annotations.ReactModule
12
10
  import okhttp3.Call
13
11
  import okhttp3.Callback
12
+ import okhttp3.ConnectionPool
13
+ import okhttp3.Dispatcher
14
14
  import okhttp3.Dns
15
15
  import okhttp3.Headers
16
16
  import okhttp3.MediaType.Companion.toMediaTypeOrNull
@@ -21,10 +21,10 @@ import okhttp3.Response
21
21
  import okhttp3.ResponseBody
22
22
  import java.io.IOException
23
23
  import java.net.InetAddress
24
- import java.net.UnknownHostException
25
24
  import java.util.Locale
26
25
  import java.util.concurrent.ConcurrentHashMap
27
26
  import java.util.concurrent.TimeUnit
27
+ import java.util.concurrent.atomic.AtomicBoolean
28
28
  import javax.net.ssl.HttpsURLConnection
29
29
 
30
30
  private const val TAG = "SniConnect"
@@ -35,65 +35,49 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
35
35
 
36
36
  companion object {
37
37
  const val NAME = "SniConnect"
38
- private const val EVENT_NAME = "SniConnectLog"
38
+
39
+ // Upper bound on cached OkHttpClient instances to prevent unbounded growth
40
+ // from JS-controlled host/IP pairs (e.g. speed-testing many endpoints).
41
+ private const val MAX_CLIENTS = 32
42
+
43
+ // A single dispatcher + connection pool shared across all cached clients so we
44
+ // don't spawn a thread pool / connection pool per (hostname, ip) pair.
45
+ private val sharedDispatcher = Dispatcher()
46
+ private val sharedConnectionPool = ConnectionPool()
39
47
  }
40
48
 
41
49
  /**
42
- * Cache key for OkHttpClient instances
43
- * Uses hostname:IP combination to ensure:
44
- * - Different IPs for the same hostname are isolated (accurate speed testing)
45
- * - Same hostname+IP combination can reuse connections (performance optimization)
46
- * Note: timeout is NOT part of the key to allow connection reuse across different timeout values
50
+ * Cache key for OkHttpClient instances.
51
+ * Uses hostname:IP so different IPs for the same hostname stay isolated (accurate
52
+ * speed testing) while the same hostname+IP reuses connections. Timeout is NOT part
53
+ * of the key it is applied per-call via `call.timeout()`.
47
54
  */
48
55
  private data class ClientKey(
49
56
  val hostname: String,
50
57
  val ip: String,
51
58
  )
52
59
 
53
- private val clientCache = ConcurrentHashMap<ClientKey, OkHttpClient>()
54
- private val activeCalls = ConcurrentHashMap<String, Call>()
55
- private var listenerCount = 0
56
-
57
- override fun getName(): String = NAME
58
-
59
- // Event emitter methods required for NativeEventEmitter
60
- @ReactMethod
61
- override fun addListener(eventType: String) {
62
- listenerCount += 1
63
- }
64
-
65
- @ReactMethod
66
- override fun removeListeners(count: Double) {
67
- listenerCount -= count.toInt()
68
- if (listenerCount < 0) {
69
- listenerCount = 0
60
+ // Bounded LRU: access-ordered, evicts the eldest client when capacity is exceeded.
61
+ // Idle connections are reclaimed by the shared ConnectionPool's own keep-alive, so
62
+ // we do NOT evictAll here (that pool is shared by every client). Synchronized because
63
+ // LinkedHashMap is not thread-safe.
64
+ private val clientCache = object : LinkedHashMap<ClientKey, OkHttpClient>(16, 0.75f, true) {
65
+ override fun removeEldestEntry(eldest: MutableMap.MutableEntry<ClientKey, OkHttpClient>): Boolean {
66
+ return size > MAX_CLIENTS
70
67
  }
71
68
  }
72
69
 
73
- private fun sendLogEvent(level: String, message: String) {
74
- if (listenerCount > 0) {
75
- val logData = Arguments.createMap().apply {
76
- putString("level", level)
77
- putString("message", message)
78
- putDouble("timestamp", System.currentTimeMillis().toDouble())
79
- }
70
+ private val activeCalls = ConcurrentHashMap<String, Call>()
80
71
 
81
- reactApplicationContext
82
- .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
83
- ?.emit(EVENT_NAME, logData)
84
- }
85
- }
72
+ override fun getName(): String = NAME
86
73
 
87
74
  override fun request(config: ReadableMap, promise: Promise) {
88
75
  try {
89
76
  val requestConfig = config.toRequestConfig()
90
- // Log module initialization
91
- sendLogEvent("info", "SniConnect module initialized successfully")
92
77
  performRequest(requestConfig, promise)
93
78
  } catch (error: Exception) {
94
- Log.e(TAG, "[SniConnect] Request failed", error)
95
- sendLogEvent("error", "Config parsing failed: ${error.message}")
96
- promise.reject("SNI_REQUEST_FAILED", error.message, error)
79
+ SniConnectLogger.error("Config parsing failed: ${error.message}")
80
+ promise.reject("SNI_INVALID_CONFIG", error.message, error)
97
81
  }
98
82
  }
99
83
 
@@ -102,38 +86,31 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
102
86
  val call = activeCalls.remove(requestId)
103
87
  if (call != null) {
104
88
  call.cancel()
105
- sendLogEvent("info", "Cancelled request: $requestId")
106
- promise.resolve(Arguments.createMap().apply {
107
- putBoolean("success", true)
108
- })
89
+ SniConnectLogger.info("Cancelled request: $requestId")
90
+ promise.resolve(Arguments.createMap().apply { putBoolean("success", true) })
109
91
  } else {
110
- sendLogEvent("info", "Request not found: $requestId")
111
- promise.resolve(Arguments.createMap().apply {
112
- putBoolean("success", false)
113
- })
92
+ promise.resolve(Arguments.createMap().apply { putBoolean("success", false) })
114
93
  }
115
94
  }
116
95
 
117
96
  @ReactMethod
118
97
  override fun cancelAllRequests(promise: Promise) {
119
98
  val count = activeCalls.size
120
- activeCalls.forEach { (_, call) ->
121
- call.cancel()
122
- }
99
+ activeCalls.forEach { (_, call) -> call.cancel() }
123
100
  activeCalls.clear()
124
- sendLogEvent("info", "Cancelled $count active requests")
125
- promise.resolve(Arguments.createMap().apply {
126
- putBoolean("success", true)
127
- })
101
+ SniConnectLogger.info("Cancelled $count active requests")
102
+ promise.resolve(Arguments.createMap().apply { putBoolean("success", true) })
128
103
  }
129
104
 
130
105
  @ReactMethod
131
106
  override fun clearDNSCache(promise: Promise) {
132
- clientCache.clear()
133
- sendLogEvent("info", "DNS cache cleared")
134
- promise.resolve(Arguments.createMap().apply {
135
- putBoolean("success", true)
136
- })
107
+ synchronized(clientCache) {
108
+ clientCache.clear()
109
+ }
110
+ // Drop pinned-IP connections from the shared pool.
111
+ sharedConnectionPool.evictAll()
112
+ SniConnectLogger.info("DNS cache cleared")
113
+ promise.resolve(Arguments.createMap().apply { putBoolean("success", true) })
137
114
  }
138
115
 
139
116
  private fun performRequest(config: RequestConfig, promise: Promise) {
@@ -148,52 +125,55 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
148
125
  // Register the call if requestId is provided
149
126
  config.requestId?.let { requestId ->
150
127
  activeCalls[requestId] = call
151
- sendLogEvent("info", "Registered request: $requestId, total active: ${activeCalls.size}")
152
128
  }
153
129
 
130
+ // Guard against double-settling the promise (RN hard-crashes otherwise).
131
+ val settled = AtomicBoolean(false)
132
+
154
133
  call.enqueue(object : Callback {
155
134
  override fun onFailure(call: Call, e: IOException) {
156
- // Unregister the call
157
135
  config.requestId?.let { activeCalls.remove(it) }
136
+ if (!settled.compareAndSet(false, true)) return
158
137
 
159
138
  if (call.isCanceled()) {
160
- sendLogEvent("info", "Request cancelled")
161
139
  promise.reject("SNI_CANCELLED", "Request cancelled", null)
162
140
  } else {
163
- Log.e(TAG, "[SniConnect] Request failed", e)
164
- sendLogEvent("error", "Request failed: ${e.message}")
141
+ SniConnectLogger.error("Request failed: ${e.message}")
165
142
  promise.reject("SNI_REQUEST_FAILED", e.message, e)
166
143
  }
167
144
  }
168
145
 
169
146
  override fun onResponse(call: Call, response: Response) {
170
- // Unregister the call
171
147
  config.requestId?.let { activeCalls.remove(it) }
172
148
 
173
- try {
149
+ val result: WritableMap = try {
174
150
  response.use {
175
151
  val bodyString = response.body.safeString()
176
152
  val headerMap = headersToMap(response.headers)
177
-
178
- val result: WritableMap = Arguments.createMap().apply {
153
+ Arguments.createMap().apply {
179
154
  putString("data", bodyString)
180
155
  putInt("status", response.code)
181
156
  putString("statusText", response.message)
182
157
  putMap("headers", headerMap.toWritableMap())
183
158
  }
184
-
185
- promise.resolve(result)
186
159
  }
187
160
  } catch (error: Exception) {
188
- Log.e(TAG, "[SniConnect] Response processing failed", error)
189
- sendLogEvent("error", "Response processing failed: ${error.message}")
161
+ if (!settled.compareAndSet(false, true)) return
162
+ SniConnectLogger.error("Response processing failed: ${error.message}")
190
163
  promise.reject("SNI_RESPONSE_FAILED", error.message, error)
164
+ return
165
+ }
166
+
167
+ if (response.code >= 400) {
168
+ SniConnectLogger.warn("HTTP ${response.code} for ${config.hostname}")
169
+ }
170
+ if (settled.compareAndSet(false, true)) {
171
+ promise.resolve(result)
191
172
  }
192
173
  }
193
174
  })
194
175
  } catch (error: Exception) {
195
- Log.e(TAG, "[SniConnect] Request setup failed", error)
196
- sendLogEvent("error", "Request setup failed: ${error.message}")
176
+ SniConnectLogger.error("Request setup failed: ${error.message}")
197
177
  promise.reject("SNI_REQUEST_FAILED", error.message, error)
198
178
  }
199
179
  }
@@ -202,47 +182,54 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
202
182
  val normalizedHost = config.hostname.lowercase(Locale.US)
203
183
  val key = ClientKey(normalizedHost, config.ip)
204
184
 
205
- return clientCache.getOrPut(key) {
206
- // Note: timeout is not part of the cache key to allow connection reuse
207
- // Use reasonable default timeouts for the client
208
- // Per-request timeout is applied via call.timeout() in performRequest
209
- val defaultTimeout = 60_000L // 60 seconds
185
+ synchronized(clientCache) {
186
+ clientCache[key]?.let { return it }
187
+
188
+ // 60s defaults at the client level; the real deadline is the per-call timeout.
189
+ val defaultTimeout = 60_000L
210
190
 
211
- OkHttpClient.Builder()
191
+ val client = OkHttpClient.Builder()
192
+ .dispatcher(sharedDispatcher)
193
+ .connectionPool(sharedConnectionPool)
212
194
  .connectTimeout(defaultTimeout, TimeUnit.MILLISECONDS)
213
195
  .readTimeout(defaultTimeout, TimeUnit.MILLISECONDS)
214
196
  .writeTimeout(defaultTimeout, TimeUnit.MILLISECONDS)
215
- .callTimeout(0, TimeUnit.MILLISECONDS) // Disable client-level call timeout
197
+ .callTimeout(0, TimeUnit.MILLISECONDS)
198
+ // TLS is validated normally: cert chain via the default trust manager and
199
+ // hostname verification against the REAL hostname (not the pinned IP).
216
200
  .hostnameVerifier { _, session ->
217
201
  HttpsURLConnection.getDefaultHostnameVerifier().verify(config.hostname, session)
218
202
  }
219
203
  .dns(createPinnedDns(config.ip, config.hostname))
220
204
  .build()
205
+
206
+ clientCache[key] = client
207
+ return client
221
208
  }
222
209
  }
223
210
 
224
211
  private fun createPinnedDns(ip: String, hostname: String): Dns =
225
212
  object : Dns {
226
213
  private val expectedHost = hostname.lowercase(Locale.US)
214
+ // Resolve the literal IP once up front (validated; never triggers DNS).
215
+ private val pinnedAddress: InetAddress = SniConnectValidation.literalToInetAddress(ip)
227
216
 
228
217
  override fun lookup(requestedHost: String): List<InetAddress> {
229
218
  return if (requestedHost.lowercase(Locale.US) == expectedHost) {
230
- listOf(resolveIp(ip))
219
+ listOf(pinnedAddress)
231
220
  } else {
232
221
  Dns.SYSTEM.lookup(requestedHost)
233
222
  }
234
223
  }
235
224
  }
236
225
 
226
+ /**
227
+ * Build the request. Always `https://<hostname><path>` on the implicit port 443 —
228
+ * `path` has been validated as relative, so scheme/host/port cannot be overridden.
229
+ */
237
230
  private fun buildRequest(config: RequestConfig): Request {
238
- val normalizedPath = if (config.path.startsWith("http")) {
239
- config.path
240
- } else {
241
- val prefix = if (config.path.startsWith("/")) "" else "/"
242
- "https://${config.hostname}$prefix${config.path}"
243
- }
244
-
245
- val builder = Request.Builder().url(normalizedPath)
231
+ val url = "https://${config.hostname}${config.path}"
232
+ val builder = Request.Builder().url(url)
246
233
 
247
234
  config.headers.forEach { (key, value) ->
248
235
  if (!key.equals("host", ignoreCase = true)) {
@@ -251,7 +238,7 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
251
238
  }
252
239
  builder.header("Host", config.hostname)
253
240
 
254
- val method = config.method.uppercase(Locale.US)
241
+ val method = config.method
255
242
  val bodyContent = config.body ?: ""
256
243
  val mediaType = config.headers.entries
257
244
  .firstOrNull { it.key.equals("Content-Type", ignoreCase = true) }
@@ -278,13 +265,10 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
278
265
  }
279
266
 
280
267
  private fun ResponseBody?.safeString(): String {
281
- if (this == null) {
282
- return ""
283
- }
268
+ if (this == null) return ""
284
269
  return try {
285
270
  this.string()
286
271
  } catch (error: IOException) {
287
- Log.e(TAG, "[SniConnect] Failed to read response body", error)
288
272
  throw IOException("Failed to read response body", error)
289
273
  }
290
274
  }
@@ -297,50 +281,47 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
297
281
  return map
298
282
  }
299
283
 
300
- private fun resolveIp(ip: String): InetAddress {
301
- return try {
302
- InetAddress.getByName(ip)
303
- } catch (error: UnknownHostException) {
304
- throw IOException("Invalid IP address: $ip", error)
305
- }
306
- }
307
-
308
284
  private fun Map<String, String>.toWritableMap(): WritableMap {
309
285
  return Arguments.createMap().apply {
310
- forEach { (key, value) ->
311
- putString(key, value)
312
- }
286
+ forEach { (key, value) -> putString(key, value) }
313
287
  }
314
288
  }
315
289
 
316
290
  private fun ReadableMap.toRequestConfig(): RequestConfig {
317
291
  val headersMap = if (hasKey("headers") && !isNull("headers")) {
318
- val headersReadable = getMap("headers")
319
- headersReadable?.toHashMap()
292
+ getMap("headers")?.toHashMap()
320
293
  ?.mapValues { (_, value) -> value?.toString() ?: "" }
321
294
  ?: emptyMap()
322
295
  } else {
323
296
  emptyMap()
324
297
  }
325
298
 
326
- val timeoutMillis = if (hasKey("timeout")) {
327
- (getDouble("timeout") * 1.0).toLong()
299
+ val timeoutMillis = if (hasKey("timeout") && !isNull("timeout")) {
300
+ getDouble("timeout").toLong().coerceAtLeast(1L)
328
301
  } else {
329
302
  30_000L
330
303
  }
331
304
 
332
- val requestId = if (hasKey("requestId") && !isNull("requestId")) {
333
- getString("requestId")
334
- } else {
335
- null
336
- }
305
+ val requestId = if (hasKey("requestId") && !isNull("requestId")) getString("requestId") else null
306
+
307
+ val ip = getString("ip") ?: throw IllegalArgumentException("ip is required")
308
+ val hostname = getString("hostname") ?: throw IllegalArgumentException("hostname is required")
309
+ val method = getString("method") ?: "GET"
310
+ val path = getString("path") ?: "/"
311
+
312
+ // Validate every caller-controlled field at the boundary.
313
+ SniConnectValidation.validatePublicIp(ip)
314
+ SniConnectValidation.validateHostname(hostname)
315
+ SniConnectValidation.validateHeaders(headersMap)
316
+ val normalizedMethod = SniConnectValidation.normalizeMethod(method)
317
+ val normalizedPath = SniConnectValidation.normalizePath(path)
337
318
 
338
319
  return RequestConfig(
339
320
  requestId = requestId,
340
- ip = getString("ip") ?: throw IllegalArgumentException("ip is required"),
341
- hostname = getString("hostname") ?: throw IllegalArgumentException("hostname is required"),
342
- method = getString("method") ?: "GET",
343
- path = getString("path") ?: "/",
321
+ ip = ip,
322
+ hostname = hostname,
323
+ method = normalizedMethod,
324
+ path = normalizedPath,
344
325
  headers = headersMap,
345
326
  body = if (hasKey("body") && !isNull("body")) getString("body") else null,
346
327
  timeoutMillis = timeoutMillis,