인증 서버에 2단계 인증을 붙이고 로그를 훑어보다가, Google OTP 등록 페이지의 응답이 통째로 로그에 찍혀 있는 걸 봤습니다. 시크릿이 평문으로 두 번 들어 있었습니다.
응답 바디 로깅에는 마스킹이 걸려 있었습니다. password 도 secret 도 목록에 있었는데 안 걸렸습니다.

응답 바디 로깅의 마스킹 정규식은 모양을 안다고 전제한다
문제가 된 필터는 흔한 모양이다. ContentCachingRequestWrapper 와 ContentCachingResponseWrapper 로 요청·응답 바디를 캐시해두고, 나중에 꺼내서 한 줄로 남긴다.
log.info("[REQ] {} {} | Params: {} | Body: {}",
request.getMethod(), request.getRequestURI(),
maskSensitiveData(request.getQueryString()), maskedRequestBody);
log.info("[RES] Status: {} | Body: {}",
response.getStatus(), maskSensitiveData(getPayload(response.getContentAsByteArray())));
마스킹은 이렇게 생겼다.
masked = masked.replaceAll("(\"password\"\\s*:\\s*\")[^\"]+(\")", "$1********$2");
masked = masked.replaceAll("(\"(?:secret|totpSecret)\"\\s*:\\s*\")[^\"]+(\")", "$1***MASKED***$2");
"secret":"값" 이라는 JSON 키 모양을 찾는다. 그런데 TOTP 등록 페이지는 서버 렌더 HTML 이다. 시크릿이 이렇게 들어 있다.
<code>JBSWY3DPEHPK3PXP</code>
<img src="otpauth://totp/demo:admin?secret=JBSWY3DPEHPK3PXP&issuer=demo"/>
정규식이 찾는 모양이 아니다. 마스킹은 정상 동작했고, 해당하는 게 없어서 아무것도 안 지웠을 뿐이다.
테스트 1 — 그대로 남기면 무엇이 찍히는지
엔드포인트 세 개짜리 최소 프로젝트로 재현했다. TOTP 등록 HTML, OTT 코드 제출(form), 일반 JSON API.
[REQ] GET /admin/mfa/totp/setup | Body:
[RES] Status: 200 | Body: <html><body>
<h1>Google OTP 등록</h1>
<p>앱에 아래 키를 등록하세요.</p>
<code>JBSWY3DPEHPK3PXP</code>
<img src="otpauth://totp/demo:admin?secret=JBSWY3DPEHPK3PXP&issuer=demo"/>
</body></html>
[REQ] POST /admin/ott/login | Body: token=482913
[RES] Status: 200 | Body: {"result":"ok"}
[REQ] GET /api/me | Body:
[RES] Status: 200 | Body: {"username":"admin","password":"********","secret":"JBSWY3DPEHPK3PXP"}
세 줄 다 문제가 있다.
HTML 응답에는 시크릿이 두 번 들어갔다. 그 시크릿만 있으면 앱을 새로 등록해 6자리 코드를 계속 만들어낼 수 있으니, 2단계 인증이 통째로 무력화된다.
token=482913 은 방금 발송한 일회용 코드다. form 마스킹 목록에 token 이 없어서 그대로 남았다.
마지막 JSON 에서는 password 만 지워졌다. 재현 프로젝트의 before 모드에는 secret 규칙을 넣지 않았는데, 목록에서 하나 빠지면 딱 이렇게 된다는 걸 같이 보려고 그렇게 뒀다.
왜 한쪽만 노출됐나
이 필터에는 제외 경로가 있었다.
if (uri.startsWith("/actuator")
|| isBinaryOrStaticRequest(request)
|| uri.contains(externalLoginPath)
|| uri.contains("/.well-known/")
|| uri.contains(adminLoginPath)
|| uri.contains(dtxLoginPath)) {
filterChain.doFilter(request, response);
return;
}
그리고 정적 리소스 판정에 uri.startsWith("/auth") 가 들어 있었다. external 사용자 화면이 전부 /auth/** 라서, external 쪽 TOTP 등록은 애초에 로깅을 타지 않았다.
노출된 건 /admin/mfa/totp/setup 하나였다. 같은 기능인데 경로 접두사가 달라서 한쪽만 새고 있었던 것이다.
경로로 거르는 방식의 문제가 여기 다 있다. 어디를 빼먹었는지는 빼먹은 사람 눈에 안 보인다. external 을 제외해둔 덕에 admin 이 새고 있다는 사실도 늦게 알았다.
테스트 2 — 기록할 것을 고른다
지울 것을 하나씩 추가하는 방식으로는 다음에 또 새로운 형식이 들어온다. 방향을 뒤집었다.
응답 바디는 content-type 이 JSON 일 때만 기록한다. 나머지는 내용 대신 크기만 남긴다.
private String resolveResponseBodyForLog(ContentCachingResponseWrapper response) {
byte[] body = response.getContentAsByteArray();
if (body.length == 0) {
return "";
}
String contentType = response.getContentType();
if (contentType == null || !contentType.contains("json")) {
return "[" + (contentType != null ? contentType : UNKNOWN) + ", " + body.length + " bytes 생략]";
}
return maskSensitiveData(getPayload(body));
}
같은 요청을 다시 보낸 결과다.
[REQ] GET /admin/mfa/totp/setup | Body:
[RES] Status: 200 | Body: [text/html;charset=UTF-8, 205 bytes 생략]
[REQ] POST /admin/ott/login | Body: token=***MASKED***
[RES] Status: 200 | Body: {"result":"ok"}
[REQ] GET /api/me | Body:
[RES] Status: 200 | Body: {"password":"********","username":"admin","secret":"***MASKED***"}
HTML 은 크기만 남았다. 디버깅에 필요한 정보(상태 코드, 응답 크기)는 그대로 있고 내용만 빠졌다.
마스킹도 같이 손봤다. form 목록에 token·password·code 를, JSON 목록에 secret·totpSecret 을 넣었다.
masked = masked.replaceAll(
"((?:refresh_token|access_token|code|client_secret|token|password)=)([^&]+)", "$1***MASKED***");
두 개가 같이 필요하다. content-type 판정은 모르는 형식을 막고, 마스킹은 JSON 안의 아는 필드를 지운다. 하나만으로는 안 된다.
테스트 3 — 목록에 없던 형식이 새로 들어오면
“목록을 늘리면 되지 않나”를 확인해봤다. 재현 프로젝트에 레거시 XML 응답을 하나 추가했다.
@GetMapping(value = "/legacy/user.xml", produces = MediaType.APPLICATION_XML_VALUE)
public String legacyXml() {
return """
<user>
<loginId>admin</loginId>
<password>pw1234</password>
<totpSecret>%s</totpSecret>
</user>
""".formatted(TOTP_SECRET);
}
password 와 totpSecret 은 둘 다 이미 마스킹 목록에 있는 단어다. 그대로 남기는 쪽으로 돌린 결과다.
[REQ] GET /legacy/user.xml | Body:
[RES] Status: 200 | Body: <user>
<loginId>admin</loginId>
<password>pw1234</password>
<totpSecret>JBSWY3DPEHPK3PXP</totpSecret>
</user>
둘 다 평문으로 나왔다. 목록에 단어가 있고 없고의 문제가 아니라 모양의 문제라서 그렇다. "password":"pw1234" 를 찾는 정규식에 <password>pw1234</password> 는 걸리지 않는다.
기록할 것을 고르는 쪽으로 돌리면, XML 규칙을 따로 넣지 않았는데도 이렇게 된다.
[REQ] GET /legacy/user.xml | Body:
[RES] Status: 200 | Body: [application/xml;charset=UTF-8, 116 bytes 생략]
새 형식이 들어올 때마다 목록을 고쳐야 하는 쪽과, 아무것도 안 해도 되는 쪽의 차이가 이만큼이다.
정리
마스킹 목록을 늘리는 건 끝이 없다. 새 화면이 하나 생기면 새 모양이 하나 생기고, 그때마다 목록에 넣는 걸 기억해야 한다. 기억에 의존하는 방어는 언젠가 진다.
기록할 것을 고르는 쪽은 기본값이 “기록 안 함”이다. 모르는 형식이 새로 들어와도 알아서 빠진다.
로그는 한번 남으면 회수가 안 된다. 파일로도 가고 수집기로도 가고, 접근 권한은 애플리케이션보다 넓은 경우가 많다.
응답 바디 로깅을 켜둔 서비스라면 지금 무엇이 찍히고 있는지 한번 열어볼 만하다.
계정 상태 검사도 비슷한 모양이었다. 인증 수단마다 검사를 붙이는 대신 모든 경로가 지나는 한 곳으로 모았다.
그 이야기는 Spring Security 계정 상태 검사 단일화 에 적어뒀다.
같은 인증 수단을 붙이다 토큰 발급이 통째로 죽었던 건은 Jackson 직렬화 허용 목록 에 있다.