diff --git a/app/src/main/java/com/nextcloud/client/di/ComponentsModule.kt b/app/src/main/java/com/nextcloud/client/di/ComponentsModule.kt index 9612adc09060..cdc0afed1408 100644 --- a/app/src/main/java/com/nextcloud/client/di/ComponentsModule.kt +++ b/app/src/main/java/com/nextcloud/client/di/ComponentsModule.kt @@ -30,6 +30,7 @@ import com.nextcloud.client.widget.DashboardWidgetService import com.nextcloud.ui.albumItemActions.AlbumItemActionsBottomSheet import com.nextcloud.ui.composeActivity.ComposeActivity import com.nextcloud.ui.dialog.account.ChooseAccountDialogFragment +import com.nextcloud.ui.dialog.account.CustomHeadersDialogFragment import com.nextcloud.ui.dialog.account.onlineStatus.SetOnlineStatusBottomSheet import com.nextcloud.ui.dialog.account.statusMessage.SetStatusMessageBottomSheet import com.nextcloud.ui.dialog.chooseStorage.ChooseStorageLocationDialogFragment @@ -529,4 +530,7 @@ internal interface ComponentsModule { @ContributesAndroidInjector fun albumSharingBottomSheet(): AlbumSharingBottomSheet + + @ContributesAndroidInjector + fun customHeadersDialogFragment(): CustomHeadersDialogFragment } diff --git a/app/src/main/java/com/nextcloud/client/jobs/AccountRemovalWork.kt b/app/src/main/java/com/nextcloud/client/jobs/AccountRemovalWork.kt index e657de816236..9fc0b3a3c516 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/AccountRemovalWork.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/AccountRemovalWork.kt @@ -22,6 +22,7 @@ import com.nextcloud.client.account.UserAccountManager import com.nextcloud.client.core.Clock import com.nextcloud.client.preferences.AppPreferences import com.nextcloud.common.NextcloudClient +import com.nextcloud.utils.headers.AccountCustomHeaders import com.owncloud.android.MainApp import com.owncloud.android.R import com.owncloud.android.datamodel.ArbitraryDataProvider @@ -137,6 +138,7 @@ class AccountRemovalWork( // delete cached OwncloudClient OwnCloudClientManagerFactory.getDefaultSingleton().removeClientFor(user.toOwnCloudAccount()) + AccountCustomHeaders.reconcile(context) if (userRemoved) { eventBus.post(AccountRemovedEvent()) diff --git a/app/src/main/java/com/nextcloud/ui/dialog/account/CustomHeadersDialogFragment.kt b/app/src/main/java/com/nextcloud/ui/dialog/account/CustomHeadersDialogFragment.kt new file mode 100644 index 000000000000..669110a837db --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/dialog/account/CustomHeadersDialogFragment.kt @@ -0,0 +1,108 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.ui.dialog.account + +import android.app.Dialog +import android.os.Bundle +import androidx.appcompat.app.AlertDialog +import androidx.core.os.bundleOf +import androidx.core.widget.doAfterTextChanged +import androidx.fragment.app.DialogFragment +import com.google.android.material.button.MaterialButton +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.nextcloud.client.account.User +import com.nextcloud.client.di.Injectable +import com.nextcloud.utils.extensions.getParcelableArgument +import com.nextcloud.utils.headers.AccountCustomHeaders +import com.nextcloud.utils.headers.CustomHeadersParseResult +import com.nextcloud.utils.headers.CustomHeadersParser +import com.owncloud.android.R +import com.owncloud.android.databinding.CustomHeadersDialogBinding +import com.owncloud.android.utils.theme.ViewThemeUtils +import javax.inject.Inject + +/** + * Edits the custom headers of an existing account, for example after a token rotation or when the server was moved + * behind a pre-authentication proxy. + */ +class CustomHeadersDialogFragment : + DialogFragment(), + Injectable { + + @Inject + lateinit var viewThemeUtils: ViewThemeUtils + + private lateinit var binding: CustomHeadersDialogBinding + private lateinit var user: User + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + user = requireNotNull(arguments.getParcelableArgument(ARG_USER, User::class.java)) + binding = CustomHeadersDialogBinding.inflate(requireActivity().layoutInflater, null, false) + viewThemeUtils.material.colorTextInputLayout(binding.customHeadersContainer) + + if (savedInstanceState == null) { + val stored = AccountCustomHeaders.read(requireContext(), user.toPlatformAccount()) + binding.customHeadersInput.setText(CustomHeadersParser.format(stored)) + } + binding.customHeadersInput.doAfterTextChanged { validate() } + + val builder = MaterialAlertDialogBuilder(requireActivity()) + .setTitle(R.string.custom_headers_edit) + .setMessage(R.string.custom_headers_edit_message) + .setView(binding.root) + .setPositiveButton(R.string.common_save) { _, _ -> save() } + .setNegativeButton(R.string.common_cancel, null) + viewThemeUtils.dialog.colorMaterialAlertDialogBackground(binding.customHeadersContainer.context, builder) + return builder.create() + } + + override fun onStart() { + super.onStart() + val dialog = dialog as? AlertDialog ?: return + (dialog.getButton(AlertDialog.BUTTON_POSITIVE) as? MaterialButton)?.let { + viewThemeUtils.material.colorMaterialButtonPrimaryTonal(it) + } + (dialog.getButton(AlertDialog.BUTTON_NEGATIVE) as? MaterialButton)?.let { + viewThemeUtils.material.colorMaterialButtonPrimaryBorderless(it) + } + validate() + } + + private fun validate() { + val error = when (val parsed = parse()) { + is CustomHeadersParseResult.Invalid -> getString(R.string.custom_headers_invalid, parsed.lineNumber) + + is CustomHeadersParseResult.Valid -> getString(R.string.custom_headers_https_only) + .takeIf { parsed.headers.isNotEmpty() && !isHttps() } + } + binding.customHeadersContainer.error = error + (dialog as? AlertDialog)?.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = error == null + } + + private fun save() { + val parsed = parse() as? CustomHeadersParseResult.Valid ?: return + if (parsed.headers.isNotEmpty() && !isHttps()) { + return + } + AccountCustomHeaders.replace(requireContext(), user.toPlatformAccount(), parsed.headers) + } + + private fun parse(): CustomHeadersParseResult = + CustomHeadersParser.parse(binding.customHeadersInput.text?.toString().orEmpty()) + + private fun isHttps(): Boolean = user.server.uri.scheme.equals(HTTPS, ignoreCase = true) + + companion object { + const val TAG = "CustomHeadersDialogFragment" + private const val ARG_USER = "user" + private const val HTTPS = "https" + + @JvmStatic + fun newInstance(user: User): CustomHeadersDialogFragment = + CustomHeadersDialogFragment().apply { arguments = bundleOf(ARG_USER to user) } + } +} diff --git a/app/src/main/java/com/nextcloud/utils/headers/AccountCustomHeaders.kt b/app/src/main/java/com/nextcloud/utils/headers/AccountCustomHeaders.kt new file mode 100644 index 000000000000..598b5c65a6cf --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/headers/AccountCustomHeaders.kt @@ -0,0 +1,63 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.utils.headers + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.Context +import androidx.core.net.toUri +import com.nextcloud.common.CustomHeaders +import com.owncloud.android.MainApp +import com.owncloud.android.lib.common.accounts.AccountUtils +import com.owncloud.android.lib.common.utils.Log_OC + +/** + * Custom headers are stored per account and registered for the origin of the account at app start. + */ +object AccountCustomHeaders { + private const val TAG = "AccountCustomHeaders" + private const val KEY_CUSTOM_HEADERS = "oc_custom_headers" + + @JvmStatic + fun registerAll(context: Context) { + val accountManager = AccountManager.get(context) + accountManager.getAccountsByType(MainApp.getAccountType(context)).forEach { register(accountManager, it) } + } + + /** + * Drops the headers of removed accounts and keeps the ones of the remaining accounts. + */ + @JvmStatic + fun reconcile(context: Context) { + CustomHeaders.origins.forEach { CustomHeaders.unregister(it) } + registerAll(context) + } + + fun store(context: Context, account: Account, json: String) { + AccountManager.get(context).setUserData(account, KEY_CUSTOM_HEADERS, json) + } + + fun read(context: Context, account: Account): Map = + CustomHeaders.fromJson(AccountManager.get(context).getUserData(account, KEY_CUSTOM_HEADERS)).orEmpty() + + /** + * Empty headers remove the stored ones. Takes effect for every following request. + */ + fun replace(context: Context, account: Account, headers: Map) { + val json = headers.takeIf { it.isNotEmpty() }?.let { CustomHeaders.toJson(it) } + AccountManager.get(context).setUserData(account, KEY_CUSTOM_HEADERS, json) + reconcile(context) + } + + private fun register(accountManager: AccountManager, account: Account) { + val headers = CustomHeaders.fromJson(accountManager.getUserData(account, KEY_CUSTOM_HEADERS)) ?: return + val baseUri = accountManager.getUserData(account, AccountUtils.Constants.KEY_OC_BASE_URL)?.toUri() + if (!CustomHeaders.register(baseUri, headers)) { + Log_OC.e(TAG, "custom headers of an account are invalid and will be ignored") + } + } +} diff --git a/app/src/main/java/com/nextcloud/utils/headers/CustomHeadersHttpClient.kt b/app/src/main/java/com/nextcloud/utils/headers/CustomHeadersHttpClient.kt new file mode 100644 index 000000000000..693cdc417676 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/headers/CustomHeadersHttpClient.kt @@ -0,0 +1,69 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.utils.headers + +import android.content.Context +import com.nextcloud.common.PlainClient +import com.owncloud.android.lib.common.network.NetworkUtils +import com.owncloud.android.lib.common.utils.Log_OC +import okhttp3.OkHttpClient +import java.io.IOException +import java.security.GeneralSecurityException +import javax.net.ssl.HostnameVerifier +import javax.net.ssl.HttpsURLConnection +import javax.net.ssl.SSLSession + +/** + * Library client for requests that the app does not send through a library client itself (web views, Glide). + */ +object CustomHeadersHttpClient { + private const val TAG = "CustomHeadersHttpClient" + + @Volatile + private var client: OkHttpClient? = null + + @Volatile + private var clientWithoutRedirects: OkHttpClient? = null + + /** + * The library client accepts any host name. Web views reject a mismatching certificate unless the user + * accepted it, so this transport does the same before it sends custom headers. + */ + fun get(context: Context): OkHttpClient = client ?: synchronized(this) { + val appContext = context.applicationContext + client ?: PlainClient(appContext).client.newBuilder() + .hostnameVerifier(hostnameVerifier(appContext)) + .build() + .also { client = it } + } + + /** + * Web views must see redirects themselves: following them here would serve the final body under the original URL. + */ + fun withoutRedirects(context: Context): OkHttpClient = clientWithoutRedirects ?: synchronized(this) { + clientWithoutRedirects ?: get(context).newBuilder() + .followRedirects(false) + .followSslRedirects(false) + .build() + .also { clientWithoutRedirects = it } + } + + private fun hostnameVerifier(context: Context) = HostnameVerifier { host, session -> + HttpsURLConnection.getDefaultHostnameVerifier().verify(host, session) || isAcceptedByUser(context, session) + } + + private fun isAcceptedByUser(context: Context, session: SSLSession): Boolean = try { + val certificate = session.peerCertificates.firstOrNull() + certificate != null && NetworkUtils.isCertInKnownServersStore(certificate, context) + } catch (e: GeneralSecurityException) { + Log_OC.e(TAG, "accepted certificates not readable: ${e.javaClass.simpleName}") + false + } catch (e: IOException) { + Log_OC.e(TAG, "peer certificate or accepted certificates not readable: ${e.javaClass.simpleName}") + false + } +} diff --git a/app/src/main/java/com/nextcloud/utils/headers/CustomHeadersParseResult.kt b/app/src/main/java/com/nextcloud/utils/headers/CustomHeadersParseResult.kt new file mode 100644 index 000000000000..166fdbfd8ff3 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/headers/CustomHeadersParseResult.kt @@ -0,0 +1,13 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.utils.headers + +sealed interface CustomHeadersParseResult { + data class Valid(val headers: Map) : CustomHeadersParseResult + + data class Invalid(val lineNumber: Int) : CustomHeadersParseResult +} diff --git a/app/src/main/java/com/nextcloud/utils/headers/CustomHeadersParser.kt b/app/src/main/java/com/nextcloud/utils/headers/CustomHeadersParser.kt new file mode 100644 index 000000000000..42b093ea8150 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/headers/CustomHeadersParser.kt @@ -0,0 +1,43 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.utils.headers + +import com.nextcloud.common.CustomHeaders + +object CustomHeadersParser { + private const val SEPARATOR = ':' + private const val MAX_HEADERS = 10 + private const val LINE_SEPARATOR = "\n" + + fun format(headers: Map): String = + headers.entries.joinToString(LINE_SEPARATOR) { (name, value) -> "$name$SEPARATOR $value" } + + fun parse(text: String): CustomHeadersParseResult { + val headers = linkedMapOf() + + text.lines().forEachIndexed { index, line -> + if (line.isBlank()) { + return@forEachIndexed + } + + val separatorIndex = line.indexOf(SEPARATOR) + val name = line.substring(0, maxOf(separatorIndex, 0)).trim() + val value = line.substring(separatorIndex + 1).trim() + val duplicate = headers.keys.any { it.equals(name, ignoreCase = true) } + + if (separatorIndex < 0 || duplicate || !CustomHeaders.isValid(name, value)) { + return CustomHeadersParseResult.Invalid(index + 1) + } + headers[name] = value + } + + if (headers.size > MAX_HEADERS) { + return CustomHeadersParseResult.Invalid(MAX_HEADERS + 1) + } + return CustomHeadersParseResult.Valid(headers) + } +} diff --git a/app/src/main/java/com/nextcloud/utils/headers/WebViewCustomHeaders.kt b/app/src/main/java/com/nextcloud/utils/headers/WebViewCustomHeaders.kt new file mode 100644 index 000000000000..25499810bdf7 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/headers/WebViewCustomHeaders.kt @@ -0,0 +1,215 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.utils.headers + +import android.content.Context +import android.net.Uri +import android.webkit.CookieManager +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature +import com.nextcloud.common.CustomHeaders +import com.owncloud.android.lib.common.utils.Log_OC +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.Request +import okhttp3.Response +import org.json.JSONObject +import java.io.IOException +import java.util.Collections +import java.util.Locale + +/** + * Web views cannot add headers to sub-resources: GET requests to a registered origin, including those of scripts, are + * fetched through the library client, which follows redirects only within that origin. Other requests of the page's + * own fetch and XMLHttpRequest calls (for example POST) get the headers from a page script; fetch rejects redirects + * for them, XMLHttpRequest cannot. + * + * The page script makes the headers readable for code running in that origin. That code, and the server behind it, + * is trusted with them anyway, so an XMLHttpRequest redirect needs the cooperation of an already trusted party. + */ +object WebViewCustomHeaders { + private const val TAG = "WebViewCustomHeaders" + private const val METHOD_GET = "GET" + private const val HEADER_COOKIE = "Cookie" + private const val HEADER_SET_COOKIE = "Set-Cookie" + private const val HEADER_LOCATION = "Location" + private const val HEADER_SEPARATOR = ", " + private const val DEFAULT_REASON = "OK" + private const val HTML_MIME_TYPE = "text/html" + private const val UTF_8 = "UTF-8" + private const val MAX_REDIRECTS = 10 + private const val HEADERS_PLACEHOLDER = "__HEADERS__" + private const val ORIGIN_PLACEHOLDER = "__ORIGIN__" + private const val LOCATION_PLACEHOLDER = "__LOCATION__" + private const val NAVIGATION_PAGE = "" + private const val FIRST_REDIRECTION_STATUS = 300 + private const val LAST_REDIRECTION_STATUS = 399 + private val UNSUPPORTED_STATUS_CODES = FIRST_REDIRECTION_STATUS..LAST_REDIRECTION_STATUS + private val SCRIPT_SCHEMES = setOf("javascript", "data", "vbscript") + + /** + * Not forwarded: OkHttp handles encoding itself, cookies are read from the web view per hop, and validators + * could produce a 304, which a [WebResourceResponse] cannot carry. + */ + private val EXCLUDED_REQUEST_HEADERS = setOf("accept-encoding", "cookie", "if-none-match", "if-modified-since") + + private val redirectNavigations: MutableSet = Collections.synchronizedSet(mutableSetOf()) + + private val PAGE_SCRIPT = """ + (function () { + var headers = __HEADERS__; + var origin = __ORIGIN__; + function needsHeaders(method, url) { + if (String(method || 'GET').toUpperCase() === 'GET') { return false; } + try { return new URL(url, location.href).origin === origin; } catch (e) { return false; } + } + var nativeFetch = window.fetch; + window.fetch = function (input, init) { + var request = input instanceof Request ? input : undefined; + var url = request ? request.url : String(input); + var method = (init && init.method) || (request && request.method); + if (!needsHeaders(method, url)) { return nativeFetch.apply(this, arguments); } + var base = (init && init.headers) || (request && request.headers); + var merged = new Headers(base); + Object.keys(headers).forEach(function (name) { merged.set(name, headers[name]); }); + var redirect = (init && init.redirect) || (request && request.redirect); + return nativeFetch.call(this, input, Object.assign({}, init, { + headers: merged, + redirect: redirect === 'manual' ? 'manual' : 'error' + })); + }; + var nativeOpen = XMLHttpRequest.prototype.open; + var nativeSend = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.open = function (method, url) { + this.__ncNeedsHeaders = needsHeaders(method, url); + return nativeOpen.apply(this, arguments); + }; + XMLHttpRequest.prototype.send = function () { + if (this.__ncNeedsHeaders) { + var xhr = this; + Object.keys(headers).forEach(function (name) { xhr.setRequestHeader(name, headers[name]); }); + } + return nativeSend.apply(this, arguments); + }; + })(); + """.trimIndent() + + /** + * Each origin gets its own script that is only injected into frames of that origin, so a page never sees the + * headers of another origin. + */ + fun installPageScripts(webView: WebView) { + val origins = CustomHeaders.origins + if (origins.isEmpty()) { + return + } + if (WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) { + for (origin in origins) { + val script = PAGE_SCRIPT + .replace(HEADERS_PLACEHOLDER, JSONObject(CustomHeaders.forOrigin(origin)).toString()) + .replace(ORIGIN_PLACEHOLDER, JSONObject.quote(origin)) + WebViewCompat.addDocumentStartJavaScript(webView, script, setOf(origin)) + } + } else { + Log_OC.w(TAG, "document start script not supported, page requests will not carry custom headers") + } + } + + /** + * Redirects are followed hop by hop: sub-resources only within the origin of the request, the main frame by + * navigating to the target, so a body is never served under the URL of another origin. + */ + fun intercept(context: Context, request: WebResourceRequest): WebResourceResponse? { + val url = request.url.toString().toHttpUrlOrNull() ?: return null + val isGet = request.method.equals(METHOD_GET, ignoreCase = true) + if (!isGet || CustomHeaders.forDestination(url.scheme, url.host, url.port).isEmpty()) { + return null + } + + val forwarded = request.requestHeaders.filterKeys { it.lowercase(Locale.ROOT) !in EXCLUDED_REQUEST_HEADERS } + var hop = url + repeat(MAX_REDIRECTS) { + val response = try { + execute(context, hop, forwarded) + } catch (e: IOException) { + Log_OC.e(TAG, "web view request via library client failed: ${e.javaClass.simpleName}") + return null + } + storeCookies(response) + if (response.code !in UNSUPPORTED_STATUS_CODES) { + return response.toWebResourceResponse() + } + + val location = response.header(HEADER_LOCATION) + response.close() + if (location == null) { + return null + } + if (request.isForMainFrame) { + val target = hop.resolve(location)?.toString() ?: location + return target.takeUnless { isScriptScheme(it) }?.let { navigationTo(it) } + } + hop = hop.resolve(location)?.takeIf { isSameOrigin(it, url) } ?: return null + } + return null + } + + /** + * Navigations started by [navigationTo] replace a server redirect and must be treated like one by web view + * clients that open other navigations outside the web view. + */ + fun isRedirectNavigation(url: Uri): Boolean = redirectNavigations.remove(normalized(url.toString())) + + private fun normalized(url: String): String = url.toHttpUrlOrNull()?.toString() ?: url + + private fun execute(context: Context, url: HttpUrl, forwarded: Map): Response { + val outgoing = Request.Builder().url(url) + forwarded.forEach { (name, value) -> outgoing.header(name, value) } + CookieManager.getInstance().getCookie(url.toString())?.let { outgoing.header(HEADER_COOKIE, it) } + return CustomHeadersHttpClient.withoutRedirects(context).newCall(outgoing.build()).execute() + } + + private fun storeCookies(response: Response) { + val cookieManager = CookieManager.getInstance() + response.headers(HEADER_SET_COOKIE).forEach { cookieManager.setCookie(response.request.url.toString(), it) } + } + + private fun isScriptScheme(location: String): Boolean { + val scheme = location.substringBefore(':', missingDelimiterValue = "").trim().lowercase(Locale.ROOT) + return scheme in SCRIPT_SCHEMES + } + + private fun isSameOrigin(first: HttpUrl, second: HttpUrl): Boolean = + first.scheme == second.scheme && first.host == second.host && first.port == second.port + + /** + * A redirect cannot be returned to the web view, so the main frame navigates to the target itself. The target + * then gets its own URL, origin and, if registered, its own interception; app schemes reach the URL handling of + * the web view client. + */ + private fun navigationTo(location: String): WebResourceResponse { + redirectNavigations.add(normalized(location)) + val page = NAVIGATION_PAGE.replace(LOCATION_PLACEHOLDER, JSONObject.quote(location)) + return WebResourceResponse(HTML_MIME_TYPE, UTF_8, page.byteInputStream()) + } + + private fun Response.toWebResourceResponse(): WebResourceResponse { + val contentType = body.contentType() + val responseHeaders = headers.toMultimap().mapValues { (_, values) -> values.joinToString(HEADER_SEPARATOR) } + return WebResourceResponse( + contentType?.let { "${it.type}/${it.subtype}" }, + contentType?.charset()?.name(), + code, + message.ifEmpty { DEFAULT_REASON }, + responseHeaders - HEADER_SET_COOKIE.lowercase(Locale.ROOT), + body.byteStream() + ) + } +} diff --git a/app/src/main/java/com/owncloud/android/MainApp.java b/app/src/main/java/com/owncloud/android/MainApp.java index 0454dc11a200..820d8d88e159 100644 --- a/app/src/main/java/com/owncloud/android/MainApp.java +++ b/app/src/main/java/com/owncloud/android/MainApp.java @@ -61,6 +61,7 @@ import com.nextcloud.client.systembars.SystemBarBackgroundCallbacks; import com.nextcloud.ui.composeActivity.ComposeProcessTextAlias; import com.nextcloud.utils.extensions.ContextExtensionsKt; +import com.nextcloud.utils.headers.AccountCustomHeaders; import com.nextcloud.utils.mdm.MDMConfig; import com.nmc.android.ui.LauncherActivity; import com.owncloud.android.authentication.PassCodeManager; @@ -311,6 +312,7 @@ public void onCreate() { MainApp.storagePath = preferences.getStoragePath(getApplicationContext().getFilesDir().getAbsolutePath()); OwnCloudClientManagerFactory.setUserAgent(getUserAgent()); + AccountCustomHeaders.registerAll(this); if (isClientBrandedPlus()) { setProxyConfig(); diff --git a/app/src/main/java/com/owncloud/android/authentication/AuthenticatorActivity.kt b/app/src/main/java/com/owncloud/android/authentication/AuthenticatorActivity.kt index 2ef83e482af3..1e5138ce5536 100644 --- a/app/src/main/java/com/owncloud/android/authentication/AuthenticatorActivity.kt +++ b/app/src/main/java/com/owncloud/android/authentication/AuthenticatorActivity.kt @@ -42,6 +42,7 @@ import androidx.core.net.toUri import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding +import androidx.core.widget.doAfterTextChanged import androidx.fragment.app.DialogFragment import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver @@ -59,6 +60,9 @@ import com.nextcloud.client.preferences.AppPreferences import com.nextcloud.repository.ClientRepository import com.nextcloud.utils.SnackbarUtil import com.nextcloud.utils.extensions.getParcelableArgument +import com.nextcloud.utils.headers.AccountCustomHeaders +import com.nextcloud.utils.headers.CustomHeadersParseResult +import com.nextcloud.utils.headers.CustomHeadersParser import com.nextcloud.utils.mdm.MDMConfig import com.nextcloud.utils.text.LinkFormatter import com.owncloud.android.MainApp @@ -71,6 +75,7 @@ import com.owncloud.android.authentication.server.EnforcedServerPolicy import com.owncloud.android.authentication.status.AuthenticatorStatus import com.owncloud.android.authentication.status.AuthenticatorStatusMapper import com.owncloud.android.authentication.account.AuthenticatorAccountStore +import com.owncloud.android.authentication.loginflow.LoginCustomHeaders import com.owncloud.android.authentication.account.CredentialsVerifier import com.owncloud.android.authentication.dialog.LoginDialog import com.owncloud.android.authentication.loginflow.LoginBrowserLauncher @@ -157,6 +162,7 @@ open class AuthenticatorActivity : private val statusMapper by lazy { AuthenticatorStatusMapper(resources) } private val accountStore by lazy { AuthenticatorAccountStore(this, AccountManager.get(this), accountManager) } private val loginFlow by lazy { LoginFlowV2(clientRepository, lifecycleScope, ::completeLoginFlow) } + private val loginHeaders = LoginCustomHeaders { AccountCustomHeaders.registerAll(this) } private var authenticatorAction = ACTION_CREATE private var account: Account? = null @@ -207,6 +213,7 @@ open class AuthenticatorActivity : savedInstanceState?.let { waitingForOpId = it.getLong(KEY_WAITING_FOR_OP_ID) isFirstAuthAttempt = it.getBoolean(KEY_AUTH_IS_FIRST_ATTEMPT_TAG) + loginHeaders.restore(it) } val webLoginUrl = mdmWebLoginUrl() ?: providerWebLoginUrl() ?: brandedWebLoginUrl() @@ -234,6 +241,7 @@ open class AuthenticatorActivity : serverInfo.mVersion?.let { outState.putString(KEY_OC_VERSION, it.version) } outState.putString(KEY_SERVER_AUTH_METHOD, serverInfo.mAuthMethod.name) outState.putBoolean(KEY_AUTH_IS_FIRST_ATTEMPT_TAG, isFirstAuthAttempt) + loginHeaders.save(outState) loginFlow.pendingAuthObjectJson?.let { outState.putString(KEY_LOGIN_FLOW_AUTH_OBJECT, it) } credentialsCheckJob?.cancel() @@ -313,6 +321,9 @@ open class AuthenticatorActivity : operationsServiceBinder = null } ProcessLifecycleOwner.get().lifecycle.removeObserver(lifecycleEventObserver) + if (isFinishing) { + loginHeaders.discard() + } super.onDestroy() } @@ -385,7 +396,7 @@ open class AuthenticatorActivity : } private fun initOverallUi(binding: AccountSetupBinding) { - binding.hostUrlContainer.setEndIconOnClickListener { checkOcServer() } + binding.hostUrlContainer.setEndIconOnClickListener { checkOcServer(captureHeaders = true) } binding.hostUrlInputHelperText.text = getString(R.string.login_url_helper_text, getString(R.string.app_name)) @@ -394,6 +405,13 @@ open class AuthenticatorActivity : viewThemeUtils.platform.colorTextView(binding.authStatusText, ColorRole.ON_PRIMARY) viewThemeUtils.material.colorTextInputLayout(binding.hostUrlContainer, ColorRole.ON_PRIMARY) viewThemeUtils.platform.colorEditTextOnPrimary(binding.hostUrlInput) + viewThemeUtils.material.colorTextInputLayout(binding.customHeadersContainer, ColorRole.ON_PRIMARY) + viewThemeUtils.platform.colorEditTextOnPrimary(binding.customHeadersInput) + viewThemeUtils.platform.colorTextView(binding.customHeadersToggle, ColorRole.ON_PRIMARY) + binding.customHeadersToggle.setOnClickListener { + setCustomHeadersVisible(binding, binding.customHeadersContainer.visibility != View.VISIBLE) + } + binding.customHeadersInput.doAfterTextChanged { setCustomHeadersVisible(binding, true) } if (!deviceInfo.hasCamera(this)) { binding.scanQr.visibility = View.GONE @@ -437,7 +455,7 @@ open class AuthenticatorActivity : return } accountSetupBinding?.hostUrlInput?.setText(url) - checkOcServer() + checkOcServer(captureHeaders = true) } override fun onNothingSelected(parent: AdapterView<*>?) = Unit @@ -640,8 +658,12 @@ open class AuthenticatorActivity : // endregion // region Server check + /** + * @param captureHeaders only for checks the user started: other checks may run with a URL returned by a server, + * which must never receive the entered headers + */ @SuppressFBWarnings("NP") - private fun checkOcServer() { + private fun checkOcServer(captureHeaders: Boolean = false) { val binding = accountSetupBinding val hostUrl = binding?.hostUrlInput?.text?.toString() var uri: String = if (hostUrl.isNullOrEmpty()) serverInfo.mBaseUrl else hostUrl.trim { it <= ' ' } @@ -659,6 +681,10 @@ open class AuthenticatorActivity : uri = toAsciiDomainOrKeep(normalizeSchemeOrKeep(uri)) + if (captureHeaders && !hostUrl.isNullOrEmpty() && !captureCustomHeaders(binding, uri)) { + return + } + if (binding != null) { serverStatus = AuthenticatorStatus(R.drawable.progress_small, getString(R.string.auth_testing_connection)) showServerStatus() @@ -667,6 +693,27 @@ open class AuthenticatorActivity : queueServerInfoCheck(uri) } + private fun setCustomHeadersVisible(binding: AccountSetupBinding, visible: Boolean) { + binding.customHeadersContainer.visibility = if (visible) View.VISIBLE else View.GONE + binding.customHeadersToggle.setText( + if (visible) R.string.custom_headers_hide_advanced_options else R.string.custom_headers_advanced_options + ) + } + + private fun captureCustomHeaders(binding: AccountSetupBinding?, typedUrl: String): Boolean { + val input = binding?.customHeadersInput?.text?.toString().orEmpty() + val error = when (val parsed = CustomHeadersParser.parse(input)) { + is CustomHeadersParseResult.Invalid -> getString(R.string.custom_headers_invalid, parsed.lineNumber) + is CustomHeadersParseResult.Valid -> getString(R.string.custom_headers_https_only) + .takeUnless { loginHeaders.capture(typedUrl, parsed.headers) } + } + binding?.customHeadersContainer?.error = error + if (error != null) { + binding?.let { setCustomHeadersVisible(it, true) } + } + return error == null + } + private fun normalizeSchemeOrKeep(uri: String): String = try { normalizeScheme(uri) } catch (e: IllegalArgumentException) { @@ -994,6 +1041,7 @@ open class AuthenticatorActivity : @Throws(AccountUtils.AccountNotFoundException::class) private fun updateAccountAuthentication() { val account = checkNotNull(account) + loginHeaders.jsonFor(accountStore.baseUrl(account))?.let { AccountCustomHeaders.store(this, account, it) } val response = accountStore.updateCredentials(account, webViewPassword) setAccountAuthenticatorResult(response) @@ -1026,6 +1074,7 @@ open class AuthenticatorActivity : account = newAccount val authenticatorResult = accountStore.addAccount(newAccount, webViewPassword, loginName, serverInfo, userInfo) + loginHeaders.jsonFor(serverInfo.mBaseUrl)?.let { AccountCustomHeaders.store(this, newAccount, it) } setAccountAuthenticatorResult(authenticatorResult.extras) setResult(RESULT_OK, authenticatorResult) @@ -1112,7 +1161,7 @@ open class AuthenticatorActivity : override fun onEditorAction(inputField: TextView?, actionId: Int, event: KeyEvent?): Boolean { val isConfirmAction = actionId == EditorInfo.IME_ACTION_NEXT || actionId == EditorInfo.IME_NULL if (isConfirmAction && inputField != null && inputField == accountSetupBinding?.hostUrlInput) { - checkOcServer() + checkOcServer(captureHeaders = true) } return false // always return false to grant that the software keyboard is hidden anyway } diff --git a/app/src/main/java/com/owncloud/android/authentication/loginflow/LoginCustomHeaders.kt b/app/src/main/java/com/owncloud/android/authentication/loginflow/LoginCustomHeaders.kt new file mode 100644 index 000000000000..885013b7a149 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/authentication/loginflow/LoginCustomHeaders.kt @@ -0,0 +1,69 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.authentication.loginflow + +import android.os.Bundle +import androidx.core.net.toUri +import com.nextcloud.common.CustomHeaders + +/** + * Custom headers entered during login. They are registered for the origin the user typed, never for a URL that was + * returned by a server, and only stored in the account if the account ends up on that origin. + * + * The registration is provisional: replacing or discarding it unregisters the origin and lets + * [restoreAccountHeaders] register the headers of existing accounts again, which may share that origin. + */ +class LoginCustomHeaders(private val restoreAccountHeaders: () -> Unit) { + private var origin: String? = null + private var headersJson: String? = null + + /** + * @return false if headers were entered but the server address is not https + */ + fun capture(typedBaseUrl: String, headers: Map): Boolean { + discard() + if (headers.isEmpty()) { + return true + } + val baseUri = typedBaseUrl.toUri() + if (!CustomHeaders.register(baseUri, headers)) { + return false + } + origin = CustomHeaders.originOf(baseUri) + headersJson = CustomHeaders.toJson(headers) + return true + } + + fun discard() { + val previousOrigin = origin ?: return + origin = null + headersJson = null + CustomHeaders.unregister(previousOrigin) + restoreAccountHeaders() + } + + fun jsonFor(accountBaseUrl: String): String? { + val accountOrigin = CustomHeaders.originOf(accountBaseUrl.toUri()) + return headersJson.takeIf { origin != null && origin == accountOrigin } + } + + fun save(outState: Bundle) { + outState.putString(KEY_ORIGIN, origin) + outState.putString(KEY_HEADERS, headersJson) + } + + fun restore(savedState: Bundle) { + val savedOrigin = savedState.getString(KEY_ORIGIN) ?: return + val headers = CustomHeaders.fromJson(savedState.getString(KEY_HEADERS)) ?: return + capture(savedOrigin, headers) + } + + companion object { + private const val KEY_ORIGIN = "LOGIN_CUSTOM_HEADERS_ORIGIN" + private const val KEY_HEADERS = "LOGIN_CUSTOM_HEADERS" + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/NextcloudWebViewClient.kt b/app/src/main/java/com/owncloud/android/ui/NextcloudWebViewClient.kt index 3a7badb3f529..7d06bc166c4a 100644 --- a/app/src/main/java/com/owncloud/android/ui/NextcloudWebViewClient.kt +++ b/app/src/main/java/com/owncloud/android/ui/NextcloudWebViewClient.kt @@ -16,6 +16,7 @@ import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import android.webkit.WebView import androidx.fragment.app.FragmentManager +import com.nextcloud.utils.headers.WebViewCustomHeaders import com.owncloud.android.authentication.AuthenticatorActivity import com.owncloud.android.lib.common.network.AdvancedX509KeyManager import com.owncloud.android.lib.common.network.NetworkUtils @@ -31,6 +32,11 @@ open class NextcloudWebViewClient(val supportFragmentManager: FragmentManager) : private val tag: String? = NextcloudWebViewClient::class.simpleName + override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest): WebResourceResponse? { + val intercepted = view?.context?.let { WebViewCustomHeaders.intercept(it, request) } + return intercepted ?: super.shouldInterceptRequest(view, request) + } + @SuppressLint("WebViewClientOnReceivedSslError") override fun onReceivedSslError(view: WebView?, handler: SslErrorHandler, error: SslError?) { val cert = error?.let { getX509CertificateFromError(it) } diff --git a/app/src/main/java/com/owncloud/android/ui/activity/ExternalSiteWebView.kt b/app/src/main/java/com/owncloud/android/ui/activity/ExternalSiteWebView.kt index ab9dd2dc2d43..ca9b326a7633 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/ExternalSiteWebView.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/ExternalSiteWebView.kt @@ -24,6 +24,7 @@ import com.nextcloud.client.utils.IntentUtil import com.nextcloud.utils.RawResourceReader import com.nextcloud.utils.SnackbarUtil import com.nextcloud.utils.extensions.isWebViewAvailable +import com.nextcloud.utils.headers.WebViewCustomHeaders import com.owncloud.android.MainApp import com.owncloud.android.R import com.owncloud.android.databinding.ExternalsiteWebviewBinding @@ -91,6 +92,7 @@ open class ExternalSiteWebView : FileActivity() { setupToolbarAndDrawer() setupWebSettings(webView.settings) webView.webViewClient = createWebViewClient() + WebViewCustomHeaders.installPageScripts(webView) proxyUtil?.applyProxy { url?.let { webView.loadUrl(it) } @@ -125,7 +127,7 @@ open class ExternalSiteWebView : FileActivity() { } override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest): Boolean { - if (request.isRedirect) { + if (request.isRedirect || WebViewCustomHeaders.isRedirectNavigation(request.url)) { return false } diff --git a/app/src/main/java/com/owncloud/android/ui/activity/ManageAccountsActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/ManageAccountsActivity.kt index 8e77bdd26a6b..2ea4fc0cf17d 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/ManageAccountsActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/ManageAccountsActivity.kt @@ -37,6 +37,7 @@ import com.nextcloud.client.account.UserAccountManager import com.nextcloud.client.jobs.download.FileDownloadEventBroadcaster import com.nextcloud.client.jobs.download.FileDownloadHelper import com.nextcloud.client.onboarding.FirstRunActivity +import com.nextcloud.ui.dialog.account.CustomHeadersDialogFragment import com.nextcloud.utils.extensions.getParcelableArgument import com.nextcloud.utils.mdm.MDMConfig.multiAccountSupport import com.owncloud.android.MainApp @@ -457,6 +458,11 @@ class ManageAccountsActivity : accountClicked(user) } + R.id.action_custom_headers -> { + CustomHeadersDialogFragment.newInstance(user) + .show(supportFragmentManager, CustomHeadersDialogFragment.TAG) + } + R.id.action_delete_account -> { openAccountRemovalDialog(user, supportFragmentManager) } diff --git a/app/src/main/java/com/owncloud/android/ui/activity/UserInfoActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/UserInfoActivity.java index f830eb7ddc8c..82f17738c4db 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/UserInfoActivity.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/UserInfoActivity.java @@ -32,6 +32,7 @@ import com.nextcloud.client.di.Injectable; import com.nextcloud.client.preferences.AppPreferences; import com.nextcloud.common.NextcloudClient; +import com.nextcloud.ui.dialog.account.CustomHeadersDialogFragment; import com.nextcloud.utils.GlideHelper; import com.nextcloud.utils.extensions.BundleExtensionsKt; import com.nextcloud.utils.text.LinkFormatter; @@ -161,6 +162,9 @@ public boolean onOptionsItemSelected(MenuItem item) { getOnBackPressedDispatcher().onBackPressed(); } else if (itemId == R.id.action_open_account) { accountClicked(user); + } else if (itemId == R.id.action_custom_headers) { + CustomHeadersDialogFragment.newInstance(user) + .show(getSupportFragmentManager(), CustomHeadersDialogFragment.TAG); } else if (itemId == R.id.action_delete_account) { openAccountRemovalDialog(user, getSupportFragmentManager()); } else { diff --git a/app/src/main/java/com/owncloud/android/utils/glide/CustomHeadersGlideUrlFetcher.kt b/app/src/main/java/com/owncloud/android/utils/glide/CustomHeadersGlideUrlFetcher.kt new file mode 100644 index 000000000000..523a2f2dc0b0 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/utils/glide/CustomHeadersGlideUrlFetcher.kt @@ -0,0 +1,55 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.utils.glide + +import com.bumptech.glide.Priority +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.data.DataFetcher +import com.bumptech.glide.load.model.GlideUrl +import okhttp3.Call +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.ResponseBody +import java.io.IOException +import java.io.InputStream + +class CustomHeadersGlideUrlFetcher(private val model: GlideUrl, private val client: OkHttpClient) : + DataFetcher { + + private var call: Call? = null + private var body: ResponseBody? = null + + override fun loadData(priority: Priority, callback: DataFetcher.DataCallback) { + val request = Request.Builder().url(model.toStringUrl()) + .apply { model.headers.forEach { (name, value) -> header(name, value) } } + .build() + try { + val newCall = client.newCall(request).also { call = it } + val response = newCall.execute() + body = response.body + if (!response.isSuccessful) { + callback.onLoadFailed(IOException("Unexpected HTTP status ${response.code}")) + return + } + callback.onDataReady(response.body.byteStream()) + } catch (e: IOException) { + callback.onLoadFailed(e) + } + } + + override fun cleanup() { + body?.close() + } + + override fun cancel() { + call?.cancel() + } + + override fun getDataClass(): Class = InputStream::class.java + + override fun getDataSource(): DataSource = DataSource.REMOTE +} diff --git a/app/src/main/java/com/owncloud/android/utils/glide/CustomHeadersGlideUrlLoader.kt b/app/src/main/java/com/owncloud/android/utils/glide/CustomHeadersGlideUrlLoader.kt new file mode 100644 index 000000000000..1f3a9c2afc6f --- /dev/null +++ b/app/src/main/java/com/owncloud/android/utils/glide/CustomHeadersGlideUrlLoader.kt @@ -0,0 +1,32 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.utils.glide + +import android.content.Context +import androidx.core.net.toUri +import com.bumptech.glide.load.Options +import com.bumptech.glide.load.model.GlideUrl +import com.bumptech.glide.load.model.ModelLoader +import com.bumptech.glide.load.model.ModelLoader.LoadData +import com.bumptech.glide.signature.ObjectKey +import com.nextcloud.common.CustomHeaders +import com.nextcloud.utils.headers.CustomHeadersHttpClient +import java.io.InputStream + +/** + * Glide's default loader repeats all request headers on redirects, so URLs of an origin with custom headers are + * loaded through the library client instead. All other URLs fall through to the default loader. + */ +class CustomHeadersGlideUrlLoader(private val context: Context) : ModelLoader { + override fun buildLoadData(model: GlideUrl, width: Int, height: Int, options: Options): LoadData = + LoadData(ObjectKey(model), CustomHeadersGlideUrlFetcher(model, CustomHeadersHttpClient.get(context))) + + override fun handles(model: GlideUrl): Boolean { + val uri = model.toStringUrl().toUri() + return CustomHeaders.forDestination(uri.scheme, uri.host, uri.port).isNotEmpty() + } +} diff --git a/app/src/main/java/com/owncloud/android/utils/glide/CustomHeadersGlideUrlLoaderFactory.kt b/app/src/main/java/com/owncloud/android/utils/glide/CustomHeadersGlideUrlLoaderFactory.kt new file mode 100644 index 000000000000..4068c7cf7cbc --- /dev/null +++ b/app/src/main/java/com/owncloud/android/utils/glide/CustomHeadersGlideUrlLoaderFactory.kt @@ -0,0 +1,21 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.utils.glide + +import android.content.Context +import com.bumptech.glide.load.model.GlideUrl +import com.bumptech.glide.load.model.ModelLoader +import com.bumptech.glide.load.model.ModelLoaderFactory +import com.bumptech.glide.load.model.MultiModelLoaderFactory +import java.io.InputStream + +class CustomHeadersGlideUrlLoaderFactory(private val context: Context) : ModelLoaderFactory { + override fun build(unused: MultiModelLoaderFactory): ModelLoader = + CustomHeadersGlideUrlLoader(context.applicationContext) + + override fun teardown() = Unit +} diff --git a/app/src/main/java/com/owncloud/android/utils/glide/NextcloudGlideModule.kt b/app/src/main/java/com/owncloud/android/utils/glide/NextcloudGlideModule.kt index e07ee40827fc..550f9ed96852 100644 --- a/app/src/main/java/com/owncloud/android/utils/glide/NextcloudGlideModule.kt +++ b/app/src/main/java/com/owncloud/android/utils/glide/NextcloudGlideModule.kt @@ -14,6 +14,7 @@ import android.net.Uri import com.bumptech.glide.Glide import com.bumptech.glide.Registry import com.bumptech.glide.annotation.GlideModule +import com.bumptech.glide.load.model.GlideUrl import com.bumptech.glide.module.AppGlideModule import com.caverock.androidsvg.SVG import com.owncloud.android.utils.svg.SVGorImage @@ -27,6 +28,7 @@ import java.io.InputStream class NextcloudGlideModule : AppGlideModule() { override fun registerComponents(context: Context, glide: Glide, registry: Registry) { registry + .prepend(GlideUrl::class.java, InputStream::class.java, CustomHeadersGlideUrlLoaderFactory(context)) .prepend(Uri::class.java, InputStream::class.java, UriModelLoaderFactory()) .prepend(String::class.java, InputStream::class.java, StringModelLoaderFactory()) .register(SVGorImage::class.java, Bitmap::class.java, SvgOrImageBitmapTranscoder(SVG_SIZE, SVG_SIZE)) diff --git a/app/src/main/res/layout-land/account_setup.xml b/app/src/main/res/layout-land/account_setup.xml index 8b09721456ed..3990a0713e4b 100644 --- a/app/src/main/res/layout-land/account_setup.xml +++ b/app/src/main/res/layout-land/account_setup.xml @@ -79,6 +79,7 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/account_setup.xml b/app/src/main/res/layout/account_setup.xml index f62cb25fa1c6..2ca9caa70354 100644 --- a/app/src/main/res/layout/account_setup.xml +++ b/app/src/main/res/layout/account_setup.xml @@ -79,6 +79,7 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/custom_headers_dialog.xml b/app/src/main/res/layout/custom_headers_dialog.xml new file mode 100644 index 000000000000..49758307b130 --- /dev/null +++ b/app/src/main/res/layout/custom_headers_dialog.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + diff --git a/app/src/main/res/menu/item_account.xml b/app/src/main/res/menu/item_account.xml index e010c17ea9b6..68c6f08b2487 100644 --- a/app/src/main/res/menu/item_account.xml +++ b/app/src/main/res/menu/item_account.xml @@ -18,6 +18,12 @@ app:showAsAction="never" android:showAsAction="never" /> + + Hash not found Signature does not match + Advanced options + Hide advanced options + Custom request headers (optional) + One per line as \"Name: value\". Only sent to this server, for example for a pre-authentication proxy. + Custom headers are only sent to https servers + Invalid header in line %1$d + Custom headers + Sent with every request to this server, for example for a pre-authentication proxy. Leave empty to remove them. + One per line as \"Name: value\" The link to your %1$s web interface when you open it in the browser. diff --git a/app/src/test/java/com/nextcloud/utils/headers/AccountCustomHeadersTest.kt b/app/src/test/java/com/nextcloud/utils/headers/AccountCustomHeadersTest.kt new file mode 100644 index 000000000000..010454177c3b --- /dev/null +++ b/app/src/test/java/com/nextcloud/utils/headers/AccountCustomHeadersTest.kt @@ -0,0 +1,114 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.utils.headers + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.Context +import com.nextcloud.common.CustomHeaders +import com.owncloud.android.MainApp +import com.owncloud.android.lib.common.accounts.AccountUtils +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class AccountCustomHeadersTest { + private val context = mockk(relaxed = true) + private val accountManager = mockk() + private val userData = mutableMapOf, String?>() + private val accounts = mutableListOf() + + @Before + fun setUp() { + UriParsingMock.install() + mockkStatic(AccountManager::class, MainApp::class) + every { AccountManager.get(any()) } returns accountManager + every { MainApp.getAccountType(any()) } returns ACCOUNT_TYPE + every { accountManager.getAccountsByType(ACCOUNT_TYPE) } answers { accounts.toTypedArray() } + every { accountManager.getUserData(any(), any()) } answers { userData[firstArg() to secondArg()] } + every { accountManager.setUserData(any(), any(), any()) } answers { + userData[firstArg() to secondArg()] = thirdArg() + } + } + + @After + fun tearDown() { + CustomHeaders.origins.forEach { CustomHeaders.unregister(it) } + unmockkAll() + } + + @Test + fun `headers of https accounts are registered for their origin`() { + addAccount("https://Cloud.Example.com:443/nextcloud", mapOf("X-A" to "1")) + + AccountCustomHeaders.registerAll(context) + + assertEquals(mapOf("X-A" to "1"), CustomHeaders.forOrigin("https://cloud.example.com")) + } + + @Test + fun `headers of accounts that are not https are ignored`() { + addAccount("http://cloud.example.com", mapOf("X-A" to "1")) + + AccountCustomHeaders.registerAll(context) + + assertTrue(CustomHeaders.origins.isEmpty()) + } + + @Test + fun `reconcile drops the headers of removed accounts only`() { + val removed = addAccount("https://one.example.com", mapOf("X-A" to "1")) + addAccount("https://two.example.com", mapOf("X-B" to "2")) + AccountCustomHeaders.registerAll(context) + + accounts.remove(removed) + AccountCustomHeaders.reconcile(context) + + assertEquals(setOf("https://two.example.com"), CustomHeaders.origins) + } + + @Test + fun `replaced headers are stored and take effect`() { + val account = addAccount("https://cloud.example.com", mapOf("X-A" to "1")) + AccountCustomHeaders.registerAll(context) + + AccountCustomHeaders.replace(context, account, mapOf("X-B" to "2")) + + assertEquals(mapOf("X-B" to "2"), AccountCustomHeaders.read(context, account)) + assertEquals(mapOf("X-B" to "2"), CustomHeaders.forOrigin("https://cloud.example.com")) + } + + @Test + fun `replacing with no headers removes them`() { + val account = addAccount("https://cloud.example.com", mapOf("X-A" to "1")) + AccountCustomHeaders.registerAll(context) + + AccountCustomHeaders.replace(context, account, emptyMap()) + + assertTrue(AccountCustomHeaders.read(context, account).isEmpty()) + assertTrue(CustomHeaders.origins.isEmpty()) + } + + private fun addAccount(baseUrl: String, headers: Map): Account { + val account = mockk() + accounts.add(account) + userData[account to AccountUtils.Constants.KEY_OC_BASE_URL] = baseUrl + userData[account to KEY_CUSTOM_HEADERS] = CustomHeaders.toJson(headers) + return account + } + + companion object { + private const val ACCOUNT_TYPE = "nextcloud" + private const val KEY_CUSTOM_HEADERS = "oc_custom_headers" + } +} diff --git a/app/src/test/java/com/nextcloud/utils/headers/CustomHeadersParserTest.kt b/app/src/test/java/com/nextcloud/utils/headers/CustomHeadersParserTest.kt new file mode 100644 index 000000000000..1888c5ed530c --- /dev/null +++ b/app/src/test/java/com/nextcloud/utils/headers/CustomHeadersParserTest.kt @@ -0,0 +1,65 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.utils.headers + +import org.junit.Assert.assertEquals +import org.junit.Test + +class CustomHeadersParserTest { + + @Test + fun `lines are parsed in order with names and values trimmed`() { + val result = CustomHeadersParser.parse(" CF-Access-Client-Id : abc \n\nX-Token: a:b:c\n") + + assertEquals( + CustomHeadersParseResult.Valid(linkedMapOf("CF-Access-Client-Id" to "abc", "X-Token" to "a:b:c")), + result + ) + assertEquals( + listOf("CF-Access-Client-Id", "X-Token"), + (result as CustomHeadersParseResult.Valid).headers.keys.toList() + ) + } + + @Test + fun `empty text is valid without headers`() { + assertEquals(CustomHeadersParseResult.Valid(emptyMap()), CustomHeadersParser.parse(" \n ")) + } + + @Test + fun `line without separator is reported with its number`() { + assertEquals(CustomHeadersParseResult.Invalid(2), CustomHeadersParser.parse("X-A: 1\nX-B 2")) + } + + @Test + fun `duplicate names are rejected regardless of case`() { + assertEquals(CustomHeadersParseResult.Invalid(2), CustomHeadersParser.parse("X-A: 1\nx-a: 2")) + } + + @Test + fun `reserved names and empty values are rejected`() { + assertEquals(CustomHeadersParseResult.Invalid(1), CustomHeadersParser.parse("Authorization: Bearer x")) + assertEquals(CustomHeadersParseResult.Invalid(1), CustomHeadersParser.parse("X-A:")) + assertEquals(CustomHeadersParseResult.Invalid(1), CustomHeadersParser.parse(": value")) + } + + @Test + fun `more than ten headers are rejected`() { + val text = (1..11).joinToString("\n") { "X-$it: v" } + + assertEquals(CustomHeadersParseResult.Invalid(11), CustomHeadersParser.parse(text)) + } + + @Test + fun `formatted headers parse back to the same headers`() { + val headers = linkedMapOf("X-A" to "1", "X-B" to "two words") + + val formatted = CustomHeadersParser.format(headers) + + assertEquals(CustomHeadersParseResult.Valid(headers), CustomHeadersParser.parse(formatted)) + } +} diff --git a/app/src/test/java/com/nextcloud/utils/headers/UriParsingMock.kt b/app/src/test/java/com/nextcloud/utils/headers/UriParsingMock.kt new file mode 100644 index 000000000000..f799c7353588 --- /dev/null +++ b/app/src/test/java/com/nextcloud/utils/headers/UriParsingMock.kt @@ -0,0 +1,32 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.utils.headers + +import android.net.Uri +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import java.net.URI + +/** + * Unit tests run against the stubbed android.jar, where [Uri.parse] returns null. Backs it with [URI] instead. + */ +object UriParsingMock { + fun install() { + mockkStatic(Uri::class) + every { Uri.parse(any()) } answers { uriOf(firstArg()) } + } + + private fun uriOf(text: String): Uri { + val parsed = URI(text) + return mockk { + every { scheme } returns parsed.scheme + every { host } returns parsed.host + every { port } returns parsed.port + } + } +} diff --git a/app/src/test/java/com/owncloud/android/authentication/loginflow/LoginCustomHeadersTest.kt b/app/src/test/java/com/owncloud/android/authentication/loginflow/LoginCustomHeadersTest.kt new file mode 100644 index 000000000000..a83d278c2072 --- /dev/null +++ b/app/src/test/java/com/owncloud/android/authentication/loginflow/LoginCustomHeadersTest.kt @@ -0,0 +1,88 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.authentication.loginflow + +import com.nextcloud.common.CustomHeaders +import com.nextcloud.utils.headers.UriParsingMock +import io.mockk.unmockkAll +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class LoginCustomHeadersTest { + private var restoreCalls = 0 + private val loginHeaders = LoginCustomHeaders { restoreCalls++ } + + @Before + fun setUp() { + UriParsingMock.install() + } + + @After + fun tearDown() { + CustomHeaders.origins.forEach { CustomHeaders.unregister(it) } + unmockkAll() + } + + @Test + fun `captured headers are registered for the typed https origin`() { + assertTrue(loginHeaders.capture("https://cloud.example.com/nextcloud", HEADERS)) + + assertEquals(HEADERS, CustomHeaders.forOrigin("https://cloud.example.com")) + } + + @Test + fun `headers for a server that is not https are refused`() { + assertFalse(loginHeaders.capture("http://cloud.example.com", HEADERS)) + + assertTrue(CustomHeaders.origins.isEmpty()) + } + + @Test + fun `no headers is accepted without registering anything`() { + assertTrue(loginHeaders.capture("http://cloud.example.com", emptyMap())) + + assertTrue(CustomHeaders.origins.isEmpty()) + } + + @Test + fun `headers are only handed to an account on the captured origin`() { + loginHeaders.capture("https://cloud.example.com", HEADERS) + + assertEquals(CustomHeaders.toJson(HEADERS), loginHeaders.jsonFor("https://cloud.example.com/index.php")) + assertNull(loginHeaders.jsonFor("https://other.example.com")) + } + + @Test + fun `discard unregisters the origin and restores account headers`() { + loginHeaders.capture("https://cloud.example.com", HEADERS) + + loginHeaders.discard() + + assertTrue(CustomHeaders.origins.isEmpty()) + assertNull(loginHeaders.jsonFor("https://cloud.example.com")) + assertEquals(1, restoreCalls) + } + + @Test + fun `capturing for another origin discards the previous one`() { + loginHeaders.capture("https://one.example.com", HEADERS) + + loginHeaders.capture("https://two.example.com", HEADERS) + + assertEquals(setOf("https://two.example.com"), CustomHeaders.origins) + assertEquals(1, restoreCalls) + } + + companion object { + private val HEADERS = mapOf("CF-Access-Client-Id" to "id") + } +}