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.
- package/android-template/app/src/androidTest/java/com/pakstr/app/Nip98ProxyOriginTest.kt +208 -0
- package/android-template/app/src/main/java/com/pakstr/app/LocalServer.kt +268 -25
- package/android-template/app/src/main/java/com/pakstr/app/Nip98ProxyOrigin.kt +90 -0
- package/android-template/app/src/test/java/com/pakstr/app/ProxyRequestPolicyTest.kt +97 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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
|
+
}
|