pakstr 0.19.2 → 0.19.3

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.
@@ -0,0 +1,208 @@
1
+ package com.pakstr.app
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertFalse
5
+ import org.junit.Assert.assertNull
6
+ import org.junit.Assert.assertTrue
7
+ import org.junit.Test
8
+ import java.net.URL
9
+ import java.util.Base64
10
+
11
+ class Nip98ProxyOriginTest {
12
+
13
+ private val localUrl = URL("http://127.0.0.1:${Config.PORT}/api/auth/login")
14
+
15
+ @Test
16
+ fun validLocalNip98EventGeneratesForwardingOrigin() {
17
+ assertEquals(
18
+ ProxyOrigin("127.0.0.1:${Config.PORT}", "http"),
19
+ localNip98ProxyOrigin(nip98Header(), "POST", localUrl)
20
+ )
21
+ }
22
+
23
+ @Test
24
+ fun bodylessNip98RequestAddsOriginWithoutEntityHeadersOrBody() {
25
+ val authorization = nip98Header()
26
+ val headers = prepareProxyRequestHeaders(
27
+ mapOf(
28
+ "authorization" to authorization,
29
+ "content-type" to "",
30
+ "content-length" to "0",
31
+ "transfer-encoding" to "chunked"
32
+ ),
33
+ "POST",
34
+ localUrl,
35
+ "https://api.example.com"
36
+ )
37
+ val request = bodylessProxyRequestBytes(
38
+ "POST",
39
+ URL("https://api.example.com/api/auth/login"),
40
+ headers
41
+ ).toString(Charsets.ISO_8859_1)
42
+
43
+ assertTrue(request.contains("authorization: $authorization\r\n"))
44
+ assertTrue(request.contains("X-Forwarded-Host: 127.0.0.1:${Config.PORT}\r\n"))
45
+ assertTrue(request.contains("X-Forwarded-Proto: http\r\n"))
46
+ assertFalse(request.contains("Content-Type", ignoreCase = true))
47
+ assertFalse(request.contains("Content-Length", ignoreCase = true))
48
+ assertFalse(request.contains("Transfer-Encoding", ignoreCase = true))
49
+ assertTrue(request.endsWith("Connection: close\r\n\r\n"))
50
+ }
51
+
52
+ @Test
53
+ fun bodyBearingNip98RequestAddsOriginAndPreservesBodyHeaders() {
54
+ val authorization = nip98Header()
55
+ val headers = prepareProxyRequestHeaders(
56
+ mapOf(
57
+ "authorization" to authorization,
58
+ "content-type" to "application/json",
59
+ "content-length" to "11"
60
+ ),
61
+ "POST",
62
+ localUrl,
63
+ "https://api.example.com"
64
+ )
65
+ val policy = proxyRequestBodyPolicy(
66
+ mapOf(
67
+ "content-type" to "application/json",
68
+ "content-length" to "11"
69
+ )
70
+ )
71
+
72
+ assertEquals(authorization, headers["authorization"])
73
+ assertEquals("application/json", headers["content-type"])
74
+ assertEquals("127.0.0.1:${Config.PORT}", headers["X-Forwarded-Host"])
75
+ assertEquals("http", headers["X-Forwarded-Proto"])
76
+ assertTrue(policy.shouldWriteBody)
77
+ assertEquals("application/json", policy.contentType)
78
+ }
79
+
80
+ @Test
81
+ fun noAuthorizationIsUnchanged() {
82
+ val input = mapOf("accept" to "application/json")
83
+
84
+ assertEquals(
85
+ input,
86
+ prepareProxyRequestHeaders(input, "GET", localUrl, "https://api.example.com")
87
+ )
88
+ }
89
+
90
+ @Test
91
+ fun bearerAuthorizationIsUnchanged() {
92
+ val input = mapOf("authorization" to "Bearer token")
93
+
94
+ assertEquals(
95
+ input,
96
+ prepareProxyRequestHeaders(input, "POST", localUrl, "https://api.example.com")
97
+ )
98
+ }
99
+
100
+ @Test
101
+ fun malformedNostrAuthorizationFailsClosed() {
102
+ assertNull(localNip98ProxyOrigin("Nostr not-base64", "POST", localUrl))
103
+ assertNull(localNip98ProxyOrigin("Nostr e30=", "POST", localUrl))
104
+ }
105
+
106
+ @Test
107
+ fun wrongKindFailsClosed() {
108
+ assertNull(localNip98ProxyOrigin(nip98Header(kind = 1), "POST", localUrl))
109
+ }
110
+
111
+ @Test
112
+ fun wrongMethodFailsClosed() {
113
+ assertNull(localNip98ProxyOrigin(nip98Header(method = "GET"), "POST", localUrl))
114
+ }
115
+
116
+ @Test
117
+ fun wrongPathFailsClosed() {
118
+ assertNull(localNip98ProxyOrigin(
119
+ nip98Header(url = "http://127.0.0.1:${Config.PORT}/api/other"),
120
+ "POST",
121
+ localUrl
122
+ ))
123
+ }
124
+
125
+ @Test
126
+ fun wrongQueryFailsClosed() {
127
+ val requestUrl = URL("http://127.0.0.1:${Config.PORT}/api/auth/login?next=one")
128
+
129
+ assertNull(localNip98ProxyOrigin(
130
+ nip98Header(url = "http://127.0.0.1:${Config.PORT}/api/auth/login?next=two"),
131
+ "POST",
132
+ requestUrl
133
+ ))
134
+ }
135
+
136
+ @Test
137
+ fun nonLocalHostFailsClosed() {
138
+ assertNull(localNip98ProxyOrigin(
139
+ nip98Header(url = "http://localhost:${Config.PORT}/api/auth/login"),
140
+ "POST",
141
+ localUrl
142
+ ))
143
+ }
144
+
145
+ @Test
146
+ fun upstreamUrlFailsClosed() {
147
+ assertNull(localNip98ProxyOrigin(
148
+ nip98Header(url = "https://api.example.com/api/auth/login"),
149
+ "POST",
150
+ localUrl
151
+ ))
152
+ }
153
+
154
+ @Test
155
+ fun ambiguousOrUnusableTagsFailClosed() {
156
+ assertNull(localNip98ProxyOrigin(
157
+ nip98Header(extraTags = ",[\"u\",\"http://127.0.0.1:${Config.PORT}/api/auth/login\"]"),
158
+ "POST",
159
+ localUrl
160
+ ))
161
+ assertNull(localNip98ProxyOrigin(
162
+ nip98Header(extraTags = ",[\"method\",\"POST\"]"),
163
+ "POST",
164
+ localUrl
165
+ ))
166
+ assertNull(localNip98ProxyOrigin(nip98Header(url = ""), "POST", localUrl))
167
+ assertNull(localNip98ProxyOrigin(nip98Header(method = ""), "POST", localUrl))
168
+ }
169
+
170
+ @Test
171
+ fun spoofedForwardingHeadersCannotOverrideDerivedValues() {
172
+ val headers = prepareProxyRequestHeaders(
173
+ mapOf(
174
+ "authorization" to nip98Header(),
175
+ "Forwarded" to "host=attacker.example;proto=https",
176
+ "X-Forwarded-Host" to "attacker.example",
177
+ "X-Forwarded-Proto" to "https"
178
+ ),
179
+ "POST",
180
+ localUrl,
181
+ "https://api.example.com"
182
+ )
183
+
184
+ assertFalse(headers.keys.any { it.equals("Forwarded", ignoreCase = true) })
185
+ assertEquals("127.0.0.1:${Config.PORT}", headers["X-Forwarded-Host"])
186
+ assertEquals("http", headers["X-Forwarded-Proto"])
187
+ }
188
+
189
+ @Test
190
+ fun missingApiBaseReturnsExistingHeadersUnchanged() {
191
+ val input = mapOf(
192
+ "authorization" to nip98Header(),
193
+ "X-Forwarded-Host" to "caller.example"
194
+ )
195
+
196
+ assertEquals(input, prepareProxyRequestHeaders(input, "POST", localUrl, null))
197
+ }
198
+
199
+ private fun nip98Header(
200
+ kind: Int = 27235,
201
+ url: String = localUrl.toString(),
202
+ method: String = "POST",
203
+ extraTags: String = ""
204
+ ): String {
205
+ val event = """{"kind":$kind,"tags":[["u","$url"],["method","$method"]$extraTags]}"""
206
+ return "Nostr ${Base64.getEncoder().encodeToString(event.toByteArray())}"
207
+ }
208
+ }
@@ -6,10 +6,219 @@ import android.webkit.MimeTypeMap
6
6
  import com.pakstr.app.debug.AppDebugLogger
