@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 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,59 +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
 
49
+ /**
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()`.
54
+ */
41
55
  private data class ClientKey(
42
- val ip: String,
43
56
  val hostname: String,
44
- val timeoutMillis: Long,
57
+ val ip: String,
45
58
  )
46
59
 
47
- private val clientCache = ConcurrentHashMap<ClientKey, OkHttpClient>()
48
- private val activeCalls = ConcurrentHashMap<String, Call>()
49
- private var listenerCount = 0
50
-
51
- override fun getName(): String = NAME
52
-
53
- // Event emitter methods required for NativeEventEmitter
54
- @ReactMethod
55
- override fun addListener(eventType: String) {
56
- listenerCount += 1
57
- }
58
-
59
- @ReactMethod
60
- override fun removeListeners(count: Double) {
61
- listenerCount -= count.toInt()
62
- if (listenerCount < 0) {
63
- 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
64
67
  }
65
68
  }
66
69
 
67
- private fun sendLogEvent(level: String, message: String) {
68
- if (listenerCount > 0) {
69
- val logData = Arguments.createMap().apply {
70
- putString("level", level)
71
- putString("message", message)
72
- putDouble("timestamp", System.currentTimeMillis().toDouble())
73
- }
70
+ private val activeCalls = ConcurrentHashMap<String, Call>()
74
71
 
75
- reactApplicationContext
76
- .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
77
- ?.emit(EVENT_NAME, logData)
78
- }
79
- }
72
+ override fun getName(): String = NAME
80
73
 
81
74
  override fun request(config: ReadableMap, promise: Promise) {
82
75
  try {
83
76
  val requestConfig = config.toRequestConfig()
84
- // Log module initialization
85
- sendLogEvent("info", "SniConnect module initialized successfully")
86
77
  performRequest(requestConfig, promise)
87
78
  } catch (error: Exception) {
88
- Log.e(TAG, "[SniConnect] Request failed", error)
89
- sendLogEvent("error", "Config parsing failed: ${error.message}")
90
- 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)
91
81
  }
92
82
  }
93
83
 
@@ -96,38 +86,31 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
96
86
  val call = activeCalls.remove(requestId)
97
87
  if (call != null) {
98
88
  call.cancel()
99
- sendLogEvent("info", "Cancelled request: $requestId")
100
- promise.resolve(Arguments.createMap().apply {
101
- putBoolean("success", true)
102
- })
89
+ SniConnectLogger.info("Cancelled request: $requestId")
90
+ promise.resolve(Arguments.createMap().apply { putBoolean("success", true) })
103
91
  } else {
104
- sendLogEvent("info", "Request not found: $requestId")
105
- promise.resolve(Arguments.createMap().apply {
106
- putBoolean("success", false)
107
- })
92
+ promise.resolve(Arguments.createMap().apply { putBoolean("success", false) })
108
93
  }
109
94
  }
110
95
 
111
96
  @ReactMethod
112
97
  override fun cancelAllRequests(promise: Promise) {
113
98
  val count = activeCalls.size
114
- activeCalls.forEach { (_, call) ->
115
- call.cancel()
116
- }
99
+ activeCalls.forEach { (_, call) -> call.cancel() }
117
100
  activeCalls.clear()
118
- sendLogEvent("info", "Cancelled $count active requests")
119
- promise.resolve(Arguments.createMap().apply {
120
- putBoolean("success", true)
121
- })
101
+ SniConnectLogger.info("Cancelled $count active requests")
102
+ promise.resolve(Arguments.createMap().apply { putBoolean("success", true) })
122
103
  }
123
104
 
124
105
  @ReactMethod
125
106
  override fun clearDNSCache(promise: Promise) {
126
- clientCache.clear()
127
- sendLogEvent("info", "DNS cache cleared")
128
- promise.resolve(Arguments.createMap().apply {
129
- putBoolean("success", true)
130
- })
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) })
131
114
  }
132
115
 
