당신의 서비스가 요청을 받으면 무언가를 검사한다. 로그인 토큰이 재사용된 것은 아닌지, 사용자가 올린 그림에 위험한 것이 없는지, 자동으로 머지해도 되는 변경인지. 검사 코드는 있고 테스트도 통과한다. 그런데 검사가 무엇과 대조하는지를 보면, 그 기준이 요청과 함께 실려 오는 경우가 많다. 그러면 상대는 내용과 기준을 같이 고쳐서 보낸다. 검사는 돌지만 아무것도 막지 못한다.
검사는 어디서 뚫리나
검사가 대조하는 기준은 상대가 손댈 수 없는 쪽에서 가져와야 한다. 요청이 들고 온 값, 요청이 가져온 규칙 파일, 상대가 쓴 원문의 글자 모양은 모두 상대의 손안에 있다. 이것으로 상대를 검사하면 상대가 정답지를 쥔 시험이 된다.
뚫리는 자리는 대개 셋 중 하나다.
- 대조값이 요청에서 온다 — "요청에 이 값이 있고, 토큰 안의 값과 같은가"를 본다. 토큰을 가진 사람은 토큰 안의 값을 읽어 요청에 그대로 넣을 수 있다.
- 규칙이 검사받는 쪽에서 온다 — 변경을 검사하는 규칙 파일·설정을 그 변경이 들고 온 것으로 읽는다. 변경이 규칙까지 고치면 끝이다.
- 해석기가 둘이다 — 필터는 원문 글자를 정규식으로 훑고, 실제 위험은 그 원문을 다른 해석기(브라우저, 셸, 데이터베이스)가 풀어 읽을 때 생긴다. 두 해석기가 같은 글자를 다르게 읽는 틈으로 빠져나간다.

흔한 예: 쇼핑몰 쿠폰
쇼핑몰이 결제 요청에 coupon=WELCOME10 과 discount=10 을 같이 받고, 서버가 "쿠폰 코드가 유효한가"만 본 뒤 요청에 실린 할인율을 쓴다고 하자. 검사는 있다. 쿠폰 코드는 진짜다. 그런데 할인율은 상대가 적었다. discount=90 으로 보내도 통과한다.
고치는 법은 검사를 더 촘촘히 하는 것이 아니다. 할인율을 요청에서 받지 않는다. 서버가 쿠폰 코드로 자기 표에서 할인율을 찾는다. 기준이 상대 손에서 서버 손으로 옮겨 가면 검사할 것 자체가 줄어든다.
같은 꼴이 곳곳에 있다.
- 사내 도구의 권한 — 요청 머리에
X-Role: admin이 있으면 관리자 화면을 연다. 그 머리는 누구나 붙일 수 있다. - 고객 응대 봇의 예외 규칙 — "위급해 보이는 대화는 유료 제한을 푼다"를 메시지 분류 결과에 걸면, 그 메시지는 사용자가 쓴다. 분류가 곧 우회 키가 된다.
일회용 값은 "있는가"가 아니라 "서버가 낸 것을 한 번 썼는가"다
토큰 재사용을 막는 일회용 값(nonce)은 OpenID Connect 명세가 말하듯 클라이언트(여기선 서버)가 만들어 세션에 묶어 둔 값이어야 한다. "요청에 nonce 가 있고 토큰 안 값과 같다"는 검사는 토큰 안 값을 읽어 다시 보내는 것으로 통과한다. 서버가 값을 발급하고, 서명 검증이 끝난 뒤에만 소비하고, 소비는 읽고 나서 쓰는 두 단계가 아니라 한 문장으로 한다.
-- 값은 서버가 발급할 때 해시로 넣어 두고, 쓸 때 한 문장으로 소비한다
DELETE FROM issued_nonces
WHERE nonce_hash = $1 AND expires_at > now()
RETURNING 1; -- 0행이면 거절: 발급한 적 없거나, 이미 썼거나, 만료
한 문장이라 같은 값을 동시에 두 번 보내도 하나만 행을 받는다.
자동 머지의 규칙은 왜 기준 브랜치에서 읽나
"보고 파일 하나만 더한 변경은 자동으로 머지한다" 같은 워크플로는 규칙 파일을 변경 쪽에서 읽는 순간 그 변경이 규칙을 다시 쓸 수 있다. GitHub Actions 의 pull_request_target은 기준 브랜치의 워크플로를 돌린다 — 대신 변경의 코드를 체크아웃해 실행하면 그 이점이 사라진다. 지킬 것:
- 규칙과 검사 스크립트는 기준 브랜치의 것만 쓴다. 변경의 코드는 실행하지 않는다.
- 검사한 바로 그 커밋일 때만 머지한다(검사 뒤 push 로 바뀐 것을 막는다).
- 허용 조건을 좁힌다 — "새 파일 하나, 이름 패턴 일치, 심볼릭 링크 아님".
- 파일 목록은 구조화된 데이터로 받고, 파일 이름을 명령 문자열에 끼워 넣지 않는다.
위험한 문법은 걸러 내지 말고 해석기를 가둔다
사용자가 쓴 SVG 도식을 페이지에 그리면서 외부 요청을 막으려고 원문에서 url( 을 찾는 필터를 두면, 브라우저의 CSS 파서는 이스케이프를 풀어 \75 rl( 도 url( 로 읽는다. 필터가 보는 글자와 브라우저가 읽는 뜻이 다르다. 금지어를 하나 더 넣는 대신 SVG 를 HTML 에 끼우지 말고 이미지로 연다. 이미지로 열린 SVG 는 스크립트도 외부 자원 로딩도 하지 않는다. 설명 글은 따로 텍스트로 꺼내 그린다.
우리가 겪은 것
- 소셜 로그인 재사용 방지 — 검증 라이브러리가 요청의 nonce 와 토큰 안의 nonce 가 평문으로 같으면 통과시켰다. 토큰 안 값은 누구나 읽는 base64 라, 서버 발급 + 한 문장 소비로 바꿨다.
- 도식 그리기 — 원문 정규식 필터가 CSS 이스케이프로 우회된다는 것을 검토에서 알았다. 이미지 태그로 그리는 쪽으로 옮겼다.
- 에이전트 보고 자동 머지 — 기본형 워크플로는 변경이 규칙을 들고 오는 구조였다. 기준 브랜치 규칙·같은 커밋·좁은 허용 조건을 비롯한 여섯 가지로 닫았다. 그래도 권한이 있는 쪽이 "정상처럼 보이는 파일"을 올리는 것은 못 막으므로, 그 파일은 지시가 아닌 참고 자료로만 읽게 했다.
- 유료 제한의 안전 예외 — 예외를 메시지 분류 결과에 걸었더니 그 메시지가 우회 키가 됐다. 예외를 "지금 열린 대화의 현재 턴 직후"로 좁혔다.
확인 목록
새 검사를 넣을 때 하나씩 묻는다.
- 이 검사가 대조하는 값은 어디서 오나? 요청에서 오면 서버가 발급·조회하도록 바꿀 수 있나?
- 이 검사를 하는 규칙·스크립트는 검사받는 변경과 같이 바뀔 수 있나?
- 필터가 읽는 방식과 실제로 그 데이터를 읽는 해석기가 같은가? 다르면 해석기를 가둘 수 있나?
- 예외(면제·우회 경로)를 켜는 조건을 사용자가 만들 수 있나?
- 검사와 사용 사이에 값이 바뀔 틈(다른 커밋, 두 단계 읽기·쓰기)이 있나?