Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions gradle/verification-metadata.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9941,6 +9941,14 @@
<sha256 value="e1abd7f1116cf5e0c59947693e2189208ec94296b2a3394c959e3511d399a7b0" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.squareup.okhttp3" name="mockwebserver3" version="5.5.1">
<artifact name="mockwebserver3-5.5.1.jar">
<sha256 value="0a93a4882bd8a7c5c707146d143cb73e7acb29a20f5f75a9767400d19368421c" origin="Generated by Gradle"/>
</artifact>
<artifact name="mockwebserver3-5.5.1.module">
<sha256 value="d3e9eb16c3205ee73e7239d51f0323112d9605fe47777e3730517ca53aad1a42" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.squareup.okhttp3" name="okhttp" version="4.12.0">
<artifact name="okhttp-4.12.0.jar">
<sha256 value="b1050081b14bb7a3a7e55a4d3ef01b5dcfabc453b4573a4fc019767191d5f4e0" origin="Generated by Gradle"/>
Expand Down
1 change: 1 addition & 0 deletions library/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
183 changes: 183 additions & 0 deletions library/src/main/java/com/nextcloud/common/CustomHeaders.kt
Original file line number Diff line number Diff line change
@@ -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<String, Map<String, String>> = emptyMap()

val origins: Set<String>
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<String, String>
): 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<String, String>
): 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<String, String> = originOf(scheme, host, port)?.let { byOrigin[it] }.orEmpty()

@JvmStatic
fun forOrigin(origin: String): Map<String, String> = 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, String>): String = JSONObject(headers).toString()

@JvmStatic
fun fromJson(json: String?): Map<String, String>? {
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
}
}
}
Original file line number Diff line number Diff line change
@@ -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())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ class NextcloudClient private constructor(
.hostnameVerifier { _: String?, _: SSLSession? -> true }
.fastFallback(true)
.proxy(proxy)
.addNetworkInterceptor(CustomHeadersInterceptor)
.build()
}
}
Expand Down
1 change: 1 addition & 0 deletions library/src/main/java/com/nextcloud/common/PlainClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ class PlainClient(
.hostnameVerifier { _: String?, _: SSLSession? -> true }
.fastFallback(true)
.proxy(proxy)
.addNetworkInterceptor(CustomHeadersInterceptor)
.build()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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++;
Expand Down
Loading