133
116
  private fun performRequest(config: RequestConfig, promise: Promise) {
@@ -136,99 +119,117 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
136
119
  val request = buildRequest(config)
137
120
  val call = client.newCall(request)
138
121
 
122
+ // Apply per-request timeout
123
+ call.timeout().timeout(config.timeoutMillis, TimeUnit.MILLISECONDS)
124
+
139
125
  // Register the call if requestId is provided
140
126
  config.requestId?.let { requestId ->
141
127
  activeCalls[requestId] = call
142
- sendLogEvent("info", "Registered request: $requestId, total active: ${activeCalls.size}")
143
128
  }
144
129
 
130
+ // Guard against double-settling the promise (RN hard-crashes otherwise).
131
+ val settled = AtomicBoolean(false)
132
+
145
133
  call.enqueue(object : Callback {
146
134
  override fun onFailure(call: Call, e: IOException) {
147
- // Unregister the call
148
135
  config.requestId?.let { activeCalls.remove(it) }
136
+ if (!settled.compareAndSet(false, true)) return
149
137
 
150
138
  if (call.isCanceled()) {
151
- sendLogEvent("info", "Request cancelled")
152
139
  promise.reject("SNI_CANCELLED", "Request cancelled", null)
153
140
  } else {
154
- Log.e(TAG, "[SniConnect] Request failed", e)
155
- sendLogEvent("error", "Request failed: ${e.message}")
141
+ SniConnectLogger.error("Request failed: ${e.message}")
156
142
  promise.reject("SNI_REQUEST_FAILED", e.message, e)
157
143
  }
158
144
  }
159
145
 
160
146
  override fun onResponse(call: Call, response: Response) {
161
- // Unregister the call
162
147
  config.requestId?.let { activeCalls.remove(it) }
163
148
 
164
- try {
149
+ val result: WritableMap = try {
165
150
  response.use {
166
151
  val bodyString = response.body.safeString()
167
152
  val headerMap = headersToMap(response.headers)
168
-
169
- val result: WritableMap = Arguments.createMap().apply {
153
+ Arguments.createMap().apply {
170
154
  putString("data", bodyString)
171
155
  putInt("status", response.code)
172
156
  putString("statusText", response.message)
173
157
  putMap("headers", headerMap.toWritableMap())
174
158
  }
175
-
176
- promise.resolve(result)
177
159
  }
178
160
  } catch (error: Exception) {
179
- Log.e(TAG, "[SniConnect] Response processing failed", error)
180
- sendLogEvent("error", "Response processing failed: ${error.message}")
161
+ if (!settled.compareAndSet(false, true)) return
162
+ SniConnectLogger.error("Response processing failed: ${error.message}")
181
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)
182
172
  }
183
173
  }
184
174
  })
185
175
  } catch (error: Exception) {
186
- Log.e(TAG, "[SniConnect] Request setup failed", error)
187
- sendLogEvent("error", "Request setup failed: ${error.message}")
176
+ SniConnectLogger.error("Request setup failed: ${error.message}")
188
177
  promise.reject("SNI_REQUEST_FAILED", error.message, error)
189
178
  }
190
179
  }
191
180
 
192
181
  private fun getOrCreateClient(config: RequestConfig): OkHttpClient {
193
182
  val normalizedHost = config.hostname.lowercase(Locale.US)
194
- val key = ClientKey(config.ip, normalizedHost, config.timeoutMillis)
195
-
196
- return clientCache.getOrPut(key) {
197
- OkHttpClient.Builder()
198
- .connectTimeout(config.timeoutMillis, TimeUnit.MILLISECONDS)
199
- .readTimeout(config.timeoutMillis, TimeUnit.MILLISECONDS)
200
- .writeTimeout(config.timeoutMillis, TimeUnit.MILLISECONDS)
201
- .callTimeout(config.timeoutMillis, TimeUnit.MILLISECONDS)
183
+ val key = ClientKey(normalizedHost, config.ip)
184
+
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
190
+
191
+ val client = OkHttpClient.Builder()
192
+ .dispatcher(sharedDispatcher)
193
+ .connectionPool(sharedConnectionPool)
194
+ .connectTimeout(defaultTimeout, TimeUnit.MILLISECONDS)
195
+ .readTimeout(defaultTimeout, TimeUnit.MILLISECONDS)
196
+ .writeTimeout(defaultTimeout, TimeUnit.MILLISECONDS)
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).
202
200
  .hostnameVerifier { _, session ->
203
201
  HttpsURLConnection.getDefaultHostnameVerifier().verify(config.hostname, session)
204
202
  }
205
203
  .dns(createPinnedDns(config.ip, config.hostname))
206
204
  .build()
205
+
206
+ clientCache[key] = client
207
+ return client
207
208
  }
208
209
  }
209
210
 
210
211
  private fun createPinnedDns(ip: String, hostname: String): Dns =
