feat: default backend none [WPB-26206] [WPB-26586] [WPB-26624]#5023
feat: default backend none [WPB-26206] [WPB-26586] [WPB-26624]#5023Garzas wants to merge 4 commits into
Conversation
Ups 🫰🟨This PR is too big. Please try to break it up into smaller PRs. |
| @Suppress("TooGenericExceptionCaught") | ||
| private suspend fun fetchSupportEmail(configUrl: String): String? = withContext(Dispatchers.IO) { | ||
| try { | ||
| val connection = URL(configUrl).openConnection() as HttpURLConnection |
There was a problem hiding this comment.
Server-Side Request Forgery occur when a web server executes a request to a user supplied
destination parameter that is not validated. Such vulnerabilities could allow an attacker to
access internal services or to launch attacks from your web server.
🧹 Fixed in commit c50e613 🧹
| @Suppress("TooGenericExceptionCaught") | ||
| private suspend fun fetchSupportEmail(configUrl: String): String? = withContext(Dispatchers.IO) { | ||
| try { | ||
| val connection = URL(configUrl).openConnection() as HttpURLConnection |
There was a problem hiding this comment.
Semgrep identified a blocking 🔴 issue in your code:
Unvalidated configUrl parameter allows Server-Side Request Forgery (SSRF) attacks via URL.openConnection(), enabling attackers to target internal services or cloud metadata endpoints.
More details about this
The code calls URL(configUrl).openConnection() to fetch configuration data from configUrl without validating that the URL is safe. An attacker can exploit this by supplying a malicious URL through the configUrl parameter—for example, pointing to internal services like http://localhost:8080/admin or http://192.168.1.1:27017 (a MongoDB instance)—to access resources that should be restricted to the server's internal network.
Here's how the attack would work:
- An attacker calls
storeFromConfigUrl()with a maliciousconfigUrllikehttp://169.254.169.254/latest/meta-data/iam/security-credentials/(AWS metadata service). - The
fetchSupportEmail()function receives this untrusted URL and creates aURLobject from it:URL(configUrl). - The
.openConnection()method makes a real HTTP request to that attacker-supplied address from the server's context. - The server connects to the internal AWS metadata service and returns credentials or sensitive environment data, which the attacker then receives in the response and parses as JSON.
Since the application trusts the configUrl parameter without validation, it becomes a bridge for the attacker to reach internal or restricted services that the server can access but external attackers normally cannot.
To resolve this comment:
✨ Commit fix suggestion
| val connection = URL(configUrl).openConnection() as HttpURLConnection | |
| private data class PredefinedBackend( | |
| val name: String, | |
| val configUrl: String | |
| ) | |
| private val predefinedBackends = listOf( | |
| // Only hardcoded, trusted config endpoints may be requested here. | |
| // Add any additional approved backends to this allowlist. | |
| PredefinedBackend( | |
| name = "Production", | |
| configUrl = "https://example.com/deeplink.json" | |
| ) | |
| ) | |
| suspend fun storeFromConfigUrl( | |
| globalDataStore: GlobalDataStore, | |
| serverLinks: ServerConfig.Links, | |
| configUrl: String | |
| ) { | |
| setCurrentBackend(serverLinks) | |
| globalDataStore.setBackendSupportEmail( | |
| backendApiUrl = serverLinks.api, | |
| supportEmail = fetchSupportEmail(configUrl) | |
| ) | |
| } | |
| @Suppress("ReturnCount") | |
| suspend fun resolveEmail(context: Context, staticSupportEmail: String): String? { | |
| val staticEmail = staticSupportEmail.trim() | |
| if (staticEmail.isNotBlank()) return staticEmail | |
| val backendApiUrl = currentBackendApiUrl ?: return null | |
| return GlobalDataStore(context.applicationContext).getBackendSupportEmail(backendApiUrl) | |
| } | |
| fun supportPageIntent(): Intent? = | |
| CustomTabsHelper.resolveUrl("")?.let { | |
| Intent(Intent.ACTION_VIEW, Uri.parse(it)) | |
| } | |
| @Suppress("TooGenericExceptionCaught") | |
| private suspend fun fetchSupportEmail(configUrl: String): String? = withContext(Dispatchers.IO) { | |
| try { | |
| val trustedConfigUrl = predefinedBackends | |
| .firstOrNull { it.configUrl == configUrl } | |
| ?.configUrl | |
| if (trustedConfigUrl == null) { | |
| appLogger.w("Rejected untrusted backend config URL: $configUrl") | |
| return@withContext null | |
| } | |
| val connection = URL(trustedConfigUrl).openConnection() as HttpURLConnection | |
| connection.connectTimeout = CONNECT_TIMEOUT_MILLIS | |
| connection.readTimeout = READ_TIMEOUT_MILLIS | |
| try { | |
| connection.inputStream.bufferedReader().use { reader -> | |
| json.decodeFromString(SupportConfig.serializer(), reader.readText()).supportEmail | |
| ?.trim() | |
| ?.takeIf { it.isNotBlank() } | |
| } | |
| } finally { | |
| connection.disconnect() | |
| } | |
| } catch (exception: Exception) { | |
| appLogger.w("Failed to read backend support email from config", exception) | |
| null | |
| } | |
| } | |
| @Serializable | |
| private data class SupportConfig( | |
| @SerialName("supportEmail") | |
| val supportEmail: String? = null | |
| ) | |
| } |
View step-by-step instructions
-
Stop passing a raw
StringintoURL(configUrl).openConnection(). ReplaceconfigUrl: Stringwith a typed backend value that distinguishes trusted predefined URLs from non-network options, for exampleBackendConfig.Predefined,BackendConfig.Custom, andBackendConfig.NoBackend. -
Move the allowed config URLs into a fixed in-code list of predefined backends instead of accepting an arbitrary URL string. For example, store values like
BackendConfig.Predefined("Production", "https://example.com/deeplink.json"). -
Update the call site so it only calls
fetchSupportEmail(...)for a trusted predefined backend URL. For example, branch withwhen (backend) { is BackendConfig.Predefined -> fetchSupportEmail(backend.configUrl); BackendConfig.Custom, BackendConfig.NoBackend -> null }. -
Change
storeFromConfigUrl(...)to accept the typed backend object, or accept a validated predefined URL only, so untrusted user input cannot reach the network request. This prevents SSRF by making the request target come from a hardcoded allowlist instead of user-controlled data. -
Keep the dropdown or selection UI mapped to backend objects, not
(name, url)string pairs, so the selected value cannot be swapped with an arbitrary URL. For example, useitems = backendConfigs.map { it.name }and resolve the selected entry withwhen (val backend = backendConfigs[index]) { ... }. -
Alternatively, if users must be allowed to enter a custom backend URL, validate it against a strict allowlist before opening the connection. Parse it with
val uri = URI(configUrl)and reject it unless the scheme ishttpsand the host is one of a small set of approved domains such asexample.com.
💬 Ignore this finding
Reply with Semgrep commands to ignore this finding.
/fp <comment>for false positive/ar <comment>for acceptable risk/other <comment>for all other reasons
Alternatively, triage in Semgrep AppSec Platform to ignore the finding created by URLCONNECTION_SSRF_FD-1.
You can view more details about this finding in the Semgrep AppSec Platform.
|



https://wearezeta.atlassian.net/browse/WPB-26206
PR Submission Checklist for internal contributors
The PR Title
SQPIT-764The PR Description
What's new in this PR?
Adds support for app builds that do not ship with a preconfigured default backend. When no backend is configured, the app shows a setup screen and waits for backend configuration from supported configuration sources.
Changes
default_backend_enabledconfig flag.websiteURL/support.supportEmailfrom the configuration payload without DB migration.feedback_menu_item_enabledflag to hide the generic feedback menu item.Validation