mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-05-05 15:57:01 +00:00
android, desktop: support link preview generation with socks (#6907)
* android, desktop: fix link previews bypassing SOCKS proxy getLinkPreview used Jsoup.connect() and URL.openStream() directly, bypassing the configured SOCKS proxy. Both the HTML fetch and image download now route through the proxy when one is configured. If the proxy address is misconfigured (unparseable port), the preview is cancelled and the user is alerted rather than falling back to a direct connection. When enabling SOCKS proxy with link previews active, or enabling link previews while SOCKS is active, the user is warned that DNS lookups may still occur locally and given the option to disable previews. Updates the SOCKS proxy limitations notice to clarify that calls cannot be proxied, and highlights it in warning colour. Note: DNS lookups may still occur locally before the SOCKS connection is established. Full SOCKS5h hostname forwarding is a separate follow-up. * android, desktop: fix SOCKS proxy parser, auth credentials, and repeated alert in link previews - Build proxy from typed NetworkProxy fields instead of parsing socksProxy string, fixing breakage on IPv6 hosts and USERNAME auth configurations - Register java.net.Authenticator for SOCKS5 credential negotiation (Java 21 SocksSocketImpl uses RequestorType.SERVER for this callback) - Remove per-keystroke invalid-proxy alert, which fired on every URL change for valid but unparseable proxy strings * ui: drop link preview SOCKS warnings and strings * ui: soften link preview alert when SOCKS is on Show the link previews opt-in alert in both SOCKS-on and SOCKS-off cases (previously skipped entirely when SOCKS was on). When SOCKS is on, use a softer description that mentions the proxy and the remaining local DNS lookup risk, and render the Disable button in primary colour instead of red. Also drop the link-previews caveat from the SOCKS limitations footer since previews now go through the proxy. * fix: harden socks proxy auth in link previews - Gate the SOCKS5 Authenticator on host:port match so destination 401 challenges no longer leak proxy credentials via the JDK auto-retry. - Snapshot Authenticator.getDefault() and restore in finally to stop leaking process-global state. - Mutex around getLinkPreview to serialize concurrent calls. - Generate a random UUID per call in ISOLATE mode for stream isolation. - Skip auth when USERNAME mode has empty username or password. * ui: shift red emphasis from Disable to Enable in link preview alert Disable is now always primary; Enable is red by default and primary when SOCKS is on. The dangerous action is enabling without proxy protection, not disabling. * ui: append SOCKS notice to link preview alert --------- Co-authored-by: iversonianGremling <24989959+iversonianGremling@users.noreply.github.com>
This commit is contained in:
+12
-7
@@ -419,9 +419,9 @@ fun ComposeView(
|
||||
withLongRunningApi(slow = 60_000) {
|
||||
if (wait != null) delay(wait)
|
||||
if (pendingLinkUrl.value != url) return@withLongRunningApi
|
||||
if (chatModel.controller.appPrefs.privacyLinkPreviewsShowAlert.get()
|
||||
&& !chatModel.controller.appPrefs.networkUseSocksProxy.get()) {
|
||||
showLinkPreviewsConfirmAlert { enable ->
|
||||
if (chatModel.controller.appPrefs.privacyLinkPreviewsShowAlert.get()) {
|
||||
val socksEnabled = chatModel.controller.appPrefs.networkUseSocksProxy.get()
|
||||
showLinkPreviewsConfirmAlert(socksEnabled) { enable ->
|
||||
if (enable != null) {
|
||||
chatModel.controller.appPrefs.privacyLinkPreviewsShowAlert.set(false)
|
||||
chatModel.controller.appPrefs.privacyLinkPreviews.set(enable)
|
||||
@@ -1703,10 +1703,15 @@ fun ComposeView(
|
||||
}
|
||||
}
|
||||
|
||||
private fun showLinkPreviewsConfirmAlert(onChoice: (Boolean?) -> Unit) {
|
||||
private fun showLinkPreviewsConfirmAlert(socksEnabled: Boolean, onChoice: (Boolean?) -> Unit) {
|
||||
AlertManager.shared.showAlertDialogButtonsColumn(
|
||||
title = generalGetString(MR.strings.link_previews_alert_title),
|
||||
text = AnnotatedString(generalGetString(MR.strings.link_previews_alert_desc)),
|
||||
text = AnnotatedString(
|
||||
if (socksEnabled)
|
||||
generalGetString(MR.strings.link_previews_alert_desc) + "\n\n" + generalGetString(MR.strings.link_previews_alert_desc_socks)
|
||||
else
|
||||
generalGetString(MR.strings.link_previews_alert_desc)
|
||||
),
|
||||
onDismissRequest = { onChoice(null) },
|
||||
buttons = {
|
||||
Column {
|
||||
@@ -1714,13 +1719,13 @@ private fun showLinkPreviewsConfirmAlert(onChoice: (Boolean?) -> Unit) {
|
||||
AlertManager.shared.hideAlert()
|
||||
onChoice(false)
|
||||
}) {
|
||||
Text(stringResource(MR.strings.link_previews_alert_disable), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
|
||||
Text(stringResource(MR.strings.link_previews_alert_disable), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
onChoice(true)
|
||||
}) {
|
||||
Text(stringResource(MR.strings.link_previews_alert_enable), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
|
||||
Text(stringResource(MR.strings.link_previews_alert_enable), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = if (socksEnabled) MaterialTheme.colors.primary else Color.Red)
|
||||
}
|
||||
// SectionItemView({
|
||||
// AlertManager.shared.hideAlert()
|
||||
|
||||
+101
-44
@@ -17,6 +17,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.model.LinkPreview
|
||||
import chat.simplex.common.model.NetworkProxyAuth
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.chat.chatViewScrollState
|
||||
@@ -24,67 +25,123 @@ import chat.simplex.common.views.chat.item.CHAT_IMAGE_LAYOUT_ID
|
||||
import chat.simplex.common.views.chat.item.imageViewFullWidth
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jsoup.Jsoup
|
||||
import java.net.Authenticator
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.PasswordAuthentication
|
||||
import java.net.Proxy
|
||||
import java.net.URL
|
||||
import java.util.UUID
|
||||
|
||||
private const val OG_SELECT_QUERY = "meta[property^=og:]"
|
||||
private const val ICON_SELECT_QUERY = "link[rel^=icon],link[rel^=apple-touch-icon],link[rel^=shortcut icon]"
|
||||
private val IMAGE_SUFFIXES = listOf(".jpg", ".png", ".ico", ".webp", ".gif")
|
||||
|
||||
// Authenticator.setDefault is process-global. The mutex serializes preview fetches
|
||||
// so concurrent calls cannot clobber each other's authenticator, and so the
|
||||
// snapshot/restore in getLinkPreview is race-free.
|
||||
private val previewMutex = Mutex()
|
||||
|
||||
suspend fun getLinkPreview(url: String): LinkPreview? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val title: String?
|
||||
val u = kotlin.runCatching { URL(url) }.getOrNull() ?: return@withContext null
|
||||
var imageUri = when {
|
||||
IMAGE_SUFFIXES.any { u.path.lowercase().endsWith(it) } -> {
|
||||
title = u.path.substringAfterLast("/")
|
||||
url
|
||||
}
|
||||
else -> {
|
||||
val connection = Jsoup.connect(url)
|
||||
.ignoreContentType(true)
|
||||
.timeout(10000)
|
||||
.followRedirects(true)
|
||||
|
||||
val response = if (url.lowercase().startsWith("https://x.com/")) {
|
||||
// Apple sends request with special user-agent which handled differently by X.com.
|
||||
// Different response that includes video poster from post
|
||||
connection
|
||||
.userAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_1) AppleWebKit/601.2.4 (KHTML, like Gecko) Version/9.0.1 Safari/601.2.4 facebookexternalhit/1.1 Facebot Twitterbot/1.0")
|
||||
.execute()
|
||||
} else {
|
||||
connection
|
||||
.execute()
|
||||
}
|
||||
val doc = response.parse()
|
||||
val ogTags = doc.select(OG_SELECT_QUERY)
|
||||
title = ogTags.firstOrNull { it.attr("property") == "og:title" }?.attr("content") ?: doc.title()
|
||||
ogTags.firstOrNull { it.attr("property") == "og:image" }?.attr("content")
|
||||
?: doc.select(ICON_SELECT_QUERY).firstOrNull { it.attr("rel").contains("icon") }?.attr("href")
|
||||
}
|
||||
}
|
||||
if (imageUri != null) {
|
||||
imageUri = normalizeImageUri(u, imageUri)
|
||||
previewMutex.withLock {
|
||||
val previousAuthenticator = Authenticator.getDefault()
|
||||
try {
|
||||
try {
|
||||
val stream = URL(imageUri).openStream()
|
||||
val image = resizeImageToStrSize(stream.use(::loadImageBitmap), maxDataSize = 14000)
|
||||
// TODO add once supported in iOS
|
||||
// val description = ogTags.firstOrNull {
|
||||
// it.attr("property") == "og:description"
|
||||
// }?.attr("content") ?: ""
|
||||
if (title != null) {
|
||||
return@withContext LinkPreview(url, title, description = "", image)
|
||||
val title: String?
|
||||
val u = kotlin.runCatching { URL(url) }.getOrNull() ?: return@withLock null
|
||||
val useSocksProxy = appPrefs.networkUseSocksProxy.get()
|
||||
val proxy: Proxy?
|
||||
if (useSocksProxy) {
|
||||
val networkProxy = appPrefs.networkProxy.get()
|
||||
proxy = Proxy(Proxy.Type.SOCKS, InetSocketAddress(networkProxy.host, networkProxy.port))
|
||||
val (authUser, authPass) = when (networkProxy.auth) {
|
||||
NetworkProxyAuth.USERNAME ->
|
||||
if (networkProxy.username.isNotEmpty() && networkProxy.password.isNotEmpty())
|
||||
networkProxy.username to networkProxy.password
|
||||
else
|
||||
null to null
|
||||
// Per-call random credentials drive Tor-style stream isolation: each
|
||||
// preview gets its own circuit, and previews don't share a circuit
|
||||
// with other unauthenticated traffic on the proxy.
|
||||
NetworkProxyAuth.ISOLATE ->
|
||||
UUID.randomUUID().toString() to UUID.randomUUID().toString()
|
||||
}
|
||||
if (authUser != null && authPass != null) {
|
||||
Authenticator.setDefault(object : Authenticator() {
|
||||
override fun getPasswordAuthentication(): PasswordAuthentication? =
|
||||
// Only respond when the SOCKS proxy itself challenges. A destination
|
||||
// server returning 401 also triggers RequestorType.SERVER; without
|
||||
// this gate, the JDK's auto-retry would post our SOCKS credentials
|
||||
// in an Authorization header to the destination.
|
||||
if (requestingHost == networkProxy.host && requestingPort == networkProxy.port)
|
||||
PasswordAuthentication(authUser, authPass.toCharArray())
|
||||
else null
|
||||
})
|
||||
} else {
|
||||
Authenticator.setDefault(null)
|
||||
}
|
||||
} else {
|
||||
proxy = null
|
||||
Authenticator.setDefault(null)
|
||||
}
|
||||
var imageUri = when {
|
||||
IMAGE_SUFFIXES.any { u.path.lowercase().endsWith(it) } -> {
|
||||
title = u.path.substringAfterLast("/")
|
||||
url
|
||||
}
|
||||
else -> {
|
||||
val connection = Jsoup.connect(url)
|
||||
.ignoreContentType(true)
|
||||
.timeout(10000)
|
||||
.followRedirects(true)
|
||||
.proxy(proxy)
|
||||
|
||||
val response = if (url.lowercase().startsWith("https://x.com/")) {
|
||||
// Apple sends request with special user-agent which handled differently by X.com.
|
||||
// Different response that includes video poster from post
|
||||
connection
|
||||
.userAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_1) AppleWebKit/601.2.4 (KHTML, like Gecko) Version/9.0.1 Safari/601.2.4 facebookexternalhit/1.1 Facebot Twitterbot/1.0")
|
||||
.execute()
|
||||
} else {
|
||||
connection
|
||||
.execute()
|
||||
}
|
||||
val doc = response.parse()
|
||||
val ogTags = doc.select(OG_SELECT_QUERY)
|
||||
title = ogTags.firstOrNull { it.attr("property") == "og:title" }?.attr("content") ?: doc.title()
|
||||
ogTags.firstOrNull { it.attr("property") == "og:image" }?.attr("content")
|
||||
?: doc.select(ICON_SELECT_QUERY).firstOrNull { it.attr("rel").contains("icon") }?.attr("href")
|
||||
}
|
||||
}
|
||||
if (imageUri != null) {
|
||||
imageUri = normalizeImageUri(u, imageUri)
|
||||
try {
|
||||
val conn = URL(imageUri).openConnection(proxy ?: Proxy.NO_PROXY)
|
||||
val stream = conn.getInputStream()
|
||||
val image = resizeImageToStrSize(stream.use(::loadImageBitmap), maxDataSize = 14000)
|
||||
// TODO add once supported in iOS
|
||||
// val description = ogTags.firstOrNull {
|
||||
// it.attr("property") == "og:description"
|
||||
// }?.attr("content") ?: ""
|
||||
if (title != null) {
|
||||
return@withLock LinkPreview(url, title, description = "", image)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
return@withLock null
|
||||
} finally {
|
||||
Authenticator.setDefault(previousAuthenticator)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
return@withContext null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1067,7 +1067,7 @@
|
||||
<string name="network_session_mode_entity_description"><![CDATA[A separate TCP connection (and SOCKS credential) will be used <b>for each contact and group member</b>.\n<b>Please note</b>: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail.]]></string>
|
||||
<string name="update_network_session_mode_question">Update transport isolation mode?</string>
|
||||
<string name="disable_onion_hosts_when_not_supported"><![CDATA[Set <i>Use .onion hosts</i> to No if SOCKS proxy does not support them.]]></string>
|
||||
<string name="socks_proxy_setting_limitations"><![CDATA[<b>Please note</b>: message and file relays are connected via SOCKS proxy. Calls and sending link previews use direct connection.]]></string>
|
||||
<string name="socks_proxy_setting_limitations"><![CDATA[<b>Please note</b>: message and file relays are connected via SOCKS proxy. Calls use direct connection.]]></string>
|
||||
<string name="network_smp_proxy_mode_private_routing">Private routing</string>
|
||||
<string name="network_smp_proxy_mode_always">Always</string>
|
||||
<string name="network_smp_proxy_mode_unknown">Unknown servers</string>
|
||||
@@ -3061,6 +3061,7 @@
|
||||
<!-- ComposeView.kt link previews opt-in alert -->
|
||||
<string name="link_previews_alert_title">Enable link previews?</string>
|
||||
<string name="link_previews_alert_desc">Sending a link preview may reveal your IP address to the website. You can change this in Privacy settings later.</string>
|
||||
<string name="link_previews_alert_desc_socks">Link preview will be requested via SOCKS proxy. DNS lookup may still happen locally via your DNS resolver.</string>
|
||||
<string name="link_previews_alert_enable">Enable</string>
|
||||
<string name="link_previews_alert_disable">Disable</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user