Skip to content
Merged
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
3 changes: 3 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,13 @@ android {
val amplitudeApiKey: String = properties.getProperty("amplitude.api.key")
val googleAdmobAppId: String = properties.getProperty("GOOGLE_ADMOB_APP_ID", "")
val googleAdmobUnitId: String = properties.getProperty("GOOGLE_ADMOB_UNIT_ID", "")
val allowedDomains: String = properties.getProperty("allowed.webview.domains", "notion.so,google.com")

buildConfigField("String", "GOOGLE_ADMOB_APP_ID", "\"$googleAdmobAppId\"")
buildConfigField("String", "GOOGLE_ADMOB_UNIT_ID", "\"$googleAdmobUnitId\"")
buildConfigField("String", "KAKAO_API_KEY", "\"$kakaoApiKey\"")
buildConfigField("String", "AMPLITUDE_API_KEY", "\"$amplitudeApiKey\"")
buildConfigField("String", "ALLOWED_WEBVIEW_DOMAINS", "\"$allowedDomains\"")
manifestPlaceholders["kakaoRedirectUri"] = "kakao$kakaoApiKey"
manifestPlaceholders["GOOGLE_ADMOB_APP_ID"] = googleAdmobAppId
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
Expand Down
14 changes: 13 additions & 1 deletion app/src/main/java/com/sopt/clody/core/login/KakaoLoginSdk.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,27 @@ import com.kakao.sdk.common.model.AuthError
import com.kakao.sdk.common.model.ClientError
import com.kakao.sdk.common.model.ClientErrorCause
import com.kakao.sdk.user.UserApiClient
import com.sopt.clody.R
import com.sopt.clody.core.security.login.LoginSecurityChecker
import kotlinx.coroutines.suspendCancellableCoroutine
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException

@Singleton
class KakaoLoginSdk @Inject constructor() : LoginSdk {
class KakaoLoginSdk @Inject constructor(
private val securityChecker: LoginSecurityChecker,
) : LoginSdk {
override suspend fun login(context: Context): Result<LoginAccessToken> = runCatching {
if (!securityChecker.isChromeInstalled(context)) {
throw LoginException.AuthException(context.getString(R.string.error_login_requires_chrome))
}

if (securityChecker.isDeviceRooted()) {
throw LoginException.AuthException(context.getString(R.string.error_login_rooted_device))
}

suspendCancellableCoroutine { continuation ->
val callback: (OAuthToken?, Throwable?) -> Unit = callback@{ token, throwable ->
if (!continuation.isActive) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.sopt.clody.core.security.login

import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import java.io.File
import javax.inject.Inject

/**
* 기본 보안 점검 구현체.
*
* 디바이스 루팅 여부 및 Chrome 브라우저 설치 여부를 확인하는 기능 구현.
*
*/

class DefaultLoginSecurityChecker @Inject constructor() : LoginSecurityChecker {

/**
* 디바이스가 루팅되었는지 여부를 검사.
* Step
* - `Build.TAGS`에 `test-keys`가 포함되어 있는지 확인
* - 루팅에 사용되는 바이너리 또는 앱의 존재 여부 검사
* - `which su` 명령어 실행 결과를 통해 `su` 명령어의 존재 여부 확인
*
* @return 디바이스가 루팅되었다면 `true`, 그렇지 않다면 `false`
*/
override fun isDeviceRooted(): Boolean {
val buildTags = Build.TAGS
if (buildTags != null && buildTags.contains("test-keys")) return true

val paths = arrayOf(
"/system/app/Superuser.apk",
"/sbin/su", "/system/bin/su", "/system/xbin/su",
"/data/local/xbin/su", "/data/local/bin/su",
"/system/sd/xbin/su", "/system/bin/failsafe/su",
"/data/local/su",
)
if (paths.any { File(it).exists() }) return true

return try {
Runtime.getRuntime().exec(arrayOf("/system/xbin/which", "su"))
.inputStream.bufferedReader().readLine() != null
} catch (e: Exception) {
false
}
Comment on lines +40 to +45
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Potential command injection vulnerability and incomplete detection.

The which su command execution has security and reliability issues:

  1. The command path /system/xbin/which may not exist on all devices
  2. The approach only checks if the command produces output, not if su actually exists

Consider a safer approach:

-        return try {
-            Runtime.getRuntime().exec(arrayOf("/system/xbin/which", "su"))
-                .inputStream.bufferedReader().readLine() != null
-        } catch (e: Exception) {
-            false
-        }
+        return try {
+            val process = Runtime.getRuntime().exec(arrayOf("which", "su"))
+            val exitCode = process.waitFor()
+            exitCode == 0
+        } catch (e: Exception) {
+            false
+        }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return try {
Runtime.getRuntime().exec(arrayOf("/system/xbin/which", "su"))
.inputStream.bufferedReader().readLine() != null
} catch (e: Exception) {
false
}
return try {
val process = Runtime.getRuntime().exec(arrayOf("which", "su"))
val exitCode = process.waitFor()
exitCode == 0
} catch (e: Exception) {
false
}
🧰 Tools
🪛 detekt (1.23.8)

[warning] 43-43: The caught exception is swallowed. The original exception could be lost.

(detekt.exceptions.SwallowedException)

🤖 Prompt for AI Agents
In
app/src/main/java/com/sopt/clody/core/security/login/DefaultLoginSecurityChecker.kt
around lines 40 to 45, the current code uses a hardcoded path to execute "which
su" that may not exist on all devices and only checks for output presence, which
is unreliable. Replace this with a safer method by searching for "su" in common
system paths programmatically or using a more robust API to verify if "su"
exists and is executable, avoiding direct command execution with fixed paths to
prevent command injection and improve detection accuracy.

}

/**
* 디바이스에 Chrome 브라우저가 설치되어 있는지 여부
*
* `com.android.chrome` 패키지의 존재 여부로 Chrome 설치 여부를 판단.
*
* @return Chrome이 설치되어 있다면 `true`, 아니라면 `false`
*/
override fun isChromeInstalled(context: Context): Boolean = try {
context.packageManager.getPackageInfo("com.android.chrome", 0)
true
} catch (e: PackageManager.NameNotFoundException) {
false
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.sopt.clody.core.security.login

import android.content.Context

interface LoginSecurityChecker {
fun isDeviceRooted(): Boolean
fun isChromeInstalled(context: Context): Boolean
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.sopt.clody.core.security.login

import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
abstract class SecurityModule {

@Binds
@Singleton
abstract fun bindLoginSecurityChecker(
impl: DefaultLoginSecurityChecker,
): LoginSecurityChecker
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.sopt.clody.core.security.weview

import android.content.Context
import android.content.Intent
import android.net.http.SslError
import android.webkit.SslErrorHandler
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient

class SecureWebViewClient(
private val context: Context,
private val allowedDomains: List<String> = listOf("notion.so", "forms.gle"),
) : WebViewClient() {

override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
val host = request.url.host ?: return true
val isSafeDomain = allowedDomains.any { host.contains(it) }
Comment on lines +17 to +18
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Potential security bypass with substring matching.

The current implementation uses host.contains(it) for domain checking, which could allow bypasses. For example, evil-notion.so.malicious.com would match notion.so.

Consider using exact domain matching or suffix checking:

-        val isSafeDomain = allowedDomains.any { host.contains(it) }
+        val isSafeDomain = allowedDomains.any { allowedDomain ->
+            host == allowedDomain || host.endsWith(".$allowedDomain")
+        }
🤖 Prompt for AI Agents
In app/src/main/java/com/sopt/clody/core/security/weview/SecureWebViewClient.kt
around lines 17 to 18, the domain check uses substring matching with
host.contains(it), which can be bypassed by malicious subdomains. Replace this
with exact domain matching or check if the host ends with the allowed domain
preceded by a dot to ensure only valid subdomains or the exact domain are
matched, preventing security bypasses.


return if (isSafeDomain) {
false
} else {
Intent(Intent.ACTION_VIEW, request.url).let {
context.startActivity(it)
}
Comment on lines +23 to +25
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Potential security risk with unverified intent launching.

Launching intents without verification could be exploited by malicious URLs (e.g., intent:// schemes or file URLs).

Add intent verification and safe launching:

-            Intent(Intent.ACTION_VIEW, request.url).let {
-                context.startActivity(it)
-            }
+            Intent(Intent.ACTION_VIEW, request.url).let { intent ->
+                if (intent.resolveActivity(context.packageManager) != null && 
+                    (request.url.scheme == "http" || request.url.scheme == "https")) {
+                    context.startActivity(intent)
+                }
+            }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Intent(Intent.ACTION_VIEW, request.url).let {
context.startActivity(it)
}
Intent(Intent.ACTION_VIEW, request.url).let { intent ->
if (intent.resolveActivity(context.packageManager) != null &&
(request.url.scheme == "http" || request.url.scheme == "https")) {
context.startActivity(intent)
}
}
🤖 Prompt for AI Agents
In app/src/main/java/com/sopt/clody/core/security/weview/SecureWebViewClient.kt
around lines 23 to 25, the code launches an intent directly from the URL without
verifying it, which poses a security risk. To fix this, add checks to verify the
intent's scheme and ensure it is safe before calling startActivity. Only allow
trusted schemes like http or https, and prevent launching intents with
potentially dangerous schemes such as intent:// or file://. Implement
conditional logic to validate the URL and safely launch the intent only if it
passes these checks.

true
}
}

override fun onReceivedSslError(view: WebView, handler: SslErrorHandler, error: SslError) {
handler.cancel()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,13 @@ class SignUpContract {
data class ToggleAllChecked(val checked: Boolean) : SignUpIntent()
data class ToggleServiceChecked(val checked: Boolean) : SignUpIntent()
data class TogglePrivacyChecked(val checked: Boolean) : SignUpIntent()

data class OpenWebView(val url: String) : SignUpIntent()
data object BackToTerms : SignUpIntent()
}

sealed interface SignUpSideEffect {
data object NavigateToTimeReminder : SignUpSideEffect
data class NavigateToWebView(val url: String) : SignUpSideEffect
data class ShowMessage(val message: String) : SignUpSideEffect
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ fun SignUpRoute(
viewModel: SignUpViewModel = mavericksViewModel(),
navigateToHome: () -> Unit,
navigateToPrevious: () -> Unit,
navigateToWebView: (String) -> Unit,
) {
val state by viewModel.collectAsState()
val context = LocalContext.current
Expand All @@ -29,7 +30,11 @@ fun SignUpRoute(
when (effect) {
is SignUpContract.SignUpSideEffect.NavigateToTimeReminder -> navigateToHome()
is SignUpContract.SignUpSideEffect.ShowMessage -> {
// 삐용삐용 에러 대응을 어떻게 할까요?
// TODO: Snackbar나 Dialog로 에러 메시지 처리
}

is SignUpContract.SignUpSideEffect.NavigateToWebView -> {
navigateToWebView(effect.url) // ✅ WebView 이동 처리
}
}
}
Expand Down Expand Up @@ -68,6 +73,9 @@ fun SignUpScreen(
onTogglePrivacy = { onIntent(SignUpContract.SignUpIntent.TogglePrivacyChecked(it)) },
onAgreeClick = { onIntent(SignUpContract.SignUpIntent.ProceedTerms) },
navigateToPrevious = navigateToPrevious,
navigateToWebView = { url ->
onIntent(SignUpContract.SignUpIntent.OpenWebView(url))
},
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ class SignUpViewModel @AssistedInject constructor(
is SignUpContract.SignUpIntent.ToggleAllChecked -> handleToggleAllChecked(intent)
is SignUpContract.SignUpIntent.ToggleServiceChecked -> handleToggleServiceChecked(intent)
is SignUpContract.SignUpIntent.TogglePrivacyChecked -> handleTogglePrivacyChecked(intent)
is SignUpContract.SignUpIntent.OpenWebView -> handleOpenWebView(intent.url)
SignUpContract.SignUpIntent.BackToTerms -> handleBackToTerms()
}
}
Expand Down Expand Up @@ -102,6 +103,10 @@ class SignUpViewModel @AssistedInject constructor(
setState { copy(privacyChecked = intent.checked) }
}

private suspend fun handleOpenWebView(url: String) {
_sideEffects.send(SignUpContract.SignUpSideEffect.NavigateToWebView(url))
}

private fun handleBackToTerms() {
setState {
copy(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,16 @@ import com.sopt.clody.presentation.utils.navigation.Route
fun NavGraphBuilder.signUpScreen(
navigateToHome: () -> Unit,
navigateToPrevious: () -> Unit,
navigateToWebView: (String) -> Unit,
) {
composable<Route.SignUp> {
SignUpRoute(
navigateToHome = navigateToHome,
navigateToPrevious = navigateToPrevious,
navigateToWebView = navigateToWebView,
)
}
}

fun NavController.navigateToSignUp(
navOptions: NavOptionsBuilder.() -> Unit = {},
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
Expand All @@ -28,7 +27,6 @@ import com.sopt.clody.presentation.ui.auth.component.checkbox.CustomCheckbox
import com.sopt.clody.presentation.ui.component.button.ClodyButton
import com.sopt.clody.presentation.ui.home.calendar.component.HorizontalDivider
import com.sopt.clody.presentation.ui.setting.screen.SettingOptionUrls
import com.sopt.clody.presentation.ui.setting.screen.onClickSettingOption
import com.sopt.clody.presentation.utils.base.BasePreview
import com.sopt.clody.presentation.utils.base.ClodyPreview
import com.sopt.clody.presentation.utils.extension.heightForScreenPercentage
Expand All @@ -44,8 +42,8 @@ fun TermsOfServicePage(
onTogglePrivacy: (Boolean) -> Unit,
onAgreeClick: () -> Unit,
navigateToPrevious: () -> Unit,
navigateToWebView: (String) -> Unit,
) {
val context = LocalContext.current
val isAgreeButtonEnabled = serviceChecked && privacyChecked

Scaffold(
Expand Down Expand Up @@ -114,14 +112,14 @@ fun TermsOfServicePage(
text = stringResource(R.string.terms_service_use),
checked = serviceChecked,
onCheckedChange = onToggleService,
onClickMore = { onClickSettingOption(context, SettingOptionUrls.TERMS_OF_SERVICE_URL) },
onClickMore = { navigateToWebView(SettingOptionUrls.TERMS_OF_SERVICE_URL) },
)
Spacer(modifier = Modifier.height(8.dp))
TermsCheckboxRow(
text = stringResource(R.string.terms_service_privacy),
checked = privacyChecked,
onCheckedChange = onTogglePrivacy,
onClickMore = { onClickSettingOption(context, SettingOptionUrls.PRIVACY_POLICY_URL) },
onClickMore = { navigateToWebView(SettingOptionUrls.PRIVACY_POLICY_URL) },
)
}
},
Expand Down Expand Up @@ -171,6 +169,7 @@ private fun TermsOfServicePagePreview() {
onTogglePrivacy = {},
onAgreeClick = {},
navigateToPrevious = {},
navigateToWebView = {},
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ import com.sopt.clody.presentation.ui.setting.navigation.navigateToSetting
import com.sopt.clody.presentation.ui.setting.navigation.navigateToWebView
import com.sopt.clody.presentation.ui.setting.navigation.notificationSettingScreen
import com.sopt.clody.presentation.ui.setting.navigation.settingScreen
import com.sopt.clody.presentation.ui.setting.navigation.webViewScreen
import com.sopt.clody.presentation.ui.splash.navigation.splashScreen
import com.sopt.clody.presentation.ui.webview.webViewScreen
import com.sopt.clody.presentation.ui.writediary.navigation.navigateToWriteDiary
import com.sopt.clody.presentation.ui.writediary.navigation.writeDiaryScreen
import com.sopt.clody.presentation.utils.navigation.safePopBackStack
Expand Down Expand Up @@ -73,6 +73,7 @@ fun ClodyNavHost(
signUpScreen(
navigateToHome = navController::navigateToTimeReminder,
navigateToPrevious = navController::safePopBackStack,
navigateToWebView = navController::navigateToWebView,
)
timeReminderScreen(
navigateToGuide = navController::navigateToGuide,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,9 @@ import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptionsBuilder
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
import com.sopt.clody.presentation.ui.setting.notificationsetting.screen.NotificationSettingRoute
import com.sopt.clody.presentation.ui.setting.screen.AccountManagementRoute
import com.sopt.clody.presentation.ui.setting.screen.SettingRoute
import com.sopt.clody.presentation.ui.setting.screen.WebViewRoute
import com.sopt.clody.presentation.utils.navigation.Route

fun NavGraphBuilder.settingScreen(
Expand Down Expand Up @@ -47,19 +45,6 @@ fun NavGraphBuilder.notificationSettingScreen(
}
}

fun NavGraphBuilder.webViewScreen(
navigateToPrevious: () -> Unit,
) {
composable<Route.WebView> { backStackEntry ->
backStackEntry.toRoute<Route.WebView>().apply {
WebViewRoute(
encodedUrl = encodedUrl,
navigateToPrevious = navigateToPrevious,
)
}
}
}

fun NavController.navigateToSetting(
navOptions: NavOptionsBuilder.() -> Unit = {},
) {
Expand Down
Loading