7
7
 
8
8
  import fi.iki.elonen.NanoHTTPD
9
+ import java.io.BufferedInputStream
10
+ import java.io.ByteArrayOutputStream
9
11
  import java.io.IOException
10
12
  import java.net.HttpURLConnection
13
+ import java.net.InetSocketAddress
14
+ import java.net.Socket
11
15
  import java.net.URL
16
+ import java.nio.charset.StandardCharsets
12
17
  import java.util.Locale
18
+ import javax.net.ssl.HttpsURLConnection
19
+ import javax.net.ssl.SSLSocketFactory
20
+
21
+ internal data class ProxyRequestBodyPolicy(
22
+ val shouldWriteBody: Boolean,
23
+ val contentType: String?
24
+ )
25
+
26
+ internal class InvalidContentLengthException : IllegalArgumentException()
27
+
28
+ internal class UnsupportedTransferEncodingException : IllegalArgumentException()
29
+
30
+ internal fun proxyRequestBodyPolicy(
31
+ headers: Map<String, String>
32
+ ): ProxyRequestBodyPolicy {
33
+ if (headers.containsKey("transfer-encoding")) {
34
+ throw UnsupportedTransferEncodingException()
35
+ }
36
+
37
+ val contentLengthHeader = headers["content-length"]
38
+ val contentLength = when {
39
+ contentLengthHeader == null -> 0L
40
+ !contentLengthHeader.matches(Regex("[0-9]+")) -> {
41
+ throw InvalidContentLengthException()
42
+ }
43
+ else -> contentLengthHeader.toLongOrNull()
44
+ ?: throw InvalidContentLengthException()
45
+ }
46
+
47
+ return ProxyRequestBodyPolicy(
48
+ shouldWriteBody = contentLength > 0,
49
+ contentType = headers["content-type"]
50
+ )
51
+ }
52
+
53
+ internal fun shouldForwardProxyRequestHeader(
54
+ name: String
55
+ ): Boolean = name.lowercase() !in setOf(
56
+ "host",
57
+ "content-length",
58
+ "connection",
59
+ "accept-encoding",
60
+ "transfer-encoding",
61
+ "forwarded",
62
+ "x-forwarded-host",
63
+ "x-forwarded-proto"
64
+ )
65
+
66
+ internal data class ProxyHttpResponse(
67
+ val code: Int,
68
+ val contentType: String?,
69
+ val body: ByteArray
70
+ )
71
+
72
+ internal fun bodylessProxyRequestBytes(
73
+ method: String,
74
+ url: URL,
75
+ headers: Map<String, String>
76
+ ): ByteArray {
77
+ val defaultPort = if (url.protocol == "https") 443 else 80
78
+ val hostHeader = if (url.port == -1 || url.port == defaultPort) {
79
+ url.host
80
+ } else {
81
+ "${url.host}:${url.port}"
82
+ }
83
+ val path = buildString {
84
+ append(url.path.ifEmpty { "/" })
85
+ url.query?.let {
86
+ append('?')
87
+ append(it)
88
+ }
89
+ }
90
+
91
+ return buildString {
92
+ append("$method $path HTTP/1.1\r\n")
93
+ append("Host: $hostHeader\r\n")
94
+ headers.forEach { (name, value) ->
95
+ if (shouldForwardProxyRequestHeader(name) &&
96
+ !name.equals("content-type", ignoreCase = true)
97
+ ) {
98
+ append("$name: $value\r\n")
99
+ }
100
+ }
101
+ append("Connection: close\r\n\r\n")
102
+ }.toByteArray(StandardCharsets.ISO_8859_1)
103
+ }
104
+
105
+ internal fun executeBodylessProxyRequest(
106
+ method: String,
107
+ url: URL,
108
+ headers: Map<String, String>
109
+ ): ProxyHttpResponse {
110
+ val port = when {
111
+ url.port != -1 -> url.port
112
+ url.protocol == "https" -> 443
113
+ else -> 80
114
+ }
115
+ if (url.protocol != "http" && url.protocol != "https") {
116
+ throw IOException("Unsupported API URL protocol: ${url.protocol}")
117
+ }
118
+
119
+ val transport = Socket().apply {
120
+ connect(InetSocketAddress(url.host, port), 15000)
121
+ soTimeout = 30000
122
+ }
123
+ val socket = if (url.protocol == "https") {
124
+ (SSLSocketFactory.getDefault() as SSLSocketFactory).createSocket(
125
+ transport,
126
+ url.host,
127
+ port,
128
+ true
129
+ )
130
+ } else {
131
+ transport
132
+ }
133
+
134
+ socket.use {
135
+ if (url.protocol == "https") {
136
+ val sslSocket = it as javax.net.ssl.SSLSocket
137
+ sslSocket.startHandshake()
138
+ if (!HttpsURLConnection.getDefaultHostnameVerifier()
139
+ .verify(url.host, sslSocket.session)
140
+ ) {
141
+ throw IOException("Hostname verification failed for ${url.host}")
142
+ }
143
+ }
144
+
145
+ it.getOutputStream().apply {
146
+ write(bodylessProxyRequestBytes(method, url, headers))
147
+ flush()
148
+ }
149
+
150
+ return readProxyHttpResponse(BufferedInputStream(it.getInputStream()))
151
+ }
152
+ }
153
+
154
+ private fun readProxyHttpResponse(input: BufferedInputStream): ProxyHttpResponse {
155
+ val statusLine = readHttpLine(input)
156
+ val code = statusLine.split(' ', limit = 3).getOrNull(1)?.toIntOrNull()
157
+ ?: throw IOException("Invalid HTTP status line")
158
+ val headers = mutableMapOf<String, String>()
159
+
160
+ while (true) {
161
+ val line = readHttpLine(input)
162
+ if (line.isEmpty()) break
163
+ val separator = line.indexOf(':')
164
+ if (separator <= 0) throw IOException("Invalid HTTP response header")
165
+ headers[line.substring(0, separator).lowercase()] =
166
+ line.substring(separator + 1).trim()
167
+ }
168
+
169
+ val body = when {
170
+ headers["transfer-encoding"]?.equals("chunked", ignoreCase = true) == true -> {
171
+ readChunkedBody(input)
172
+ }
173
+ headers["content-length"] != null -> {
174
+ readFixedLengthBody(input, headers.getValue("content-length").toLong())
175
+ }
176
+ else -> input.readBytes()
177
+ }
178
+
179
+ return ProxyHttpResponse(code, headers["content-type"], body)
180
+ }
181
+
182
+ private fun readHttpLine(input: BufferedInputStream): String {
183
+ val bytes = ByteArrayOutputStream()
184
+ while (true) {
185
+ val value = input.read()
186
+ if (value == -1) throw IOException("Unexpected end of HTTP response")
187
+ if (value == '\r'.code) {
188
+ if (input.read() != '\n'.code) throw IOException("Invalid HTTP line ending")
189
+ return bytes.toString(StandardCharsets.ISO_8859_1.name())
190
+ }
191
+ bytes.write(value)
192
+ }
193
+ }
194
+
195
+ private fun readChunkedBody(input: BufferedInputStream): ByteArray {
196
+ val body = ByteArrayOutputStream()
197
+ while (true) {
198
+ val size = readHttpLine(input).substringBefore(';').trim().toLong(16)
199
+ if (size == 0L) {
200
+ while (readHttpLine(input).isNotEmpty()) Unit
201
+ return body.toByteArray()
202
+ }
203
+ body.write(readFixedLengthBody(input, size))
204
+ if (readHttpLine(input).isNotEmpty()) throw IOException("Invalid chunk ending")
205
+ }
206
+ }
207
+
208
+ private fun readFixedLengthBody(
209
+ input: BufferedInputStream,
210
+ length: Long
211
+ ): ByteArray {
212
+ if (length > Int.MAX_VALUE) throw IOException("HTTP response body too large")
213
+ val body = ByteArray(length.toInt())
214
+ var offset = 0
215
+ while (offset < body.size) {
216
+ val read = input.read(body, offset, body.size - offset)
217
+ if (read == -1) throw IOException("Unexpected end of HTTP response body")
218
+ offset += read
219
+ }
220
+ return body
221
+ }
13
222
 