211
212
  object : Dns {
212
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)
213
216
 
214
217
  override fun lookup(requestedHost: String): List<InetAddress> {
215
218
  return if (requestedHost.lowercase(Locale.US) == expectedHost) {
216
- listOf(resolveIp(ip))
219
+ listOf(pinnedAddress)
217
220
  } else {
218
221
  Dns.SYSTEM.lookup(requestedHost)
219
222
  }
220
223
  }
221
224
  }
222
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
+ */
223
230
  private fun buildRequest(config: RequestConfig): Request {
224
- val normalizedPath = if (config.path.startsWith("http")) {
225
- config.path
226
- } else {
227
- val prefix = if (config.path.startsWith("/")) "" else "/"
228
- "https://${config.hostname}$prefix${config.path}"
229
- }
230
-
231
- val builder = Request.Builder().url(normalizedPath)
231
+ val url = "https://${config.hostname}${config.path}"
232
+ val builder = Request.Builder().url(url)
232
233
 
233
234
  config.headers.forEach { (key, value) ->
234
235
  if (!key.equals("host", ignoreCase = true)) {
@@ -237,7 +238,7 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
237
238
  }
238
239
  builder.header("Host", config.hostname)
239
240
 
240
- val method = config.method.uppercase(Locale.US)
241
+ val method = config.method
241
242
  val bodyContent = config.body ?: ""
242
243
  val mediaType = config.headers.entries
243
244
  .firstOrNull { it.key.equals("Content-Type", ignoreCase = true) }
@@ -264,13 +265,10 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
264
265
  }
265
266
 
266
267
  private fun ResponseBody?.safeString(): String {
267
- if (this == null) {
268
- return ""
269
- }
268
+ if (this == null) return ""
270
269
  return try {
271
270
  this.string()
272
271
  } catch (error: IOException) {
273
- Log.e(TAG, "[SniConnect] Failed to read response body", error)
274
272
  throw IOException("Failed to read response body", error)
275
273
  }
276
274
  }
@@ -283,50 +281,47 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
283
281
  return map
284
282
  }
285
283
 
286
- private fun resolveIp(ip: String): InetAddress {
287
- return try {
288
- InetAddress.getByName(ip)
289
- } catch (error: UnknownHostException) {
290
- throw IOException("Invalid IP address: $ip", error)
291
- }
292
- }
293
-
294
284
  private fun Map<String, String>.toWritableMap(): WritableMap {
295
285
  return Arguments.createMap().apply {
296
- forEach { (key, value) ->
297
- putString(key, value)
298
- }
286
+ forEach { (key, value) -> putString(key, value) }
299
287
  }
300
288
  }
301
289
 
302
290
  private fun ReadableMap.toRequestConfig(): RequestConfig {
303
291
  val headersMap = if (hasKey("headers") && !isNull("headers")) {
304
- val headersReadable = getMap("headers")
305
- headersReadable?.toHashMap()
292
+ getMap("headers")?.toHashMap()
306
293
  ?.mapValues { (_, value) -> value?.toString() ?: "" }
307
294
  ?: emptyMap()
308
295
  } else {
309
296
  emptyMap()
310
297
  }
311
298
 
312
- val timeoutMillis = if (hasKey("timeout")) {
313
- (getDouble("timeout") * 1.0).toLong()
299
+ val timeoutMillis = if (hasKey("timeout") && !isNull("timeout")) {
300
+ getDouble("timeout").toLong().coerceAtLeast(1L)
314
301
  } else {
315
302
  30_000L
316
303
  }
317
304
 
318
- val requestId = if (hasKey("requestId") && !isNull("requestId")) {
319
- getString("requestId")
320
- } else {
321
- null
322
- }
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)
323
318
 
324
319
  return RequestConfig(
325
320
  requestId = requestId,
326
- ip = getString("ip") ?: throw IllegalArgumentException("ip is required"),
327
- hostname = getString("hostname") ?: throw IllegalArgumentException("hostname is required"),
328
- method = getString("method") ?: "GET",
329
- path = getString("path") ?: "/",
321
+ ip = ip,
322
+ hostname = hostname,
323
+ method = normalizedMethod,
324
+ path = normalizedPath,
330
325
  headers = headersMap,
331
326
  body = if (hasKey("body") && !isNull("body")) getString("body") else null,
332
327
  timeoutMillis = timeoutMillis,