diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml
index 881e4f9a54..f221faab2b 100644
--- a/gradle/verification-metadata.xml
+++ b/gradle/verification-metadata.xml
@@ -9941,6 +9941,14 @@
+
+
+
+
+
+
+
+
diff --git a/library/build.gradle b/library/build.gradle
index 852c2bf23c..3a1e849a28 100644
--- a/library/build.gradle
+++ b/library/build.gradle
@@ -80,6 +80,7 @@ dependencies {
// dependencies for tests
testImplementation "junit:junit:$junit_version"
testImplementation 'org.mockito:mockito-core:5.24.0'
+ testImplementation 'com.squareup.okhttp3:mockwebserver3:5.5.1'
testImplementation 'com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0'
// dependencies for instrumented tests
diff --git a/library/src/main/java/com/nextcloud/common/CustomHeaders.kt b/library/src/main/java/com/nextcloud/common/CustomHeaders.kt
new file mode 100644
index 0000000000..d4d47c9c81
--- /dev/null
+++ b/library/src/main/java/com/nextcloud/common/CustomHeaders.kt
@@ -0,0 +1,183 @@
+/*
+ * Nextcloud Android Library
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: MIT
+ */
+package com.nextcloud.common
+
+import android.net.Uri
+import com.owncloud.android.lib.common.utils.Log_OC
+import okhttp3.HttpUrl
+import org.apache.commons.httpclient.HttpMethod
+import org.json.JSONException
+import org.json.JSONObject
+import java.util.Locale
+
+/**
+ * Process wide registry of additional request headers, each bound to one https origin.
+ *
+ * All library clients ask this registry on every hop (including redirects), so a header is only ever sent to the
+ * origin it was registered for, for example a pre-authentication proxy in front of the server.
+ *
+ * The registry lives in memory only. Persisting the headers, for example as account user data with [toJson], and
+ * registering them again after a process start is up to the app.
+ */
+object CustomHeaders {
+ private const val TAG = "CustomHeaders"
+ private const val HTTPS = "https"
+ private const val DEFAULT_HTTPS_PORT = 443
+ private const val MIN_VISIBLE_ASCII = 0x20
+ private const val MAX_VISIBLE_ASCII = 0x7E
+ private const val TAB = '\t'.code
+ private const val INJECTED_HEADERS_PARAMETER = "nextcloud.customHeaders.injected"
+ private val TOKEN = Regex("^[!#$%&'*+.^_`|~0-9A-Za-z-]+$")
+ private val RESERVED_NAMES =
+ setOf(
+ "host",
+ "authorization",
+ "proxy-authorization",
+ "user-agent",
+ "cookie",
+ "content-length",
+ "content-type",
+ "transfer-encoding",
+ "connection",
+ "destination"
+ )
+
+ @Volatile
+ private var byOrigin: Map> = emptyMap()
+
+ val origins: Set
+ get() = byOrigin.keys
+
+ @JvmStatic
+ fun isValid(
+ name: String,
+ value: String
+ ): Boolean {
+ val nameAllowed = TOKEN.matches(name) && name.lowercase(Locale.ROOT) !in RESERVED_NAMES
+ val valueAllowed =
+ value.isNotEmpty() &&
+ value.all { it.code in MIN_VISIBLE_ASCII..MAX_VISIBLE_ASCII || it.code == TAB }
+ return nameAllowed && valueAllowed
+ }
+
+ /**
+ * Same form as `URL.origin` in JavaScript: lower case, internationalized hosts in punycode as OkHttp sends them,
+ * default port omitted.
+ */
+ @JvmStatic
+ fun originOf(
+ scheme: String?,
+ host: String?,
+ port: Int
+ ): String? {
+ val canonicalHost = host?.takeIf { it.isNotEmpty() }?.let { canonicalHostOf(it) }
+ if (scheme == null || canonicalHost == null) {
+ return null
+ }
+ val normalizedScheme = scheme.lowercase(Locale.ROOT)
+ val bracketedHost = if (':' in canonicalHost) "[$canonicalHost]" else canonicalHost
+ val isDefaultPort = port < 0 || (normalizedScheme == HTTPS && port == DEFAULT_HTTPS_PORT)
+ return if (isDefaultPort) "$normalizedScheme://$bracketedHost" else "$normalizedScheme://$bracketedHost:$port"
+ }
+
+ private fun canonicalHostOf(host: String): String? =
+ try {
+ HttpUrl
+ .Builder()
+ .scheme(HTTPS)
+ .host(host)
+ .build()
+ .host
+ } catch (_: IllegalArgumentException) {
+ null
+ }
+
+ @JvmStatic
+ fun originOf(uri: Uri?): String? = uri?.let { originOf(it.scheme, it.host, it.port) }
+
+ /**
+ * @return false if the base URI is not https or a header is invalid; nothing is registered then
+ */
+ @JvmStatic
+ fun register(
+ baseUri: Uri?,
+ headers: Map
+ ): Boolean {
+ val isHttps = baseUri?.scheme.equals(HTTPS, ignoreCase = true)
+ val origin = originOf(baseUri)
+ if (!isHttps || origin == null) {
+ return false
+ }
+ return register(origin, headers)
+ }
+
+ internal fun register(
+ origin: String,
+ headers: Map
+ ): Boolean {
+ if (headers.isEmpty() || !headers.all { (name, value) -> isValid(name, value) }) {
+ return false
+ }
+ synchronized(this) { byOrigin = byOrigin + (origin to headers.toMap()) }
+ return true
+ }
+
+ @JvmStatic
+ fun unregister(origin: String) {
+ synchronized(this) { byOrigin = byOrigin - origin }
+ }
+
+ internal fun clear() {
+ synchronized(this) { byOrigin = emptyMap() }
+ }
+
+ @JvmStatic
+ fun forDestination(
+ scheme: String?,
+ host: String?,
+ port: Int
+ ): Map = originOf(scheme, host, port)?.let { byOrigin[it] }.orEmpty()
+
+ @JvmStatic
+ fun forOrigin(origin: String): Map = byOrigin[origin].orEmpty()
+
+ /**
+ * Apache methods are reused for every redirect hop, so the headers injected for the previous hop are removed by
+ * name even if the registry changed in between, and values set by the operation itself are put back.
+ */
+ @JvmStatic
+ fun applyTo(method: HttpMethod) {
+ val previouslyInjected = method.params.getParameter(INJECTED_HEADERS_PARAMETER) as? Map<*, *>
+ previouslyInjected?.forEach { (name, operationValue) ->
+ method.removeRequestHeader(name as String)
+ (operationValue as? String)?.let { method.setRequestHeader(name, it) }
+ }
+
+ val uri = method.uri
+ val allowed = forDestination(uri.scheme, uri.host, uri.port)
+ val operationValues = allowed.keys.associateWith { method.getRequestHeader(it)?.value }
+ allowed.forEach { (name, value) -> method.setRequestHeader(name, value) }
+ method.params.setParameter(INJECTED_HEADERS_PARAMETER, operationValues)
+ }
+
+ @JvmStatic
+ fun toJson(headers: Map): String = JSONObject(headers).toString()
+
+ @JvmStatic
+ fun fromJson(json: String?): Map? {
+ if (json.isNullOrBlank()) {
+ return null
+ }
+ return try {
+ val obj = JSONObject(json)
+ obj.keys().asSequence().associateWith { obj.getString(it) }
+ } catch (_: JSONException) {
+ Log_OC.e(TAG, "custom headers are malformed and will be ignored")
+ null
+ }
+ }
+}
diff --git a/library/src/main/java/com/nextcloud/common/CustomHeadersInterceptor.kt b/library/src/main/java/com/nextcloud/common/CustomHeadersInterceptor.kt
new file mode 100644
index 0000000000..f4fedf4d0d
--- /dev/null
+++ b/library/src/main/java/com/nextcloud/common/CustomHeadersInterceptor.kt
@@ -0,0 +1,31 @@
+/*
+ * Nextcloud Android Library
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: MIT
+ */
+package com.nextcloud.common
+
+import okhttp3.Interceptor
+import okhttp3.Response
+
+/**
+ * Must be registered as network interceptor so that it runs for every hop of a redirect chain. OkHttp builds the
+ * next hop from the request before the network interceptors ran, so headers added here never carry over to it.
+ *
+ * Library clients install it themselves; an [okhttp3.OkHttpClient] passed in by the caller has to carry it already.
+ */
+object CustomHeadersInterceptor : Interceptor {
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val request = chain.request()
+ val url = request.url
+ val allowed = CustomHeaders.forDestination(url.scheme, url.host, url.port)
+ if (allowed.isEmpty()) {
+ return chain.proceed(request)
+ }
+
+ val builder = request.newBuilder()
+ allowed.forEach { (name, value) -> builder.header(name, value) }
+ return chain.proceed(builder.build())
+ }
+}
diff --git a/library/src/main/java/com/nextcloud/common/NextcloudClient.kt b/library/src/main/java/com/nextcloud/common/NextcloudClient.kt
index 7f51e6437a..bfd4a126d7 100644
--- a/library/src/main/java/com/nextcloud/common/NextcloudClient.kt
+++ b/library/src/main/java/com/nextcloud/common/NextcloudClient.kt
@@ -94,6 +94,7 @@ class NextcloudClient private constructor(
.hostnameVerifier { _: String?, _: SSLSession? -> true }
.fastFallback(true)
.proxy(proxy)
+ .addNetworkInterceptor(CustomHeadersInterceptor)
.build()
}
}
diff --git a/library/src/main/java/com/nextcloud/common/PlainClient.kt b/library/src/main/java/com/nextcloud/common/PlainClient.kt
index a611442d74..672abadcf3 100644
--- a/library/src/main/java/com/nextcloud/common/PlainClient.kt
+++ b/library/src/main/java/com/nextcloud/common/PlainClient.kt
@@ -65,6 +65,7 @@ class PlainClient(
.hostnameVerifier { _: String?, _: SSLSession? -> true }
.fastFallback(true)
.proxy(proxy)
+ .addNetworkInterceptor(CustomHeadersInterceptor)
.build()
}
}
diff --git a/library/src/main/java/com/owncloud/android/lib/common/OwnCloudClient.java b/library/src/main/java/com/owncloud/android/lib/common/OwnCloudClient.java
index 3d10638ee6..989d12b6c7 100644
--- a/library/src/main/java/com/owncloud/android/lib/common/OwnCloudClient.java
+++ b/library/src/main/java/com/owncloud/android/lib/common/OwnCloudClient.java
@@ -20,6 +20,7 @@
import android.net.Uri;
import android.text.TextUtils;
+import com.nextcloud.common.CustomHeaders;
import com.nextcloud.common.DNSCache;
import com.nextcloud.common.NextcloudUriDelegate;
import com.owncloud.android.lib.common.accounts.AccountUtils;
@@ -189,6 +190,7 @@ public int executeMethod(HttpMethod method) throws IOException {
// logCookiesAtState("before");
method.setFollowRedirects(false);
+ CustomHeaders.applyTo(method);
int status = super.executeMethod(method);
if (status >= 500 && status < 600 && DNSCache.isIPV6First(hostname)) {
@@ -270,6 +272,7 @@ public RedirectionPath followRedirection(HttpMethod method) throws IOException {
destination.setValue(redirectedDestination);
method.setRequestHeader(destination);
}
+ CustomHeaders.applyTo(method);
status = super.executeMethod(method);
result.addStatus(status);
redirectionsCount++;
diff --git a/library/src/test/java/com/nextcloud/common/CustomHeadersTest.kt b/library/src/test/java/com/nextcloud/common/CustomHeadersTest.kt
new file mode 100644
index 0000000000..b6fdc43a6d
--- /dev/null
+++ b/library/src/test/java/com/nextcloud/common/CustomHeadersTest.kt
@@ -0,0 +1,195 @@
+/*
+ * Nextcloud Android Library
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: MIT
+ */
+package com.nextcloud.common
+
+import mockwebserver3.MockResponse
+import mockwebserver3.MockWebServer
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import org.apache.commons.httpclient.URI
+import org.apache.commons.httpclient.methods.GetMethod
+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 CustomHeadersTest {
+ private val headers = mapOf("CF-Access-Client-Id" to "id", "CF-Access-Client-Secret" to "secret")
+ private val client = OkHttpClient.Builder().addNetworkInterceptor(CustomHeadersInterceptor).build()
+
+ private lateinit var home: MockWebServer
+ private lateinit var foreign: MockWebServer
+
+ @Before
+ fun setUp() {
+ home = MockWebServer().apply { start() }
+ foreign = MockWebServer().apply { start() }
+ }
+
+ @After
+ fun tearDown() {
+ CustomHeaders.clear()
+ home.close()
+ foreign.close()
+ }
+
+ private fun registerHome() =
+ assertTrue(CustomHeaders.register(CustomHeaders.originOf("http", home.hostName, home.port)!!, headers))
+
+ @Test
+ fun sendsOnlyToRegisteredOrigin() {
+ CustomHeaders.register("https://cloud.example.com", headers)
+
+ assertEquals(headers, CustomHeaders.forDestination("https", "cloud.example.com", 443))
+ assertEquals(headers, CustomHeaders.forDestination("HTTPS", "CLOUD.example.com", -1))
+ assertTrue(CustomHeaders.forDestination("https", "other.example.com", 443).isEmpty())
+ assertTrue(CustomHeaders.forDestination("https", "cloud.example.com", 8443).isEmpty())
+ assertTrue(CustomHeaders.forDestination("https", "cloud.example.com.evil.net", 443).isEmpty())
+ assertTrue(CustomHeaders.forDestination("http", "cloud.example.com", 80).isEmpty())
+ assertTrue(CustomHeaders.forDestination(null, "cloud.example.com", 443).isEmpty())
+ }
+
+ @Test
+ fun originMatchesJavaScriptForm() {
+ assertEquals("https://cloud.example.com", CustomHeaders.originOf("HTTPS", "Cloud.Example.com", 443))
+ assertEquals("https://cloud.example.com:8443", CustomHeaders.originOf("https", "cloud.example.com", 8443))
+ assertNull(CustomHeaders.originOf("https", null, 443))
+ }
+
+ @Test
+ fun rejectsInvalidHeaders() {
+ fun register(
+ name: String,
+ value: String
+ ) = CustomHeaders.register("https://h", mapOf(name to value))
+
+ assertFalse(register("Authorization", "x"))
+ assertFalse(register("user-agent", "x"))
+ assertFalse(register("Host", "x"))
+ assertFalse(register("Bad Name", "x"))
+ assertFalse(register("X-Ok", "a\r\nInjected: 1"))
+ assertFalse(register("X-Ok", ""))
+ assertTrue(CustomHeaders.origins.isEmpty())
+ assertTrue(register("X-Ok", "value"))
+ }
+
+ @Test
+ fun apacheMethodGetsHeadersOnlyForRegisteredOrigin() {
+ CustomHeaders.register("https://cloud.example.com", headers)
+ val method = GetMethod("https://cloud.example.com/remote.php/dav")
+
+ CustomHeaders.applyTo(method)
+ assertEquals("secret", method.getRequestHeader("CF-Access-Client-Secret").value)
+
+ method.uri = URI("https://evil.example.net/x", true)
+ CustomHeaders.applyTo(method)
+ assertNull(method.getRequestHeader("CF-Access-Client-Secret"))
+ assertNull(method.getRequestHeader("CF-Access-Client-Id"))
+ }
+
+ @Test
+ fun okHttpDoesNotLeakHeadersOnCrossHostRedirect() {
+ registerHome()
+ foreign.enqueue(MockResponse.Builder().code(200).build())
+ home.enqueue(
+ MockResponse
+ .Builder()
+ .code(302)
+ .addHeader("Location", foreign.url("/landing").toString())
+ .build()
+ )
+
+ client.newCall(Request.Builder().url(home.url("/start")).build()).execute().close()
+
+ assertEquals("secret", home.takeRequest().headers["CF-Access-Client-Secret"])
+ val leaked = foreign.takeRequest()
+ assertNull(leaked.headers["CF-Access-Client-Secret"])
+ assertNull(leaked.headers["CF-Access-Client-Id"])
+ }
+
+ @Test
+ fun okHttpKeepsOperationHeadersForOtherHosts() {
+ CustomHeaders.register("https://cloud.example.com", mapOf("OCS-APIREQUEST" to "true", "Accept" to "x"))
+ foreign.enqueue(MockResponse.Builder().code(200).build())
+
+ client
+ .newCall(
+ Request
+ .Builder()
+ .url(foreign.url("/x"))
+ .header("OCS-APIREQUEST", "true")
+ .header("Accept", "a")
+ .build()
+ ).execute()
+ .close()
+
+ val received = foreign.takeRequest().headers
+ assertEquals("true", received["OCS-APIREQUEST"])
+ assertEquals("a", received["Accept"])
+ }
+
+ @Test
+ fun apacheMethodRemovesHeadersAfterRegistrationIsReplaced() {
+ CustomHeaders.register("https://cloud.example.com", mapOf("X-Old-Secret" to "old"))
+ val method = GetMethod("https://cloud.example.com/remote.php/dav")
+
+ CustomHeaders.applyTo(method)
+ assertEquals("old", method.getRequestHeader("X-Old-Secret").value)
+
+ CustomHeaders.register("https://cloud.example.com", mapOf("X-New-Secret" to "new"))
+ method.uri = URI("https://evil.example.net/x", true)
+ CustomHeaders.applyTo(method)
+
+ assertNull(method.getRequestHeader("X-Old-Secret"))
+ assertNull(method.getRequestHeader("X-New-Secret"))
+ }
+
+ @Test
+ fun apacheMethodKeepsOperationHeaders() {
+ CustomHeaders.register("https://cloud.example.com", headers)
+ val method = GetMethod("https://other.example.net/x")
+ method.setRequestHeader("Accept", "application/json")
+
+ CustomHeaders.applyTo(method)
+
+ assertEquals("application/json", method.getRequestHeader("Accept").value)
+ }
+
+ @Test
+ fun apacheMethodRestoresOverriddenOperationHeaderOnRedirect() {
+ CustomHeaders.register("https://cloud.example.com", mapOf("OCS-APIREQUEST" to "custom"))
+ val method = GetMethod("https://cloud.example.com/ocs")
+ method.setRequestHeader("OCS-APIREQUEST", "true")
+
+ CustomHeaders.applyTo(method)
+ assertEquals("custom", method.getRequestHeader("OCS-APIREQUEST").value)
+
+ method.uri = URI("https://other.example.net/ocs", true)
+ CustomHeaders.applyTo(method)
+ assertEquals("true", method.getRequestHeader("OCS-APIREQUEST").value)
+ }
+
+ @Test
+ fun internationalizedHostMatchesPunycode() {
+ assertEquals("https://xn--bcher-kva.example", CustomHeaders.originOf("https", "bücher.example", -1))
+ CustomHeaders.register("https://xn--bcher-kva.example", headers)
+
+ assertEquals(headers, CustomHeaders.forDestination("https", "xn--bcher-kva.example", 443))
+ assertEquals(headers, CustomHeaders.forDestination("https", "BÜCHER.example", 443))
+ }
+
+ @Test
+ fun unregisterStopsSending() {
+ CustomHeaders.register("https://cloud.example.com", headers)
+ CustomHeaders.unregister("https://cloud.example.com")
+
+ assertTrue(CustomHeaders.forDestination("https", "cloud.example.com", 443).isEmpty())
+ }
+}