14
223
  class LocalServer(
15
224
 
@@ -182,13 +391,51 @@ class LocalServer(
182
391
  }
183
392
 
184
393
  val finalUrl = apiBase.removeSuffix("/") + uri
394
+ val url = URL(finalUrl)
395
+ val method = session?.method?.name ?: "GET"
396
+ val requestHeaders = session?.headers ?: emptyMap()
397
+ val query = session?.queryParameterString
398
+ val localRequestUrl = URL(
399
+ "http://127.0.0.1:${Config.PORT}$uri" +
400
+ if (query.isNullOrEmpty()) "" else "?$query"
401
+ )
402
+ val preparedHeaders = prepareProxyRequestHeaders(
403
+ headers = requestHeaders,
404
+ method = method,
405
+ requestUrl = localRequestUrl,
406
+ apiBase = apiBase
407
+ )
408
+ val bodyPolicy = if (
409
+ method == "POST" || method == "PUT" || method == "PATCH"
410
+ ) {
411
+ proxyRequestBodyPolicy(requestHeaders)
412
+ } else {
413
+ null
414
+ }
185
415
 
186
- val connection = URL(finalUrl).openConnection() as HttpURLConnection
416
+ if (bodyPolicy?.shouldWriteBody == false) {
417
+ val response = executeBodylessProxyRequest(
418
+ method,
419
+ url,
420
+ preparedHeaders
421
+ )
422
+ if (AppDebugLogger.isNetworkLoggingEnabled(context)) {
423
+ AppDebugLogger.network(
424
+ context,
425
+ "Response: ${response.code} $finalUrl"
426
+ )
427
+ }
428
+ return newFixedLengthResponse(
429
+ Response.Status.lookup(response.code) ?: Response.Status.OK,
430
+ response.contentType ?: "application/json",
431
+ response.body.inputStream(),
432
+ response.body.size.toLong()
433
+ )
434
+ }
435
+
436
+ val connection = url.openConnection() as HttpURLConnection
187
437
  connection.doInput = true
188
438
  connection.instanceFollowRedirects = false
189
-
190
- val method = session?.method?.name ?: "GET"
191
-
192
439
  connection.requestMethod = method
193
440
 
194
441
  connection.connectTimeout = 15000
@@ -198,30 +445,14 @@ class LocalServer(
198
445
  * Forward headers
199
446
  */
200
447
 
201
- session?.headers?.forEach { (key, value) ->
202
-
203
- val skip = setOf(
204
- "host", "content-length", "connection", "accept-encoding"
448
+ preparedHeaders.forEach { (key, value) ->
449
+ connection.setRequestProperty(
450
+ key, value
205
451
  )
206
-
207
-
208
- if (!skip.contains(key.lowercase())) {
209
-
210
- connection.setRequestProperty(
211
- key, value
212
- )
213
- }
214
452
  }
215
- session?.headers?.get("authorization")
216
- ?.let { auth ->
217
- connection.setRequestProperty(
218
- "Authorization", auth
219
- )
220
- }
221
-
222
453
 
223
- if (method == "POST" || method == "PUT" || method == "PATCH") {
224
454
 
455
+ if (bodyPolicy?.shouldWriteBody == true) {
225
456
  val files = HashMap<String, String>()
226
457
 
227
458
  session?.parseBody(files)
@@ -231,7 +462,7 @@ class LocalServer(
231
462
  val bytes = body.toByteArray(Charsets.UTF_8)
232
463
 
233
464
  connection.doOutput = true
234
- session?.headers?.get("content-type")
465
+ bodyPolicy.contentType
235
466
  ?.let {
236
467
  connection.setRequestProperty(
237
468
  "Content-Type", it
@@ -280,6 +511,18 @@ class LocalServer(
280
511
 
281
512
  )
282
513
 
514
+ } catch (e: InvalidContentLengthException) {
515
+ return newFixedLengthResponse(
516
+ Response.Status.BAD_REQUEST,
517
+ "application/json",
518
+ """{"error":"invalid_content_length"}"""
519
+ )
520
+ } catch (e: UnsupportedTransferEncodingException) {
521
+ return newFixedLengthResponse(
522
+ Response.Status.BAD_REQUEST,
523
+ "application/json",
524
+ """{"error":"unsupported_transfer_encoding"}"""
525
+ )
283
526
  } catch (e: Exception) {
284
527
  AppDebugLogger.error(
285
528
 
@@ -0,0 +1,90 @@
1
+ package com.pakstr.app
2
+
3
+ import org.json.JSONArray
4
+ import org.json.JSONObject
5
+ import java.net.URL
6
+ import java.util.Base64
7
+
8
+ internal data class ProxyOrigin(
9
+ val host: String,
10
+ val proto: String
11
+ )
12
+
13
+ internal fun localNip98ProxyOrigin(
14
+ authorization: String?,
15
+ method: String,
16
+ requestUrl: URL
17
+ ): ProxyOrigin? {
18
+ val encodedEvent = authorization
19
+ ?.takeIf { it.startsWith("Nostr ") }
20
+ ?.removePrefix("Nostr ")
21
+ ?.takeIf { it.isNotBlank() }
22
+ ?: return null
23
+
24
+ return runCatching {
25
+ val event = JSONObject(
26
+ String(Base64.getDecoder().decode(encodedEvent), Charsets.UTF_8)
27
+ )
28
+ val kind = event.opt("kind")
29
+ if (kind !is Number || kind.toInt() != 27235 || kind.toDouble() != 27235.0) {
30
+ return null
31
+ }
32
+
33
+ val tags = event.optJSONArray("tags") ?: return null
34
+ val signedUrlValue = uniqueUsableTagValue(tags, "u") ?: return null
35
+ val signedMethod = uniqueUsableTagValue(tags, "method") ?: return null
36
+ if (!signedMethod.equals(method, ignoreCase = true)) return null
37
+
38
+ val signedUrl = URL(signedUrlValue)
39
+ if (signedUrl.protocol != "http" ||
40
+ signedUrl.host != "127.0.0.1" ||
41
+ signedUrl.port != Config.PORT ||
42
+ signedUrl.userInfo != null ||
43
+ signedUrl.ref != null ||
44
+ signedUrl.path != requestUrl.path ||
45
+ signedUrl.query != requestUrl.query
46
+ ) {
47
+ return null
48
+ }
49
+
50
+ ProxyOrigin(
51
+ host = "127.0.0.1:${Config.PORT}",
52
+ proto = "http"
53
+ )
54
+ }.getOrNull()
55
+ }
56
+
57
+ internal fun prepareProxyRequestHeaders(
58
+ headers: Map<String, String>,
59
+ method: String,
60
+ requestUrl: URL,
61
+ apiBase: String?
62
+ ): Map<String, String> {
63
+ if (apiBase.isNullOrBlank()) return headers
64
+
65
+ val prepared = headers.filterKeys(::shouldForwardProxyRequestHeader).toMutableMap()
66
+ localNip98ProxyOrigin(
67
+ authorization = headers.entries.firstOrNull {
68
+ it.key.equals("authorization", ignoreCase = true)
69
+ }?.value,
70
+ method = method,
71
+ requestUrl = requestUrl
72
+ )?.let { origin ->
73
+ prepared["X-Forwarded-Host"] = origin.host
74
+ prepared["X-Forwarded-Proto"] = origin.proto
75
+ }
76
+ return prepared
77
+ }
78
+
79
+ private fun uniqueUsableTagValue(tags: JSONArray, name: String): String? {
80
+ var value: String? = null
81
+ var matchingTags = 0
82
+ for (index in 0 until tags.length()) {
83
+ val tag = tags.optJSONArray(index) ?: continue
84
+ if (tag.optString(0) != name) continue
85
+ matchingTags++
86
+ if (tag.length() != 2 || tag.opt(1) !is String) return null
87
+ value = tag.optString(1).takeIf { it.isNotBlank() } ?: return null
88
+ }
89
+ return value.takeIf { matchingTags == 1 }
90
+ }
@@ -0,0 +1,97 @@
1
+ package com.pakstr.app
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertFalse
5
+ import org.junit.Assert.assertNull
6
+ import org.junit.Assert.assertThrows
7
+ import org.junit.Assert.assertTrue
8
+ import org.junit.Test
9
+ import java.net.URL
10
+
11
+ class ProxyRequestPolicyTest {
12
+
13
+ @Test
14
+ fun bodylessPostDoesNotWriteBodyOrManufactureContentType() {
15
+ val policy = proxyRequestBodyPolicy(emptyMap())
16
+
17
+ assertFalse(policy.shouldWriteBody)
18
+ assertNull(policy.contentType)
19
+ }
20
+
21
+ @Test
22
+ fun zeroLengthPostDoesNotWriteBodyOrManufactureContentType() {
23
+ val policy = proxyRequestBodyPolicy(
24
+ mapOf("content-length" to "0")
25
+ )
26
+
27
+ assertFalse(policy.shouldWriteBody)
28
+ assertNull(policy.contentType)
29
+ }
30
+
31
+ @Test
32
+ fun bodylessRequestOmitsEntityHeadersAndBody() {
33
+ val request = bodylessProxyRequestBytes(
34
+ "POST",
35
+ URL("https://example.com/api/auth/login"),
36
+ mapOf(
37
+ "authorization" to "Nostr event",
38
+ "content-type" to "",
39
+ "content-length" to "0",
40
+ "transfer-encoding" to "chunked"
41
+ )
42
+ ).toString(Charsets.ISO_8859_1)
43
+
44
+ assertTrue(request.startsWith("POST /api/auth/login HTTP/1.1\r\n"))
45
+ assertTrue(request.contains("authorization: Nostr event\r\n"))
46
+ assertFalse(request.contains("Content-Type", ignoreCase = true))
47
+ assertFalse(request.contains("Content-Length", ignoreCase = true))
48
+ assertFalse(request.contains("Transfer-Encoding", ignoreCase = true))
49
+ assertTrue(request.endsWith("Connection: close\r\n\r\n"))
50
+ }
51
+
52
+ @Test
53
+ fun jsonPostWritesBodyAndPreservesContentType() {
54
+ val policy = proxyRequestBodyPolicy(
55
+ mapOf(
56
+ "content-length" to "11",
57
+ "content-type" to "application/json"
58
+ )
59
+ )
60
+
61
+ assertTrue(policy.shouldWriteBody)
62
+ assertEquals("application/json", policy.contentType)
63
+ }
64
+
65
+ @Test
66
+ fun authorizationIsForwardedButTransferEncodingIsNot() {
67
+ assertTrue(shouldForwardProxyRequestHeader("authorization"))
68
+ assertFalse(shouldForwardProxyRequestHeader("transfer-encoding"))
69
+ }
70
+
71
+ @Test
72
+ fun transferEncodedBodyIsRejected() {
73
+ assertThrows(UnsupportedTransferEncodingException::class.java) {
74
+ proxyRequestBodyPolicy(
75
+ mapOf("transfer-encoding" to "chunked")
76
+ )
77
+ }
78
+ }
79
+
80
+ @Test
81
+ fun malformedContentLengthIsRejected() {
82
+ assertThrows(InvalidContentLengthException::class.java) {
83
+ proxyRequestBodyPolicy(
84
+ mapOf("content-length" to "invalid")
85
+ )
86
+ }
87
+ }
88
+
89
+ @Test
90
+ fun negativeContentLengthIsRejected() {
91
+ assertThrows(InvalidContentLengthException::class.java) {
92
+ proxyRequestBodyPolicy(
93
+ mapOf("content-length" to "-1")
94
+ )
95
+ }
96
+ }
97
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pakstr",
3
- "version": "0.19.2",
3
+ "version": "0.19.3",
4
4
  "description": "CLI for packaging Nostr web apps into Android APKs",
5
5
  "type": "commonjs",
6
6
  "main": "dist/index.js",