GitHub webhook 새 IP 48개: 방화벽을 열어도 서명 검증은 남아요
PR은 열렸는데 배포 봇은 조용해요. 이런 상황에서 앱 코드만 살피다 보면, 요청이 서버에 도착하기 전 방화벽에서 막혔다는 사실을 늦게 발견할 수 있어요. GitHub가 10월 29일 webhook 발송 IP 대역을 추가했다는 공지는 바로 이 경로와 관련돼요.
추가된 것은 세 대역, 바꾸는 곳은 수신 쪽이에요
새 CIDR은 9.234.98.160/28, 9.234.98.176/28, 9.234.106.48/28이에요. github.com의 webhook 전달에 적용되고 GitHub Enterprise Cloud를 포함한 모든 요금제가 대상이에요. 고정 허용 목록을 관리한다면 기존 대역을 지우지 말고 이 세 개를 함께 추가해야 해요.
각 /28에는 주소 16개가 들어 있어 합하면 48개예요. 아래는 Python ipaddress로 계산한 실제 로컬 실행 결과예요. 이 숫자는 계정 수나 발송 서버 수가 아니라 CIDR에 포함된 주소 수예요. GET /meta의 hooks로 목록을 주기적으로 갱신하고 있다면 이번 추가 때문에 별도 변경할 필요는 없다는 게 GitHub의 안내예요.

IP 허용과 webhook 서명은 서로 다른 질문에 답해요
허용 목록은 어느 네트워크에서 온 요청을 받아들일지 정해요. 이때 보는 것은 Meta API의 hooks예요. Actions 러너의 외부 통신 주소를 가리키는 actions와는 용도가 달라요. 배포 봇이 webhook을 받는 경로와 CI 작업이 외부 API를 호출하는 경로를 같은 목록으로 다루면 원인을 놓치기 쉬워요.
요청이 통과한 뒤에는 이 webhook에 설정한 secret으로 X-Hub-Signature-256을 검증해야 해요. GitHub는 요청 본문과 secret으로 HMAC-SHA256 서명을 만들어요. 서버도 받은 원본 바이트로 서명을 계산하고 hmac.compare_digest나 crypto.timingSafeEqual 같은 상수 시간 비교를 사용해요. 허용된 IP라는 이유로 이 단계를 생략하지 않아요.
JSON은 같아 보여도 바이트가 다를 수 있어요
예를 들어 {"action":"opened"}를 파싱한 뒤 {"action": "opened"}로 다시 만들면 공백 하나가 달라져요. 같은 의미의 JSON이지만 원래 서명과는 맞지 않을 수 있어요. 아래 로컬 예시는 공개용 가상 secret으로 같은 본문은 True, 공백을 추가한 본문은 False가 나오는 것을 확인했어요. 실제 GitHub 전달 실험 결과는 아니에요.

따라서 본문을 변환하기 전에 원본 바이트를 확보하는 순서가 중요해요. 프록시나 프레임워크가 본문을 바꿨는지도 살펴볼 수 있어요. 문제를 찾겠다고 payload URL에 secret을 넣으면 URL 자체가 비밀을 노출하는 통로가 돼요. secret은 서버의 안전한 설정에서 읽도록 유지해요.
복구한 요청이 같은 일을 두 번 만들지 않으려면
네트워크 경로를 복구했다면 실패한 전달 내역을 보고 필요한 항목을 다시 보내요. 이때 X-GitHub-Delivery를 기준으로 처리 이력을 살피면 중복 실행을 줄일 수 있어요. 수동 재전송에서도 이 ID는 원래 전달과 같으므로, 완료한 요청과 실패 후 재개할 요청을 구분해야 해요. 단순히 본 적 있는 ID를 모두 버리면 정상 복구까지 막을 수 있어요.
수신 서버는 10초 안에 2XX로 응답하고 긴 처리는 큐로 넘기는 방식이 권장돼요. 실제 업무를 맡길 때에는 서명 확인, 내구성 있는 접수, 재시도 가능한 처리 상태가 이어지게 설계해요. 이번 공지의 출발점은 IP 세 대역이지만, 요청이 도착하고 검증되고 한 번의 업무로 마무리되는 경로를 함께 보면 장애를 더 빨리 이해할 수 있어요.