openlang

webhook 하나에서만 HMAC 불일치가 나요 (이름에 악센트가 들어간 것)

저장해 둔 webhook 전송분을 다시 재생해서, 하위 단계에서 신뢰하기 전에 HMAC-SHA256을 검증하는 작은 Node 체커를 만들었어요 (순수 JS, 빌드 단계 없음). 세 개 중 두 개는 통과해요. billing-partner의 evt_2044는 실패하는데, 같은 파트너의 evt_2051은 같은 secret으로 통과해요.

$ node main.js
staging         evt_1001  ok
billing-partner evt_2044  FAIL (digest)
billing-partner evt_2051  ok

두 secret 모두 지난주에 교체했어서 처음엔 오래된 secret 탓을 했어요. 그런데 그러면 evt_2051도 통과하지 못했을 거예요. 제가 보기에 유일한 차이는 2044의 고객 이름이에요. 악센트가 들어간 Zoë Ramírez요.

그래서 payload가 createHmac에 들어가는 과정 어딘가에서 utf8과 latin1 문제가 아닐까 의심했어요. update()에 넘기는 인코딩을 바꿔 봤는데 전혀 달라지지 않았어요.

lib/verify.js의 핵심 부분이에요:

const body = JSON.parse(delivery.raw);

// sign the payload, same as the partner docs describe
const payload = JSON.stringify(body);
const expected = crypto
  .createHmac("sha256", secret)
  .update(payload, "utf8")
  .digest("hex");

파트너 문서에는 서명이 "the JSON payload"에 대한 거라고 나와 있어요 (아마 맞을 거예요). 그래서 이 외에 뭘 해시해야 하는지 모르겠어요. 제가 뭘 놓치고 있을까요? 그리고 왜 그 이벤트만 그럴까요?

(toleranceSeconds 재생 검사는 TODO니까 무시해 주세요.)

영어에서 번역원문 보기

이어지는 대화 7

서로의 근거를 따라
  1. 고유 링크

    문제는 update()에 넘기는 인코딩이 아니라 JSON.parse → JSON.stringify 왕복이라고 봐요. 그래서 인코딩을 바꿔도 아무 변화가 없었던 거고요.

    HMAC은 파트너가 서명한 바로 그 바이트에 대해 계산돼요. 파싱했다가 다시 stringify하면 파트너의 직렬화가 아니라 내 직렬화를 서명하게 돼요. 순수 ASCII에 compact 포맷인 페이로드는 둘이 바이트까지 똑같이 나오는 경우가 많아서, evt_2051이랑 evt_1001은 운 좋게 통과한 거예요. evt_2044에는 non-ASCII 문자가 있고, 직렬화기들이 갈리는 지점이 바로 거기예요. 파트너가 Zo\u00eb Ram\u00edrez처럼 이스케이프해서 보내면 (Java/PHP/Python 인코더 중에 기본값이 그런 것들이 있어요), JSON.stringify는 리터럴 Zoë Ramírez를 내놓고, 그래서 digest가 달라져요. 반대 경우도 있을 수 있어요. 키 순서, 공백, 숫자 포맷도 다를 수 있지만 지금 증상에는 악센트가 제일 잘 맞아요.

    해결: delivery.raw를 손대지 말고 그대로 해시하세요.

    const expected = crypto
      .createHmac("sha256", secret)
      .update(delivery.raw)   // string or Buffer, exactly as received
      .digest("hex");

    JSON.parse는 서명이 확인된 다음에만 호출하세요. 전달 내용을 디스크에 저장해 뒀다면, 그걸 쓴 쪽이 텍스트를 다시 인코딩하거나 정규화하지 않았는지도 확인하세요. raw Buffer를 저장하는 게 가장 안전해요.

    고치는 김에 digest 비교도 === 말고 crypto.timingSafeEqual로 하세요. 문서: https://nodejs.org/api/crypto.html#cryptotimingsafeequala-b

    도움이 됐으면 좋겠어요. raw로 하면 2044가 해결되는지도 알려 주세요.

    영어에서 번역원문 보기
  2. 고유 링크

    const payload = JSON.stringify(body);

    이 줄이 문제예요. 파트너는 자기네 바이트에 서명한 거지, 여러분의 JSON.stringify가 만든 걸 서명한 게 아니에요. 제 경험상 이런 문서에서 "the JSON payload"는 항상 raw body를 뜻해요. delivery.raw를 그대로 해시하고, 검증한 뒤에 파싱하세요.

    https://nodejs.org/api/crypto.html

    영어에서 번역원문 보기
  3. 고유 링크

    raw body를 해시해야 한다는 건 동의해요. 다만 "the JSON payload는 항상 raw를 뜻한다"는 말에는 좀 반대하고 싶어요. 문서에서 그 표현을 느슨하게 쓰는 경우가 정말 많고, 애초에 tess.p도 그것 때문에 걸렸던 거 아닐까요?

    리플레이 체커에 한 가지 주의할 점이 있어요. 저장해 둔 전달 내용을 utf8 문자열로 읽었다가 다시 썼다면 BOM이나 CRLF가 바뀌어서 똑같이 digest가 깨질 수 있어요. 저라면 raw를 처음부터 끝까지 Buffer로 유지하겠어요. 그리고 나중에 Express 뒤에 두게 되면, 여러분이 body를 볼 때쯤엔 express.json()이 이미 파싱을 끝낸 상태예요. 돌아가는 방법은 두 가지예요.

    // option A: keep the bytes next to the parsed body
    app.use(express.json({
      verify: (req, _res, buf) => { req.rawBody = buf; },
    }));
    
    // option B: raw only on the webhook route, parse after verifying
    app.post("/hooks/billing", express.raw({ type: "application/json" }), (req, res) => {
      // req.body is a Buffer here
    });

    저는 B 쪽으로 기울어요. 실수로 엉뚱한 걸 해시하기가 더 어렵거든요. 그래도 기존 앱에는 A가 덜 침습적이에요. 리뷰에서 어느 쪽을 변호할지는 잘 모르겠어요.

    영어에서 번역원문 보기

같은 질문의 다른 갈래

  1. ack가 unbuffered receive인데도 -race가 DATA RACE를 보고해요. time.After 때문일까요?

    race detector가 틀렸거나, 제가 ack가 보장해 주는 걸 잘못 이해하고 있는 걸까요? pushgate라는 작은 서비스인데, 제 모바일 앱의 푸시 알림용 디바이스 토큰에 붙은 HMAC 서명을 검사해요. 호출하는 쪽이…

    답글 4최근 활동 go
  2. free 전에 memset하면 기본 플래그에서는 통과하는데 OPT=-O2에서는 실패해요

    WSL에서 vault라는 작은 C 크리덴셜 홀더를 만들고 있어요. vault_release()는 시크릿을 지운 다음 free해야 해요: 테스트인 tests/wipe_check.c는 시크릿 포인터를 저장해 두고 vault_re…

    답글 6최근 활동 c
  3. -O0과 -O2에서 같은 overflow 체크인데 에러 분기가 달라요

    count=65536, rec_size=32768인 악성 pack을 넣어봤어요. -O0에서는 "pack too large"가 나오는데, -O2에서는 "out of memory"가 나와요. 소스는 똑같은데 왜 이런지 도저히 모…

    답글 6최근 활동 c