Technote

보안 실무

연재 REST API 와 헤드리스 8부 중 5부

웹훅 수신 — nonce 를 걸면 100% 실패합니다

외부 서버에는 쿠키도 nonce 도 없습니다. 검증은 서명이나 공유 키로 하고, hash_equals 로 비교하며, 서명은 반드시 원본 바디에 대고 계산합니다.

결제 · 메일 · 배포 서비스가 우리에게 사건을 알려 주는 경로가 웹훅입니다. 그리고 이것은 앞 회차의 인증 규칙이 전부 적용되지 않는 유일한 자리입니다. 호출자는 브라우저가 아니라 남의 서버이고, 그 서버에는 우리 쿠키도 우리 nonce 도 없습니다.

nonce 는 여기서 100% 실패합니다

wp_verify_nonce() 는 현재 사용자와 시간에 묶인 값을 확인합니다. 외부 서버는 로그인 상태가 아니고 nonce 를 만들 방법도 없으므로, 웹훅 라우트에 nonce 검증을 걸면 모든 요청이 예외 없이 실패합니다. 그런데 실패가 조용합니다 — 결제 서비스는 재시도를 몇 번 하고 포기하고, 우리 쪽에는 “입금이 확인되지 않는다” 는 증상만 남습니다.

그래서 웹훅 라우트의 permission_callback 은 '__return_true' 입니다. 이것은 검증을 포기한다는 뜻이 아니라, 검증의 자리가 다르다는 뜻입니다 — 인증은 핸들러 첫 줄에서 서명으로 합니다.

같은 REST 라우트지만 검증의 자리가 다르다

서명은 원본 바디에 대고 계산합니다

여기가 가장 많이 틀리는 지점입니다. 서명 검증을 하려고 json_decode() 로 파싱한 뒤 json_encode() 로 다시 만든 문자열에 해시를 겁니다. 그러면 키 순서 · 공백 · 유니코드 이스케이프가 달라져 서명이 절대 맞지 않습니다. 값은 같은데 바이트가 다른 것입니다.

반드시 $request->get_body() 로 받은 그대로의 문자열을 쓰고, 파싱은 검증이 끝난 뒤에 합니다.

add_action( 'rest_api_init', function () {
	register_rest_route(
		'wper/v1',
		'/webhook/payment',
		[
			'methods'             => 'POST',
			'callback'            => 'wper_receive_payment_hook',
			// 외부 서버에는 쿠키가 없다. 인증은 핸들러 첫 줄이 한다.
			'permission_callback' => '__return_true',
		]
	);
} );

function wper_receive_payment_hook( WP_REST_Request $request ) {
	$raw    = $request->get_body();                 // 원본 그대로. 재직렬화 금지.
	$sent   = (string) $request->get_header( 'x-signature' );
	$expect = hash_hmac( 'sha256', $raw, wper_webhook_secret() );

	// == 를 쓰지 않는다. 타이밍 차이가 새고, 타입 저글링도 있다.
	if ( ! hash_equals( $expect, $sent ) ) {
		return new WP_Error( 'wper_bad_signature', 'signature mismatch', [ 'status' => 401 ] );
	}

	$event = json_decode( $raw, true );
	$id    = isset( $event['id'] ) ? sanitize_text_field( $event['id'] ) : '';

	if ( '' === $id || get_transient( 'wper_hook_' . $id ) ) {
		return rest_ensure_response( [ 'status' => 'success' ] );   // 중복은 조용히 성공
	}

	set_transient( 'wper_hook_' . $id, 1, DAY_IN_SECONDS );
	wper_apply_payment_event( $event );

	return rest_ensure_response( [ 'status' => 'success' ] );
}

비교는 hash_equals() 로만 합니다. == 는 문자열 비교 시간이 일치 길이에 따라 달라져 정보를 흘리고, PHP 의 느슨한 비교에서는 특정 형태의 문자열이 예상 밖으로 같다고 판정될 수 있습니다. 비밀값 비교에 == 를 쓰지 않는다는 규칙은 예외 없이 지키는 편이 쉽습니다.

재시도 · 멱등성 · 캐시 우회

웹훅은 같은 사건이 여러 번 옵니다. 네트워크가 끊겼거나 우리가 2xx 를 늦게 돌려주면 서비스가 재시도합니다. 그래서 사건 식별자를 저장해 두고 이미 처리한 것은 건너뜁니다 — 위 예제의 트랜지언트가 그 역할입니다. 중복에는 오류가 아니라 성공을 돌려줍니다. 오류를 돌려주면 재시도가 영원히 이어집니다.

그리고 운영에서 실제로 나는 사고가 하나 더 있습니다. 페이지 캐시가 웹훅 라우트를 캐시하면 상태가 갱신되지 않습니다. nginx FastCGI 캐시나 CDN 이 첫 응답을 저장해 두고 이후 요청에 PHP 를 태우지 않으면, 서비스는 200 을 받지만 우리 DB 는 그대로입니다. 로그에는 아무 이상이 없고 결제만 반영되지 않습니다. 캐시 우회 조건에 웹훅과 결제 라우트를 반드시 넣어야 하는 이유입니다.

마지막으로, 페이로드의 금액이나 상태를 그대로 믿지 않습니다. 사건이 왔다는 사실만 받아들이고 금액은 우리가 만든 주문 레코드에서 다시 읽어 대조합니다.

이런 검증을 포함한 보안 관점은 보안 아카이브에 모여 있고, 캐시 계층과 결제 경로가 서로 어긋나지 않게 구성하는 실제 작업은 최적화 지원 사업에서 함께 다룹니다.

다음 회차

다음 회차는 방향이 반대입니다. 우리가 의도하지 않았는데 API 가 내주고 있는 것 — 사용자 목록과 로그인 아이디가 기본으로 어떻게 새는지, 그리고 그것을 닫는 방법입니다.

이 주제의 다른 글

노하우 목록으로

보안 실무

리드 데이터는 자산이면서 책임입니다

연락처 목록은 마케팅 자산이지만 동시에 개인정보입니다. 필요한 만큼만 받고, 기간을 정해 두고, 그 기간이 지나면 실제로 지우는 세 가지가 핵심입니다.

마케터 3분 읽기

보안 실무

분석과 개인정보 — 최소 수집과 고지의 실제

배너를 띄우는 것과 동의를 받는 것은 다릅니다. 무엇을 왜 수집하는지 스스로 설명할 수 있어야 하고, 보관 기간은 방치가 아니라 결정이어야 합니다.

마케터 4분 읽기

₩270,000 · 신청하기