Skip to content

Commit def0a55

Browse files
committed
Improve download security and refine rate limit parsing
- Enhanced file name sanitization in `AndroidDownloader` and `DesktopDownloader` to strip URL query parameters/fragments and prevent path traversal. - Updated `AndroidDownloader` and `DesktopDownloader` to use `response.use { ... }` blocks for better resource management. - Refactored `RateLimitInterceptor` to provide more detailed error logging for malformed rate limit headers. - Simplified `Authenticator` calls in `HttpClientFactory.android.kt` by using explicit imports. - Ensured `Authenticator.setDefault(null)` is called when building OkHttpClient to avoid credential leaks.
1 parent a6cc8f1 commit def0a55

4 files changed

Lines changed: 105 additions & 66 deletions

File tree

core/data/src/androidMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.android.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@ import java.net.InetSocketAddress
66
import java.net.Proxy
77
import okhttp3.Credentials
88
import zed.rainxch.core.domain.model.ProxyConfig
9+
import java.net.Authenticator
910
import java.net.PasswordAuthentication
1011
import java.net.ProxySelector
1112

1213
actual fun createPlatformHttpClient(proxyConfig: ProxyConfig): HttpClient {
13-
java.net.Authenticator.setDefault(null)
14+
Authenticator.setDefault(null)
1415

1516
return HttpClient(OkHttp) {
1617
engine {
@@ -54,7 +55,7 @@ actual fun createPlatformHttpClient(proxyConfig: ProxyConfig): HttpClient {
5455
)
5556

5657
if (proxyConfig.username != null) {
57-
java.net.Authenticator.setDefault(object : java.net.Authenticator() {
58+
Authenticator.setDefault(object : Authenticator() {
5859
override fun getPasswordAuthentication(): PasswordAuthentication? {
5960
if (requestingHost == proxyConfig.host &&
6061
requestingPort == proxyConfig.port

core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidDownloader.kt

Lines changed: 52 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -30,20 +30,26 @@ class AndroidDownloader(
3030
private val activeDownloads = ConcurrentHashMap<String, Call>()
3131

3232
private fun buildClient(): OkHttpClient {
33+
Authenticator.setDefault(null)
34+
3335
return OkHttpClient.Builder().apply {
3436
when (val config = proxyManager.currentProxyConfig.value) {
3537
is ProxyConfig.None -> proxy(Proxy.NO_PROXY)
36-
is ProxyConfig.System -> { }
38+
is ProxyConfig.System -> {}
3739
is ProxyConfig.Http -> {
3840
proxy(Proxy(Proxy.Type.HTTP, InetSocketAddress(config.host, config.port)))
3941
if (config.username != null && config.password != null) {
4042
proxyAuthenticator { _, response ->
4143
response.request.newBuilder()
42-
.header("Proxy-Authorization", Credentials.basic(config.username!!, config.password!!))
44+
.header(
45+
"Proxy-Authorization",
46+
Credentials.basic(config.username!!, config.password!!)
47+
)
4348
.build()
4449
}
4550
}
4651
}
52+
4753
is ProxyConfig.Socks -> {
4854
proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress(config.host, config.port)))
4955
if (config.username != null && config.password != null) {
@@ -67,8 +73,13 @@ class AndroidDownloader(
6773
val dir = File(dirPath)
6874
if (!dir.exists()) dir.mkdirs()
6975

70-
val safeName = (suggestedFileName?.takeIf { it.isNotBlank() }
71-
?: url.substringAfterLast('/').ifBlank { "asset-${UUID.randomUUID()}.apk" })
76+
val rawName = suggestedFileName?.takeIf { it.isNotBlank() }
77+
?: url.substringAfterLast('/').substringBefore('?').substringBefore('#')
78+
.ifBlank { "asset-${UUID.randomUUID()}.apk" }
79+
val safeName = rawName.substringAfterLast('/').substringAfterLast('\\')
80+
require(safeName.isNotBlank() && safeName != "." && safeName != "..") {
81+
"Invalid file name: $rawName"
82+
}
7283

7384
val destination = File(dir, safeName)
7485

@@ -87,37 +98,41 @@ class AndroidDownloader(
8798
activeDownloads[safeName] = call
8899

89100
try {
90-
val response = call.execute()
91-
if (!response.isSuccessful) {
92-
throw kotlinx.io.IOException("Unexpected code ${response.code}")
93-
}
101+
call.execute().use { response ->
102+
if (!response.isSuccessful) {
103+
throw kotlinx.io.IOException("Unexpected code ${response.code}")
104+
}
94105

95-
val body = response.body
96-
val contentLength = body.contentLength()
97-
val total = if (contentLength > 0) contentLength else null
98-
99-
body.byteStream().use { input ->
100-
destination.outputStream().use { output ->
101-
val buffer = ByteArray(8192)
102-
var downloaded: Long = 0
103-
var bytesRead: Int
104-
while (input.read(buffer).also { bytesRead = it } != -1) {
105-
output.write(buffer, 0, bytesRead)
106-
downloaded += bytesRead
107-
val percent = if (total != null) ((downloaded * 100L) / total).toInt() else null
108-
emit(DownloadProgress(downloaded, total, percent))
106+
val body = response.body
107+
val contentLength = body.contentLength()
108+
val total = if (contentLength > 0) contentLength else null
109+
110+
body.byteStream().use { input ->
111+
destination.outputStream().use { output ->
112+
val buffer = ByteArray(8192)
113+
var downloaded: Long = 0
114+
var bytesRead: Int
115+
while (input.read(buffer).also { bytesRead = it } != -1) {
116+
output.write(buffer, 0, bytesRead)
117+
downloaded += bytesRead
118+
val percent =
119+
if (total != null) ((downloaded * 100L) / total).toInt() else null
120+
emit(DownloadProgress(downloaded, total, percent))
121+
}
109122
}
110123
}
111-
}
112124

113-
if (destination.exists() && destination.length() > 0) {
114-
Logger.d { "Download complete: ${destination.absolutePath}" }
115-
val finalDownloaded = destination.length()
116-
val finalPercent = if (total != null) ((finalDownloaded * 100L) / total).toInt() else 100
117-
emit(DownloadProgress(finalDownloaded, total, finalPercent))
118-
} else {
119-
throw IllegalStateException("File not ready after download: ${destination.absolutePath}")
125+
if (destination.exists() && destination.length() > 0) {
126+
Logger.d { "Download complete: ${destination.absolutePath}" }
127+
val finalDownloaded = destination.length()
128+
val finalPercent =
129+
if (total != null) ((finalDownloaded * 100L) / total).toInt() else 100
130+
emit(DownloadProgress(finalDownloaded, total, finalPercent))
131+
} else {
132+
throw IllegalStateException("File not ready after download: ${destination.absolutePath}")
133+
}
120134
}
135+
121136
} catch (e: Exception) {
122137
destination.delete()
123138
Logger.e(e) { "Download failed" }
@@ -129,8 +144,13 @@ class AndroidDownloader(
129144

130145
override suspend fun saveToFile(url: String, suggestedFileName: String?): String =
131146
withContext(Dispatchers.IO) {
132-
val safeName = (suggestedFileName?.takeIf { it.isNotBlank() }
133-
?: url.substringAfterLast('/').ifBlank { "asset-${UUID.randomUUID()}.apk" })
147+
val rawName = suggestedFileName?.takeIf { it.isNotBlank() }
148+
?: url.substringAfterLast('/').substringBefore('?').substringBefore('#')
149+
.ifBlank { "asset-${UUID.randomUUID()}.apk" }
150+
val safeName = rawName.substringAfterLast('/').substringAfterLast('\\')
151+
require(safeName.isNotBlank() && safeName != "." && safeName != "..") {
152+
"Invalid file name: $rawName"
153+
}
134154

135155
val file = File(files.appDownloadsDir(), safeName)
136156

core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/interceptor/RateLimitInterceptor.kt

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import zed.rainxch.core.domain.repository.RateLimitRepository
1616
class RateLimitInterceptor(
1717
private val rateLimitRepository: RateLimitRepository,
1818
) {
19-
19+
2020
class Config {
2121
var rateLimitRepository: RateLimitRepository? = null
2222
}
@@ -52,9 +52,18 @@ class RateLimitInterceptor(
5252

5353
private fun parseRateLimitFromHeaders(headers: Headers): RateLimitInfo? {
5454
return try {
55-
val limit = headers["X-RateLimit-Limit"]?.toIntOrNull() ?: return null.also { Logger.w { "Missing X-RateLimit-Limit" } }
56-
val remaining = headers["X-RateLimit-Remaining"]?.toIntOrNull() ?: return null.also { Logger.w { "Missing X-RateLimit-Remaining" } }
57-
val reset = headers["X-RateLimit-Reset"]?.toLongOrNull() ?: return null.also { Logger.w { "Missing X-RateLimit-Reset" } }
55+
val limitHeader = headers["X-RateLimit-Limit"]
56+
?: return null.also { Logger.w { "Missing X-RateLimit-Limit" } }
57+
val limit = limitHeader.toIntOrNull()
58+
?: return null.also { Logger.w { "Malformed X-RateLimit-Limit: $limitHeader" } }
59+
val remainingHeader = headers["X-RateLimit-Remaining"]
60+
?: return null.also { Logger.w { "Missing X-RateLimit-Remaining" } }
61+
val remaining = remainingHeader.toIntOrNull()
62+
?: return null.also { Logger.w { "Malformed X-RateLimit-Remaining: $remainingHeader" } }
63+
val resetHeader = headers["X-RateLimit-Reset"]
64+
?: return null.also { Logger.w { "Missing X-RateLimit-Reset" } }
65+
val reset = resetHeader.toLongOrNull()
66+
?: return null.also { Logger.w { "Malformed X-RateLimit-Reset: $resetHeader" } }
5867
val resource = headers["X-RateLimit-Resource"] ?: "core"
5968

6069
RateLimitInfo(limit, remaining, reset, resource)

core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopDownloader.kt

Lines changed: 37 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -30,20 +30,26 @@ class DesktopDownloader(
3030
private val activeDownloads = ConcurrentHashMap<String, Call>()
3131

3232
private fun buildClient(): OkHttpClient {
33+
Authenticator.setDefault(null)
34+
3335
return OkHttpClient.Builder().apply {
3436
when (val config = proxyManager.currentProxyConfig.value) {
3537
is ProxyConfig.None -> proxy(Proxy.NO_PROXY)
36-
is ProxyConfig.System -> { }
38+
is ProxyConfig.System -> {}
3739
is ProxyConfig.Http -> {
3840
proxy(Proxy(Proxy.Type.HTTP, InetSocketAddress(config.host, config.port)))
3941
if (config.username != null && config.password != null) {
4042
proxyAuthenticator { _, response ->
4143
response.request.newBuilder()
42-
.header("Proxy-Authorization", Credentials.basic(config.username!!, config.password!!))
44+
.header(
45+
"Proxy-Authorization",
46+
Credentials.basic(config.username!!, config.password!!)
47+
)
4348
.build()
4449
}
4550
}
4651
}
52+
4753
is ProxyConfig.Socks -> {
4854
proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress(config.host, config.port)))
4955
if (config.username != null && config.password != null) {
@@ -91,36 +97,39 @@ class DesktopDownloader(
9197
activeDownloads[safeName] = call
9298

9399
try {
94-
val response = call.execute()
95-
if (!response.isSuccessful) {
96-
throw kotlinx.io.IOException("Unexpected code ${response.code}")
97-
}
100+
call.execute().use { response ->
101+
if (!response.isSuccessful) {
102+
throw kotlinx.io.IOException("Unexpected code ${response.code}")
103+
}
98104

99-
val body = response.body
100-
val contentLength = body.contentLength()
101-
val total = if (contentLength > 0) contentLength else null
102-
103-
body.byteStream().use { input ->
104-
destination.outputStream().use { output ->
105-
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
106-
var downloaded: Long = 0
107-
var bytesRead: Int
108-
while (input.read(buffer).also { bytesRead = it } != -1) {
109-
output.write(buffer, 0, bytesRead)
110-
downloaded += bytesRead
111-
val percent = if (total != null) ((downloaded * 100L) / total).toInt() else null
112-
emit(DownloadProgress(downloaded, total, percent))
105+
val body = response.body
106+
val contentLength = body.contentLength()
107+
val total = if (contentLength > 0) contentLength else null
108+
109+
body.byteStream().use { input ->
110+
destination.outputStream().use { output ->
111+
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
112+
var downloaded: Long = 0
113+
var bytesRead: Int
114+
while (input.read(buffer).also { bytesRead = it } != -1) {
115+
output.write(buffer, 0, bytesRead)
116+
downloaded += bytesRead
117+
val percent =
118+
if (total != null) ((downloaded * 100L) / total).toInt() else null
119+
emit(DownloadProgress(downloaded, total, percent))
120+
}
113121
}
114122
}
115-
}
116123

117-
if (destination.exists() && destination.length() > 0) {
118-
Logger.d { "Download complete: ${destination.absolutePath}" }
119-
val finalDownloaded = destination.length()
120-
val finalPercent = if (total != null) ((finalDownloaded * 100L) / total).toInt() else 100
121-
emit(DownloadProgress(finalDownloaded, total, finalPercent))
122-
} else {
123-
throw IllegalStateException("File not ready after download: ${destination.absolutePath}")
124+
if (destination.exists() && destination.length() > 0) {
125+
Logger.d { "Download complete: ${destination.absolutePath}" }
126+
val finalDownloaded = destination.length()
127+
val finalPercent =
128+
if (total != null) ((finalDownloaded * 100L) / total).toInt() else 100
129+
emit(DownloadProgress(finalDownloaded, total, finalPercent))
130+
} else {
131+
throw IllegalStateException("File not ready after download: ${destination.absolutePath}")
132+
}
124133
}
125134
} catch (e: Exception) {
126135
destination.delete()

0 commit comments

Comments
 (0)