payment-lab 기술적 이슈 -1- 로깅 중 비밀번호 노출의 위험성 및 대비책
회원가입은 기능을 구현하는 것 자체는 매우 간단합니다. 그러나, 회원가입을 안전하게 수행하는 방법을 적용하여 보안을 강화하고자 한다면 그 기능은 점점 더 어려워집니다.
회원가입을 안전하게 수행하는 방법은 여러 가지가 있지만, 저는 그중에서 회원가입 과정에서 로깅을 수행할 때 비밀번호가 노출되었을 때의 위험성과 그 대응방안을 모색하고자 합니다. 먼저 문제가 발생할 수 있는 코드부터 보여드리겠습니다.
class AccountRegister(
private val accountRepository: AccountRepository,
private val encrypt: PasswordEncrypt
) {
fun register(email: String, password: String, username: String, phoneNumber: String, roles: MutableSet<Role> = hashSetOf(
Role.USER)): Account {
if (accountRepository.existByEmail(email))
throw DuplicatedEmailException()
val account = Account.register(email, encrypt.encode(password), username, phoneNumber, roles)
accountRepository.save(account)
return account
}
//....
}
로직의 흐름 자체는 문제가 없어 보입니다. 이메일 중복 체크를 통해 이미 가입된 회원이 있는지 확인하고, 입력받은 값들을 활용하여 새로운 사용자 엔티티 모델을 초기화하여 데이터베이스에 저장하는 코드입니다.
로직 자체는 문제가 없겠지만, 만약에 회원가입을 포함한 모든 기능의 동작 과정을 모니터링하기 위해 단계별로 로깅을 해야 할 경우, 치명적인 문제가 발생할 수 있습니다. 바로 암호화되기 이전의 '비밀번호'를 출력하는 경우죠. 아래의 예시를 들겠습니다.
@Service
@Transactional
class AccountService(
private val accountRegister: AccountRegister,
private val accountValidator: AccountValidator,
private val accountLoginProcessor: AccountLoginProcessor,
private val tokenGenerator: TokenGenerator,
private val tokenReIssuer: TokenReIssuer,
private val env: Environment
) {
private val log = LoggerFactory.getLogger(AccountService::class.java)
fun register(command: RegisterAccount) {
log.info("회원 가입 시작 : $command")
val account = accountRegister.register(command.email, command.password, command.username, command.phoneNumber)
accountRegister.registerConfirm(account.emailCheckToken!!, account.email)
}
}
앞서 예시로 든 AccountRegister를 di하여 회원가입을 수행하는 AccountService.register입니다.
이미 눈치채신 분이 계시겠지만, 이렇게 로그를 출력하는 코드를 입력하면, 나중에 서버에 띄우고 회원가입을 수행했을 때 이렇게 출력이 될 겁니다.
2023-11-11T11:44:50.338+09:00 INFO 58486 --- [-nio-443-exec-6] o.c.p.a.application.AccountService : 회원 가입 시작 : RegisterAccount(email=hello@gmail.com, password=qwer1234, username=helloUsername, phoneNumber=010-1234-1234)
코틀린의 경우 data class를 활용하면 별도로 toString() 함수를 재정의하지 않아도 됩니다. 그러나 이러한 편리함은 경우에 따라 위와 같이 비밀번호를 노출시키는 어이없는 실수를 하게 되는 경우도 발생합니다.
물론 실무에서는 이러한 실수를 하지 않도록 내부적으로 규정을 확립해서 최대한 이런 일이 없도록 합의를 하게 됩니다. 그러나, 아무리 규정을 잘 만들어도 사람은 언젠가 실수를 하기 마련이고, 비밀번호가 노출되는 상황은 이러한 실수로 인해 발생되는 경우가 대부분입니다. 따라서, 회원가입 과정을 로깅하는 데 있어서 비밀번호 노출을 막기 위해 어느 정도의 기술적인 안전장치는 있어야 할 겁니다. 이러한 안전장치를 마련할 수 있는 방법으로 무엇이 있을까요? 간단히 정리해 보면 다음과 같습니다.
- toString() 재정의
- 커스텀 Getter 메소드
- 로그 출력 레벨 제어
1. toString() 재정의
보통 개발의 편의성을 위해 객체를 구성하면, 객체의 인스턴스를 바로 출력했을 때, 필드를 일일이 출력해야 하는 수고를 덜고자 합니다. 인텔리제이와 같은 IDE를 활용하면 좀 더 편리하게 toString()을 재정의할 수 있습니다. 비밀번호와 같은 민감정보도 그대로 노출시키기 때문에, 조금은 주의해서 사용해야 합니다.
스프링 시큐리티에서 제공하는 User 클래스의 경우, 아예 비밀번호 필드를 하드코딩으로 마스킹 처리를 합니다.
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append(super.toString()).append(": ");
sb.append("Username: ").append(this.username).append("; ");
sb.append("Password: [PROTECTED]; ");
//....
return sb.toString();
}
Payment-lab의 경우, 좀 더 유연한 비즈니스 로직을 구성하기 위해 사용자 도메인을 스프링 시큐리티의 User가 아닌, Account로 직접 관리하기로 했습니다. (개인적으로 상속 비용을 치르는 것 자체를 선호하지 않는 것도 있습니다..)
그래서 제 경우에는 Account의 toString()을 직접 재정의하기로 했습니다.
override fun toString(): String {
return "RegisterAccount(email='$email', password='[PROTECTED]', username='$username', phoneNumber='$phoneNumber')"
}
이렇게 재정의를 수행하고 다시 AccountService.register함수를 실행하면 다음과 같이 로그를 출력합니다.
2023-11-11T17:39:24.231+09:00 INFO 60725 --- [-nio-443-exec-6] o.c.p.a.application.AccountService : 회원 가입 시작 : RegisterAccount(email='hello@gmail.com', password='[PROTECTED]', username='helloUsername', phoneNumber='010-1234-1234')
이제 더 이상 인스턴스 자체를 출력해도 사용자가 입력했던 비밀번호를 그대로 출력하지 않습니다. 이제 이걸로 완전히 해결이 된 걸까요?
2. 커스텀 Getter 메소드
toString()으로 비밀번호 출력을 어느 정도 막을 수 있지만, 아래와 같은 상황을 한 번 보죠.
fun register(command: RegisterAccount) {
log.debug("회원 가입 시작, 비밀번호 암호화 이전 상태 확인 : ${command.password}")
val account = accountRegister.register(command.email, command.password, command.username, command.phoneNumber)
accountRegister.registerConfirm(account.emailCheckToken!!, account.email)
}
개발 단계에서는 코드가 의도대로 작 동작하고 있는지 확인하기 위해 이렇게 debug 로그를 찍는 경우가 종종 있습니다. 정석대로라면 profile 별로 프로퍼티 파일을 분류하고, dev, local 환경에서만 debug 로그 출력을 허용합니다. 이 부분이 제대로 지켜지고 있다면, 크게 문제는 없겠지만, 정말 만약에 prod 환경에서도 debug 로그 출력을 허용한다면, 회원가입을 수행하는 사용자의 비밀번호는 바로 평문으로 출력될 것입니다.
그렇다면, 당장 생각나는 방법은 이 RegisterAccount 클래스에서 password의 getter를 커스터마이징을 수행하는 것이죠.
data class RegisterAccount(
val email: String,
private val _password: String,
val username: String,
val phoneNumber: String
) {
val password: String
get() = "[PROTECTED]"
override fun toString(): String {
return "RegisterAccount(email='$email', password='[PROTECTED]', username='$username', phoneNumber='$phoneNumber')"
}
}
자, 이렇게 getter를 커스터마이징하면, 로그 출력을 수행하더라도 password는 평문이 출력되지 않을 것입니다. 하지만... 이렇게 하면 본래의 목적인 '디버깅'을 수행하기가 어려울뿐더러 registerregister 함수에서는 사용자가 어떤 비밀번호를 입력하더라도 '[PROTECTED]'를 받게 될 것입니다.
그렇다면 대체 어떻게 하면 될까요?
3. 로그 출력 레벨 제어
위에서 언급한 로그는 디버깅용으로만 사용합니다. 대부분의 개발팀은 로그 레벨을 dev 환경까지만 debug를 허용하고, prod 환경에서는 info까지만 허용하도록 합니다. 그렇다면, 개발팀이 이러한 규칙을 잘 지킬 수 있도록, 애초에 prod 환경에 로그 출력 레벨에 debug를 허용할 경우 빌드를 실패하게 하면 됩니다.
로그 출력 레벨을 제어하는 방법은 다양하지만, 저의 경우는 간단하게 코틀린 코드를 활용하기로 했습니다.
현재 진행 중인 Payment-lab의 main 함수를 예로 들겠습니다.
@SpringBootApplication
class PaymentsLabApplication: ApplicationListener<ApplicationReadyEvent> {
override fun onApplicationEvent(event: ApplicationReadyEvent) {
val env = event.applicationContext.environment
val rootLoggingLevel = env.getProperty("logging.level.root", String::class.java, "NOT_SET")
val applicationLoggingLevel = env
.getProperty("logging.level.org.collaborators.paymentslab.*", String::class.java, "NOT_SET")
if (isProfileProduction(env) && isLoggingLevelDebug(rootLoggingLevel, applicationLoggingLevel)) {
throw IllegalStateException("production 환경에서는 로그 출력 레벨을 'debug'로 설정해선 안됩니다.")
}
}
private fun isLoggingLevelDebug(loggingLevel: String, applicationLoggingLevel: String) =
loggingLevel.equals("DEBUG", ignoreCase = true)
|| applicationLoggingLevel.equals("DEBUG", ignoreCase = true)
private fun isProfileProduction(env: ConfigurableEnvironment) = "prod" in env.activeProfiles
companion object {
@JvmStatic
fun main(args: Array<String>) {
SpringApplication.run(PaymentsLabApplication::class.java, *args)
}
}
}
최대한 단순하게 구성해 보았습니다. production 환경에 프로파일을 prod로 지정하고, 로그 레벨을 'debug'인 경우가 발견되면 예외를 출력하고 애플리케이션을 종료시키는 코드입니다.
이렇게 구성하면, 적어도 개발자가 디버그 로그로 비밀번호 입력 및 인코딩이 잘되는지 확인하는 디버그 로그를 출력하다가 실수로 비밀번호를 production 환경에 내놓고 다니는 어이없는 실수는 줄일 수 있을 겁니다.
마무리...
'로깅 중 비밀번호 노출의 위험성 및 대비책'을 마련하기 위해 3가지 시도를 해보았습니다. toString()을 재정의하여, 비밀번호 필드를 그대로 출력하는 경우를 대비하였고, getter를 커스텀하려 했으나, dto의 getter를 하드코딩으로 커스텀하는 것은 비즈니스 로직을 구성하는데 큰 의미가 없었습니다.
그래서 로그 출력 레벨을 적어도 prod 환경에서는 debug를 지정하지 못하게 하는 장치를 마련해 두었습니다. 그렇다면.. 완전히 해결이 되었을까요? 아쉽게도 아닙니다. 실수로 비밀번호를 출력하게 하는 경우도 있지만, 빌런 개발자라면 충분히 고객의 비밀번호를 탈취하기 위해 일부러 비밀번호를 출력하여 수집하는 사례도 있을 겁니다. 따라서, 기술력이 뒷받침된다면, 배포하기 전에 '정적 테스트 도구'를 활용하여 password를 출력하는 코드를 찾아서 배포를 못하게 막는 방법도 있겠습니다.