사라진 마침표의 기묘한 사건
(tjaart.substack.com)- 문서 템플릿으로 이메일·문자·PDF를 생성하던 시스템에서, 특정 고객에게 보낸 이메일 본문만 마침표가 사라지는 문제가 발생함
- 템플릿 원본·미리보기·PDF에는 마침표가 있었지만, 고객별 placeholder 값을 실제 데이터로 치환한 뒤 특정 줄 길이에서만 재현됨
- 커스텀 SMTP 클라이언트가 줄 길이를 제한하면서 마침표를 새 줄 첫 글자로 밀어냈고, SMTP의 dot transparency 규칙 때문에 서버가 그 마침표를 제거함
- 수정은 마침표로 시작하고 뒤에 다른 문자가 이어지는 줄 앞에 클라이언트가 마침표를 하나 더 붙이는 방식으로 이뤄짐
- 같은 SMTP 클라이언트 코드를 공유한 다른 팀이 패치하지 않아 일부 고객에게 월 보험료가 $27.00 대신 $2700으로 보이는 이메일이 발송됐고, 버그는 즉시 수정됨
중앙 템플릿 시스템에서 시작된 문제
- 약 7년 전 한 클라이언트는 여러 Microsoft Word 템플릿에 흩어진 문서를 단일 시스템으로 통합하려고 했음
- 기존 방식은 직원이 문서의 placeholder를 직접 이름, 성 등으로 바꾸는 형태였고, 오래된 약관·예전 회사 로고·잘못된 폰트가 들어간 템플릿이 함께 돌아다니며 관리가 어려워짐
- 새 시스템은 문서 템플릿을 중앙에서 관리하고, 이를 바탕으로 PDF 문서, 문자 메시지, 이메일 본문을 생성하도록 구성됨
- 신규 고객 환영 메시지도 전달 방식에 따라 서로 다른 템플릿을 가질 수 있었음
- 이메일 버전은 HTML 테이블과 기본적인 스타일을 사용할 수 있음
- 우편 발송용 버전은 인포그래픽을 포함할 수 있음
- 문자 메시지 버전은 짧은 환영 문구만 담을 수 있음
특정 고객에게만 사라진 마침표
- 시스템이 운영된 지 몇 달, 혹은 1년 넘게 지난 뒤 한 관리자가 특정 고객에게 보낸 이메일 본문에서 마침표 하나가 빠졌다고 알려옴
- 같은 이메일을 다른 고객에게 보내면 마침표가 사라지지 않아 단순한 템플릿 오류로 보기 어려웠음
- 템플릿 소스 코드에는 해당 마침표가 실제로 존재했음
- 로컬 환경에서 프로덕션 템플릿을 복사해 이메일 본문을 미리보기로 생성했을 때도 마침표가 보였고, 같은 템플릿으로 만든 출력용 버전에서도 정상 표시됨
- 로컬 환경은 특정 포트의 localhost로 이메일을 보내고, SMTP4dev 같은 가짜 SMTP 서버와 Outlook으로 이메일을 확인하는 방식이었음
- 처음 로컬에서 보낸 이메일을 Outlook으로 확인했을 때도 마침표는 정상적으로 표시됨
고객별 데이터가 만든 재현 조건
- 템플릿은 이메일, PDF, 문자 메시지를 만들 때 고객의 이름과 성 같은 placeholder 값을 실제 데이터로 치환함
- 같은 템플릿이라도 각 고객에게 전송되는 이메일 본문은 내용 길이가 달라질 수 있었음
- 문제가 된 고객에게 실제로 사용된 placeholder 값을 찾아 로컬 환경에서 같은 값으로 이메일을 다시 보냈더니, Outlook에서 마침표가 사라지는 현상을 확인함
- 문제는 해당 고객 이메일의 구체적인 본문 길이와 내용에 의존했음
- 처음에는 템플릿의 마침표 문자가 인코딩됐는지, 실제 마침표가 아닌 다른 문자인지 등을 확인했지만 원인은 아니었음
- 템플릿 안에서 마침표 위치를 한 칸 옮기자 Outlook에서 다시 마침표가 보였고, 줄 위치가 재현의 단서가 됨
SMTP 줄 길이와 dot transparency
- 디버깅 결과 이메일을 데이터베이스에 저장하는 코드는 placeholder를 고객 정보로 바꾸는 것 외에 템플릿을 변경하지 않았음
- 이후 정기적으로 발송 대상을 가져와 이메일을 보내는 cron 작업의 코드로 조사 범위가 좁혀짐
- cron 작업이 호출하는 코드 일부는 이전 프로젝트에서 가져온 것이었고, 그 안에 커스텀 SMTP 클라이언트 구현이 포함돼 있었음
- 해당 코드에는 이메일 본문의 각 줄이 특정 문자 수보다 길어지지 않도록 줄을 나누는 함수가 있었음
- 이 동작은 SMTP 명세의 줄 길이 제한과 관련됨
- 텍스트 줄의 최대 전체 길이는
<CRLF>를 포함해 1000 octets - 투명성을 위해 복제되는 선행 마침표는 이 계산에 포함되지 않음
- SMTP Service Extensions로 이 수치는 늘어날 수 있음
- 텍스트 줄의 최대 전체 길이는
- 문제의 이메일 본문에서는 줄 길이 제한 때문에 마침표가 다음 줄 첫 문자로 이동함
- SMTP 명세에서 mail data 종료 표시는 마침표 하나만 있는 줄로 처리됨
- SMTP의 dot transparency 규칙은 마침표로 시작하는 본문 줄을 별도로 처리함
- SMTP 클라이언트는 메일 텍스트 줄을 보내기 전에 첫 글자가 마침표인지 확인하고, 마침표이면 줄 맨 앞에 마침표를 하나 더 삽입해야 함
- SMTP 서버는 줄이 마침표 하나만 있으면 메일 데이터 종료로 처리함
- 첫 글자가 마침표이고 같은 줄에 다른 문자가 있으면 서버는 첫 글자를 삭제함
- 커스텀 SMTP 클라이언트는 줄이 마침표로 시작할 때 마침표를 하나 더 추가하는 처리를 하지 않았고, 수신 SMTP 서버가 첫 마침표를 삭제하면서 실제 본문 마침표가 사라짐
수정과 뒤늦게 드러난 영향
- 수정은 줄이 마침표로 시작하고 같은 줄에 다른 문자가 있을 때 클라이언트가 마침표를 하나 더 붙이도록 하는 방식이었음
- 이렇게 보내면 수신 SMTP 서버가 첫 마침표를 제거해도 본문에는 원래 마침표가 남음
- 문제가 된 고객의 동일한 수신자 정보로 로컬에서 원본 이메일을 다시 보냈을 때, 마침표는 더 이상 사라지지 않았음
- 수정이 배포된 뒤, 같은 SMTP 클라이언트 코드를 이전 프로젝트에서 가져다 썼기 때문에 다른 팀에도 해당 버그를 알림
- 몇 달 뒤 다른 팀의 시스템은 아직 패치되지 않은 상태였고, 고객들에게 새 월 보험료를 알리는 중요한 이메일을 여러 건 발송함
- 일부 이메일에서는 월 보험료의 소수점 구분 마침표가 정확히 줄 첫 문자 위치에 놓이며 사라짐
- 그 결과 일부 고객은 새 보험료가
$27.00이 아니라$2700인 것처럼 보이는 이메일을 받음 - 이 버그는 이메일 본문 각 줄의 길이에 의존했고, 고객의 이름과 성 길이가 정확히 맞는 일부 고객에게만 발생함
- 원인이 이미 파악돼 있었기 때문에 해당 팀의 코드는 즉시 패치됨
댓글과 토론
Hacker News 의견들
-
이 코드는 일부가 SMTP 클라이언트를 직접 구현하고 있었고, 근본 원인은 거기에 있어 보임
프로토콜을 정확히 구현하는 건 어렵고, 글에서 나온 버그 같은 일이 흔함
제대로 구현된 SMTP 클라이언트 라이브러리라면 입력 텍스트 안의 마침표 위치와 상관없이 SMTP 규약에 맞게 인코딩했을 것이고, 템플릿 계층이 SMTP를 신경 쓸 필요가 없음- 진짜 문제는 프로토콜 자체보다, 그 프로토콜을 다루는 카우보이식 접근에 있음
SMTP 규약에 맞게 텍스트를 받아 인코딩하는 건 어렵지 않지만, 애초에 그 처리가 필요하다는 걸 알아야 함
SQL 삽입, XSS 같은 수많은 오류와 보안 취약점도 같은 실수, 즉 문자열 이어붙이기에서 생김
SQL 쿼리에서는 값을 쿼리 템플릿에 바인딩하는 작업이 타입 없는 문자열 공간이 아니라 “SQL 공간”에서 일어나야 하고,SELECT * FROM foo WHERE foo.bar =+$userData처럼 직렬화된 SQL 문자열을 직접 만드는 건 잘못된 방식임
HTML 템플릿도 마찬가지로 문자열 표현이 아니라 문서 트리 수준에서 다루면 멍청한 취약점을 피할 수 있음
이메일에서 마침표가 사라지는 걸 피하려면 SMTP 파이프라인 중간에 구조 없는 텍스트를 주입하지 말고, 작업 중인 추상화 수준을 존중해야 함
관련해서 langsec도 볼 만함 - 진짜 문제는 SMTP가 인밴드 신호를 포함한 “평문” 프로토콜이라는 데 있음
“마침표 하나만 들어 있는 줄”을 문자 그대로의 줄이 아니라 제어 시퀀스로 정의했기 때문에 이런 일이 생김
SMTP는 불필요하게 복잡한 설계의 예이고, 구현 버그도 그 복잡성을 반영함
직접 구현하라고 권하는 건 아니지만, SMTP가 혼자서도 올바르게 구현하기 어려운 수준이어서는 안 됨 - 좋은 생각처럼 들리지만, SMTP 라이브러리를 하나 가져왔더니 그 라이브러리가 의존성 5개를 끌고 오고, 각각이 또 전이 의존성 3개씩을 끌고 오며, 그중 하나는 실제로는 1%만 쓰이는 만능 도구상자 프로젝트일 수 있음
나머지는 죽은 무게인데도 호출되지도 않는 함수 때문에 의존성 20개를 더 끌고 오고, 어느새 코드베이스는 몇 MB씩 부풀며, 쓰는 줄도 몰랐던 라이브러리의 CVE 경고를 받게 됨 - https://en.wikipedia.org/wiki/Jamie_Zawinski#Zawinski's_Law
- 진짜 문제는 프로토콜 자체보다, 그 프로토콜을 다루는 카우보이식 접근에 있음
-
기술적인 부분은 이미 다른 이들이 더 잘 다뤘지만, 문장 끝 마침표 하나 같은 사소한 것이 얼마나 중요한지 떠오르는 일화가 있음
내가 일하는 독일에서는 퇴사 시 수행 업무와 직원 평가를 적은 추천서인 “Zeugnis”를 요청하는 것이 일반적이고, 구직 때 보통 요구되는 중요한 문서임
당연히 직원이 “이 사람은 게으르니 채용하지 말라” 같은 문구를 받아들일 리 없으니, 칭찬처럼 위장한 “Zeugnissprache”라는 일종의 암호가 생겼음
그 암호 중 하나가 마지막 문장에 마침표가 없으면 “여기 적힌 건 전부 무시하고, 이 사람은 형편없다”는 뜻이라는 것임
지난 직장 이후 내 Zeugnis를 변호사에게 검토시켰고, 모든 평가가 긍정적이었는데도 부주의 때문인지 마지막 문장에 마침표가 빠져 있었음- 추천서에 비밀 암호가 쓰인다는 건 도시전설임
인사 담당자들이 잠재적 경쟁자들과 함께 쓸 비밀 코드를 만들 유인이 없고, 새 인사 담당자에게 가르치면서도 비밀로 유지한다는 것도 말이 안 됨
이런 전설은 인사 담당자가 누군가가 골칫거리였다는 사실을 너무 노골적으로 쓸 수 없기 때문에 생김
긍정적으로 쓸 말이 없으면 시간 엄수 같은 평범한 장점을 칭찬하는 식이고, 비밀 암호라기보다는 인사말투의 “bless their heart”에 가까움
소송을 걸면 “판사님, 좋은 말이었습니다! 항상 제시간에 왔거든요!”라고 빠져나갈 여지를 남기는 셈임 - “마지막 문장에 마침표가 없으면 여기 적힌 건 전부 무시하고, 이 사람은 형편없다”라니, 대체 뭐가 잘못될 수 있겠나 싶음
- 성과평가 문서에도 마침표가 빠져 있었는지 확인해 봤는지 궁금함
- 추천서에 비밀 암호가 쓰인다는 건 도시전설임
-
이메일을 보내는 크론 작업이 왜 자체 SMTP 클라이언트를 구현해야 하는지 모르겠음
그냥 mailutils의mail같은 프로그램을 쓰면 됨
전달 가능성 관점에서 봐도 소켓 위에 뼈대만 있는 SMTP 상호작용을 직접 만드는 건 시작부터 무리임
요즘은 아무 메일 교환 호스트에 직접 연결해서 메일을 보낼 수 없고, 보통 ISP가 제공하는 특정 SMTP 릴레이 호스트에 연결해야 함
그러려면 TLS 연결, 인증 등을 모두 구현해야 함
약간 아이러니한 점은 cron 자체도 이미 메일을 보낼 줄 안다는 것임
cron 작업의 출력은 소유자에게 메일로 보내지고, 일부 cron은 crontab의MAILTO변수 같은 것으로 수신 주소를 바꿀 수 있음- 메일을 보낼 때 호스트의 MTA를 써야 하는데도 자체 SMTP 클라이언트를 내장한 것들이 실제로 많음
이유는 “그 물건”을 쓰고 싶은 사용자가 SMTP 서버 주소 정도는 입력할 수 있지만, sendmail 같은 송신 MTA가 제대로 설정되어 있다고 프로그램이 믿을 방법은 없기 때문임
회사에는 시스템 메일을 보낼 수 없는 서버가 대규모로 존재하고, 그 상태가 프로그램 작성자나 사용자 통제 밖인 경우도 많음
메일 설정은 DNS, 암호화, 정책 같은 선행 조건이 필요한 전문 영역이라, 지갑 앱 같은 것이 메일을 보내게 하려는 목적만으로는 너무 과함
“시스템 관리자에게 문의하세요”는 대규모든 소규모든 현실적인 선택지가 아닌 경우가 많음
좋은 이유는 아니지만, 실제 이유이긴 함 - 시스템에 이미 메일을 보내 줄 바이너리가 있다고 믿을 수 있는 세계는 없지만, 그렇다고 직접 만들지는 않겠음
웬만한 언어라면 표준 라이브러리에 없더라도 쓸 만한 SMTP 클라이언트 라이브러리가 많이 있음 - 이것도 Zawinski의 법칙 사례 아닌가 싶음
- 메일을 보낼 때 호스트의 MTA를 써야 하는데도 자체 SMTP 클라이언트를 내장한 것들이 실제로 많음
-
여기에는 큰 나쁜 습관이 두 가지 보임
첫째는 많은 이들이 짚었듯이 표준을 대충 구현하지 말라는 것임
직접 구현해야 한다면 필요한 주의와 정성을 들이거나, 이미 만들어진 라이브러리를 써야 함
둘째는 의존성을 벤더링하지 말라는 것임
사용하는 라이브러리는 정기적으로, 제때 업데이트해야지 “필요할 때만” 해서는 안 됨
업데이트가 지연되거나 아예 피하면 업스트림에서는 이미 고쳐진 버그도, 직접 문제가 보일 때만 업데이트해야 한다고 생각한 사람들에게 큰 문제가 될 수 있음- 의존성 벤더링을 하지 않는 대안이 더 나빠 보임
그러면 애플리케이션 안정성이 업스트림 변경에 노출되고, 그 변경이 코드를 깨뜨릴 수 있음
수정 사항을 즉시 받지 못할 수는 있지만, 원치 않는 불안정성과 추가 위험을 들이기보다는 내가 필요한 수정 때문에 변경한다는 걸 아는 편이 낫다고 봄
“고장 나지 않았으면 고치지 말라”는 쪽임
- 의존성 벤더링을 하지 않는 대안이 더 나빠 보임
-
요즘은 많은 사람들이 터미널로 직접 상호작용하면서 기본 프로토콜을 배우지 않는 것 같음
SMTP는 원래 그런 식의 수동 상호작용을 염두에 둔 듯하고, 실제로 한동안 그렇게 써본 입장에서는 메시지를 끝내는 “한 줄의 마침표 하나”가 기억에 영구히 새겨져 있음
관련해서 이스케이프 처리도 많은 프로그래머에게 낯선 개념처럼 보임
위 상황을 보고 “마침표 하나만 들어 있는 줄을 가진 이메일을 보내고 싶으면 어쩌지?”라고 묻지 않는 사람들도 많지만, 또 다른 큰 집단은 그 질문을 매우 논리적이고 이해하기 쉽다고 느낄 것임- 지난 30년 사이에 소프트웨어 개발자 중 의미 있는 비율이 그런 식으로 배웠다면 오히려 놀랄 것 같음
-
도트 스터핑이 필요함
SMTP https://www.rfc-editor.org/rfc/rfc5321#section-4.5.2
POP3에도 있음 https://www.rfc-editor.org/rfc/rfc1939#page-8 -
네트워크 프로토콜 구현을 디버깅했던 경험이 떠오름. 구체적으로는 오래된 사람들을 위한 AppleTalk NBP였음
전부 코딩했는데 내 패킷은 거부, 즉 조용히 드롭됐고 실제 Apple 구현 패킷은 통과했음
좋은 패킷과 나쁜 패킷을 컴퓨터 화면에 띄워 두고 바이트 단위로 비교했지만 문제를 찾을 수 없었고, 체크섬까지 포함해 처음부터 끝까지 정확히 같았음
퇴근 시간이 되어 나중에 보려고 그 바보 같은 것들을 출력하기로 했는데, 출력하자마자 오류가 보였음
내 버전은 두 페이지였고, 정상 구현은 한 페이지였음
데이터를 보내기 전에 버퍼를 제대로 비우지 않았던 것임. mbuf란 그런 것임
아직도 생각하면 웃김 -
“We are happy to welcome you to our family.”라는 줄은 줄 길이 제한 근처에도 가지 않음
뭔가 다른 일이 있는 듯하고, 예를 들면 전체가 실제로 HTML MIME 첨부였을 수도 있음
이런 식으로... lots of text ...다음에We are happy to welcome you to our family.가 있었을 수 있음
하지만 HTML을 무작정 줄로 자르면 태그가 깨짐- 이건 그냥 예시였고, 정확한 문자 수를 가진 실제 예시를 제공하지 못해 미안함
- 깨진 HTML 이메일을 받은 사람들 대부분은 이메일 형식이 이상하다는 걸 알아차리고, 읽기 쉬운 이메일도 제대로 못 쓰는 회사로 보고 회사 역량 평가를 낮춘 뒤 다음 이메일로 넘어갈 것임
회사는 그 사소한 문제를 전혀 모른 채 지나갈 가능성이 큼 - quoted-printable 인코딩의 소프트 줄바꿈을 쓰면 HTML 태그를 깨지 않고도 줄을 무작정 나눌 수 있음
quoted-printable은 CRLF를 포함해 최대 78자 제한을 가져야 하지만, 이메일 클라이언트들은 대체로 관대함
-
도트 스터핑을 처음 들어본 사람이라면 이메일 세계 안에 어떤 다른 공포가 있는지 믿기 어려울 것임
헤더 접기, 로컬 파트의 따옴표 처리, IPv6 리터럴 같은 것들이 있음- IPv6 리터럴에는 무슨 문제가 있는지 궁금함
-
이전 프로젝트에서 빌려온 SMTP 클라이언트 코드였다는 대목을 읽자마자, 다른 팀이 아직 이 버그를 패치하지 않았다는 결말이 나올 줄 알았음