클라이언트가 보낸 문자열을 로그에 그대로 찍고 있었다
LinkSquare 랜챗 서버의 신고 처리 로직에 거부 로그를 하나 추가했다. 신고 대상이 유효하지 않을 때 무슨 일이 있었는지 남기려는 목적이었다. 별생각 없이 넣은, 흔한 디버깅용 로그 한 줄이었다.
console.error(`... 신고 거부: reporter=${id} target=${payload.partnerId} ...`)
여기서 payload.partnerId는 클라이언트가 보낸 값이다. 서버는 이 값이 문자열이라는 것만 확인했을 뿐, 길이 제한도 문자 제한도 걸어두지 않았다. 정상적인 클라이언트라면 partnerId는 항상 짧은 식별자 형태로만 온다. 문제는 “정상적인 클라이언트라면”이라는 전제가 서버 코드 안에서는 아무 의미가 없다는 점이다. 랜챗은 로그인 없이도 쓸 수 있게 설계돼 있어서, 이 요청을 보내는 쪽이 실제로 우리가 만든 클라이언트인지, 요청 형식만 흉내 낸 다른 프로그램인지 서버는 구분할 방법이 없다. 그래서 랜챗 관련 코드는 어디서든 클라이언트가 보내는 값을 일단 의심하고 시작하는 게 원칙이었다.
무엇이 문제였나
리뷰에서 이 지점을 짚었다. 조작된 클라이언트가 개행 문자가 섞인 문자열을 partnerId로 보내면, 그 값이 그대로 로그에 찍히면서 로그에 가짜 줄을 심을 수 있다. 운영 로그를 눈으로 읽는 사람이나 로그를 파싱해서 알림을 띄우는 도구를 속일 수 있는 셈이다. 예를 들어 다른 로그 라인 형태를 흉내 낸 문자열을 심으면, 실제로 없었던 이벤트가 있었던 것처럼 보이게 만들 수 있다. 아주 긴 문자열을 반복해서 보내는 것만으로 디스크를 채우는 것도 가능하다.
이전 버전의 이 로그는 서버가 만든 값(reporter=${id})만 찍고 있었다. id는 서버가 발급한 값이라 클라이언트가 임의로 바꿀 수 없다. 클라이언트가 만든 값을 로그에 넣는 건 이번 변경이 처음이었다. 즉 이 표면은 이번 변경이 새로 연 것이지, 원래부터 있던 문제가 아니었다.
무엇보다 같은 파일이 바로 몇 줄 위 주석에서 “조작된 클라이언트를 전제하고 서버에서 막는다”고 스스로 선언하고 있었다. 신고 대상 검증, 페어 확인, 중복 신고 방지 같은 로직은 전부 그 전제 위에서 짜여 있었다. 그런데 정작 그 옆에 새로 붙인 로그 한 줄은 같은 전제를 지키지 않고 있었다.
수정
길이를 자르고 이스케이프해서 찍도록 바꿨다.
target=${JSON.stringify(claimedPartnerId.slice(0, 64))}
JSON.stringify는 개행과 제어 문자를 이스케이프 시퀀스로 바꾸기 때문에 값이 한 줄을 벗어나지 못한다. 로그를 줄 단위로 파싱하는 도구 입장에서 이 값은 항상 한 줄 안에 갇힌다. slice(0, 64)는 디스크 소모를 막는다. 신고 대상 식별자는 원래 짧으므로 64자면 정상적인 값을 자를 일이 없다. 값 자체를 검증해서 거부하는 방법도 고민했지만, 이 로그는 어디까지나 “무슨 값이 왔는지”를 남기는 게 목적이라 값을 바꾸기보다는 안전하게 표현하는 쪽을 택했다.
같은 파일에서 하나 더 찾았다
수정한 김에 파일 전체를 훑어 같은 계열의 로그가 더 있는지 확인했다. 하나 더 있었다. 신고 기록이 유실될 때 남기는 로그에 신고 사유가 들어가는데, 이 사유는 사용자가 “기타”를 골랐을 때 직접 입력한 텍스트를 포함한다. 즉 partnerId와 마찬가지로 클라이언트가 자유롭게 채워 넣을 수 있는 문자열이다. 길이는 이미 제한돼 있었지만 개행은 제거하지 않고 있었다. 같은 패턴(JSON.stringify + 길이 제한)을 그대로 적용했다. 한 번 뚫린 원칙은 대개 한 곳에만 있지 않다는 걸 다시 확인한 셈이다.
곁가지 — 막지 않는 방어를 방어라고 적지 않기
같은 세션에 OAuth 콜백의 리다이렉트 처리도 손봤다. 로그인 후 돌아갈 경로를 쿼리 파라미터로 받는 구조인데, 오픈 리다이렉트를 막으려고 “//”로 시작하는 값(프로토콜 상대 URL)을 거부하는 검사를 추가했다. 의도는 좋았다. 다만 실제로 필요한지는 따로 확인하지 않은 채였다.
리뷰어가 이 가드가 실제로 뭔가를 막고 있는지 확인했다. 이 코드의 리다이렉트는 항상 확정된 origin 문자열에 경로를 이어붙이는 형태였다. 그래서 “//evil.com”을 넣어도, 그걸 인코딩한 형태를 넣어도, 백슬래시를 섞어도 결과 URL의 host는 바뀌지 않았다. URL 파서로 직접 넣어 확인한 결과였다.
즉 내가 추가한 가드는 막는 취약점이 없는 방어적 여분이었다. 가드 자체는 나쁘지 않다. 나중에 누군가 문자열 결합 방식을 다른 형태로 바꾸면 그때는 이 가드가 실제로 필요해진다. 문제는 주석이었다. 주석이 그걸 실제 방어처럼 적어두면 다음 사람이 코드의 무게를 오해한다. 지금 당장 뭔가를 막고 있다고 믿고 그 위에 다른 가정을 쌓게 된다.
주석을 고쳤다. 이 결합 패턴에서는 방어적 여분이라는 것, 다만 결합 방식이 바뀌면 이 가정이 깨지니 가드는 남겨둔다는 것을 명시했다. 방어가 있다는 사실과 그 방어가 지금 뭔가를 막고 있다는 사실은 다른 이야기다. 이번 세션에서 로그 인젝션과 이 리다이렉트 가드는 겉보기엔 전혀 다른 이슈였지만, 둘 다 “코드가 실제로 하는 일”과 “코드에 대해 우리가 말하는 것” 사이의 간극이라는 같은 뿌리를 갖고 있었다.
교훈
로그는 출력이지만 입력이기도 하다. 사람이 읽고 도구가 파싱한다. 신뢰할 수 없는 값을 그대로 넣으면 그 값이 로그의 구조 자체를 조작할 수 있다.
기능 코드에서 지키는 원칙을 로그에서는 잊기 쉽다. “이건 그냥 로그니까”라는 생각이 정확히 그 빈틈이다. 검증도, 리뷰도, 기능 코드만큼 로그 한 줄에 시간을 쓰지 않는다.
표면을 새로 열었는지는 항상 물어봐야 한다. 이번 경우엔 이전 로그가 서버 생성 값만 찍고 있었으므로, 이번 변경이 없었다면 존재하지 않았을 문제였다. 새로 추가하는 줄 하나하나가 새로운 신뢰 경계를 만들 수 있다.
막지 않는 방어를 방어라고 적지 않는 것도 같은 무게로 중요하다. 주석이 과장하면 다음 사람이 그 코드를 함부로 못 건드리거나, 반대로 실제 방어가 필요한 곳을 놓친다. 방어적 여분이면 그렇게 정확히 적어두는 편이 낫다.
둘을 나란히 겪고 나니, 리뷰에서 물어야 할 질문은 결국 하나로 좁혀졌다. 이 코드가 실제로 하는 일과, 이 코드에 대해 우리가 적어둔 말이 지금도 일치하는가. 로그 한 줄이든 주석 한 줄이든, 이 질문을 건너뛰면 둘 사이의 거리는 조용히 벌어진다.