From 8f3d7aad77a3cdddc445f6f3facde079224bc565 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Fri, 8 May 2026 23:38:06 +0200 Subject: [PATCH] Redact OAuth secrets from logs --- .../authentication/AccountAuthenticator.java | 6 +- .../authentication/LoginActivity.kt | 12 ++-- .../lib/common/http/logging/LogInterceptor.kt | 71 +++++++++++++++++-- .../oauth/TokenRequestRemoteOperation.kt | 6 +- .../oauth/responses/TokenResponse.kt | 20 +++++- .../oauth/model/TokenResponse.kt | 20 +++++- 6 files changed, 114 insertions(+), 21 deletions(-) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/AccountAuthenticator.java b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/AccountAuthenticator.java index 8f408e6ea6..56f6c1c7f4 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/AccountAuthenticator.java +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/AccountAuthenticator.java @@ -329,8 +329,7 @@ private String refreshToken( return null; } - Timber.d("Ready to exchange for new tokens. Account: [ %s ], Refresh token: [ %s ]", account.name, - refreshToken); + Timber.d("Ready to exchange refresh token for new tokens. Account: [ %s ]", account.name); String baseUrl = accountManager.getUserData(account, AccountUtils.Constants.KEY_OC_BASE_URL); @@ -440,8 +439,7 @@ private String handleSuccessfulRefreshToken( } accountManager.setUserData(account, KEY_OAUTH2_REFRESH_TOKEN, refreshTokenToUseFromNowOn); - Timber.d("Token refreshed successfully. New access token: [ %s ]. New refresh token: [ %s ]", - newAccessToken, refreshTokenToUseFromNowOn); + Timber.d("Token refreshed successfully for account [ %s ]", account.name); return newAccessToken; } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt index ea440310d9..e48b03204d 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt @@ -133,7 +133,7 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - Timber.d("onCreate called with intent data: ${intent.data}, isTaskRoot: $isTaskRoot") + Timber.d("onCreate called with intent data present: ${intent.data != null}, isTaskRoot: $isTaskRoot") if (handleOAuthRedirectOnCreate()) return @@ -747,7 +747,7 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted override fun onNewIntent(intent: Intent?) { super.onNewIntent(intent) intent?.let { - Timber.d("onNewIntent received with data: ${it.data}") + Timber.d("onNewIntent received with data present: ${it.data != null}") if (!::binding.isInitialized) { Timber.w("onNewIntent received before binding initialized, ignoring OAuth response") return @@ -762,12 +762,12 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted val state = intent.data?.getQueryParameter("state") if (state != authenticationViewModel.oidcState) { - Timber.e("OAuth: state mismatch (expected=${authenticationViewModel.oidcState}, got=$state). Finishing.") + Timber.e("OAuth state mismatch. Finishing.") showMessageInSnackbar(message = getString(R.string.auth_oauth_error)) finish() } else { if (authorizationCode != null) { - Timber.d("Authorization code received [$authorizationCode]. Let's exchange it for access token") + Timber.d("Authorization code received. Exchanging it for access token") exchangeAuthorizationCodeForTokens(authorizationCode) } else { val authorizationError = intent.data?.getQueryParameter("error") @@ -851,12 +851,12 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted when (val uiResult = it.peekContent()) { is UIResult.Loading -> {} is UIResult.Success -> { - Timber.d("Tokens received ${uiResult.data}, trying to login, creating account and adding it to account manager") + Timber.d("Tokens received, trying to login, creating account and adding it to account manager") val tokenResponse = uiResult.data ?: return@observe // Extract preferred_username from id_token for login_hint on re-login preferredUsername = extractPreferredUsernameFromIdToken(tokenResponse.idToken) - Timber.d("Preferred username from id_token: $preferredUsername") + Timber.d("Preferred username extracted from id_token: ${preferredUsername != null}") // When webfinger provides a client_id without dynamic registration, // store it so AccountAuthenticator can use it for token refresh diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/logging/LogInterceptor.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/logging/LogInterceptor.kt index f794989d20..adeb23a3bb 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/logging/LogInterceptor.kt +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/logging/LogInterceptor.kt @@ -28,6 +28,7 @@ import eu.opencloud.android.lib.common.http.HttpConstants.OC_X_REQUEST_ID import com.squareup.moshi.Moshi import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import okhttp3.Headers +import okhttp3.HttpUrl import okhttp3.Interceptor import okhttp3.MediaType import okhttp3.Request @@ -74,7 +75,7 @@ class LogInterceptor : Interceptor { info = RequestInfo( id = requestId, method = request.method, - url = request.url.toString(), + url = redactUrl(request.url), ) ) ) @@ -88,10 +89,16 @@ class LogInterceptor : Interceptor { if (auxHeaders.contains(AUTHORIZATION_HEADER)) { val authHeaderList = auxHeaders[AUTHORIZATION_HEADER]!!.split(" ") val authType = authHeaderList[0] - val authInfo = if (redactAuthHeader) "[redacted]" else authHeaderList[1] + val authInfo = if (redactAuthHeader) REDACTED else authHeaderList.getOrNull(1).orEmpty() auxHeaders[AUTHORIZATION_HEADER] = "$authType $authInfo" } - return auxHeaders + return auxHeaders.mapValues { (header, value) -> + if (SENSITIVE_HEADER_NAMES.any { it.equals(header, ignoreCase = true) }) { + REDACTED + } else { + redactSensitiveData(value) + } + } } private fun getRequestBodyString(requestBodyParam: RequestBody?): String? { @@ -110,7 +117,7 @@ class LogInterceptor : Interceptor { val contentType = requestBody.contentType() val charset: Charset = contentType?.charset(StandardCharsets.UTF_8) ?: StandardCharsets.UTF_8 if (contentType.isLoggable()) { - return buffer.readString(charset) + return redactSensitiveData(buffer.readString(charset)) } else if (requestBody.contentLength() > 0) { return "$BINARY_OMITTED ${requestBody.contentLength()} $BYTES" } @@ -147,7 +154,7 @@ class LogInterceptor : Interceptor { status = response.code, version = response.protocol.toString(), ), - url = request.url.toString(), + url = redactUrl(request.url), ) ) ) @@ -158,7 +165,7 @@ class LogInterceptor : Interceptor { private fun getResponseBodyString(contentType: MediaType?, contentLength: Int, responseBody: String): String? = if (contentType?.isLoggable() == true) { - responseBody + redactSensitiveData(responseBody) } else if (contentLength > 0) { "$BINARY_OMITTED $contentLength $BYTES" } else { @@ -176,11 +183,63 @@ class LogInterceptor : Interceptor { return String.format(DURATION_FORMAT, hours, minutes, seconds, auxMillis) } + private fun redactUrl(url: HttpUrl): String { + var redactedUrl = url.newBuilder() + url.queryParameterNames + .filter(::isSensitiveField) + .forEach { redactedUrl = redactedUrl.setQueryParameter(it, REDACTED) } + return redactedUrl.build().toString() + } + + private fun redactSensitiveData(value: String): String = + FORM_FIELD_REGEX.replace( + JSON_FIELD_REGEX.replace( + TO_STRING_FIELD_REGEX.replace(value) { + "${it.groupValues[1]}$REDACTED" + } + ) { + "${it.groupValues[1]}$REDACTED${it.groupValues[4]}" + } + ) { + "${it.groupValues[1]}${it.groupValues[2]}=$REDACTED" + } + + private fun isSensitiveField(field: String): Boolean = + SENSITIVE_FIELD_NAMES.any { it.equals(field, ignoreCase = true) } + companion object { var httpLogsEnabled: Boolean = false var redactAuthHeader: Boolean = true + private const val REDACTED = "[redacted]" private const val LIMIT_BODY_LOG: Long = 1000000 private const val BINARY_OMITTED = "<-- Body end for response -- Binary -- Omitted:" private const val BYTES = "bytes -->" + private val SENSITIVE_HEADER_NAMES = setOf( + "Cookie", + "Set-Cookie", + "X-Auth-Token", + ) + private val SENSITIVE_FIELD_NAMES = setOf( + "access_token", + "accessToken", + "authorization_code", + "authorizationCode", + "client_secret", + "clientSecret", + "code", + "code_verifier", + "codeVerifier", + "id_token", + "idToken", + "password", + "passcode", + "pattern", + "refresh_token", + "refreshToken", + ) + private val SENSITIVE_FIELD_PATTERN = SENSITIVE_FIELD_NAMES.joinToString("|") { Regex.escape(it) } + private val JSON_FIELD_REGEX = Regex("""(?i)("($SENSITIVE_FIELD_PATTERN)"\s*:\s*")([^"]*)(")""") + private val FORM_FIELD_REGEX = Regex("""(?i)(^|[?&])($SENSITIVE_FIELD_PATTERN)=([^&#]*)""") + private val TO_STRING_FIELD_REGEX = Regex("""(?i)(\b($SENSITIVE_FIELD_PATTERN)\s*=\s*)([^,)\s&]+)""") } } diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/oauth/TokenRequestRemoteOperation.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/oauth/TokenRequestRemoteOperation.kt index a35b8bef0a..c1ea87a355 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/oauth/TokenRequestRemoteOperation.kt +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/oauth/TokenRequestRemoteOperation.kt @@ -63,20 +63,20 @@ class TokenRequestRemoteOperation( val responseBody = postMethod.getResponseBodyAsString() return if (status == HTTP_OK && responseBody != null) { - Timber.d("Successful response $responseBody") + Timber.d("Successful token response received") // Parse the response val moshi: Moshi = Moshi.Builder().build() val jsonAdapter: JsonAdapter = moshi.adapter(TokenResponse::class.java) val tokenResponse: TokenResponse? = jsonAdapter.fromJson(responseBody) - Timber.d("Get tokens completed and parsed to $tokenResponse") + Timber.d("Get tokens completed and parsed") RemoteOperationResult(RemoteOperationResult.ResultCode.OK).apply { data = tokenResponse } } else { - Timber.e("Failed response while getting tokens from the server status code: $status; response message: $responseBody") + Timber.e("Failed response while getting tokens from the server status code: $status") RemoteOperationResult(postMethod) } diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/oauth/responses/TokenResponse.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/oauth/responses/TokenResponse.kt index 313d0c0645..637d134c13 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/oauth/responses/TokenResponse.kt +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/oauth/responses/TokenResponse.kt @@ -44,4 +44,22 @@ data class TokenResponse( val idToken: String? = null, @Json(name = "additional_parameters") val additionalParameters: Map? -) +) { + override fun toString(): String = + "TokenResponse(" + + "accessToken=$REDACTED, " + + "expiresIn=$expiresIn, " + + "refreshToken=${refreshToken.redactedOrNull()}, " + + "tokenType=$tokenType, " + + "userId=$userId, " + + "scope=$scope, " + + "idToken=${idToken.redactedOrNull()}, " + + "additionalParameters=$additionalParameters" + + ")" + + private fun String?.redactedOrNull(): String = if (this == null) "null" else REDACTED + + private companion object { + const val REDACTED = "[redacted]" + } +} diff --git a/opencloudDomain/src/main/java/eu/opencloud/android/domain/authentication/oauth/model/TokenResponse.kt b/opencloudDomain/src/main/java/eu/opencloud/android/domain/authentication/oauth/model/TokenResponse.kt index 44dcad9fbc..2bcddf0193 100644 --- a/opencloudDomain/src/main/java/eu/opencloud/android/domain/authentication/oauth/model/TokenResponse.kt +++ b/opencloudDomain/src/main/java/eu/opencloud/android/domain/authentication/oauth/model/TokenResponse.kt @@ -28,4 +28,22 @@ data class TokenResponse( val scope: String?, val idToken: String? = null, val additionalParameters: Map? -) +) { + override fun toString(): String = + "TokenResponse(" + + "accessToken=$REDACTED, " + + "expiresIn=$expiresIn, " + + "refreshToken=${refreshToken.redactedOrNull()}, " + + "tokenType=$tokenType, " + + "userId=$userId, " + + "scope=$scope, " + + "idToken=${idToken.redactedOrNull()}, " + + "additionalParameters=$additionalParameters" + + ")" + + private fun String?.redactedOrNull(): String = if (this == null) "null" else REDACTED + + private companion object { + const val REDACTED = "[redacted]" + } +}