비밀 코드 활용하기

참고

  • 보안을 위해 웹훅 endpoint URL은 가급적 https 사용을 권장합니다. https가 http보다 안전하며, https를 사용할 경우 SSL/TLS 인증서가 유효해야 합니다. (Self-signed certificates 는 동작하지 않습니다.)
  • 현재 모아폼 웹훅 요청에 대한 지정된 IP 주소가 없습니다. 모아폼은 AWS 서버에서 호스팅되며, 동적 IP 주소를 사용하므로 고정 IP 주소나 IP 주소 범위를 보장할 수 없습니다.

웹훅을 수신하기 위한 endpoint URL은 외부에 노출될 수 있으므로 보안에 취약합니다. 웹훅 설정에서 '비밀 코드'를 입력하고, 각 웹훅 페이로드를 이 비밀코드로 서명하도록 하여 이 문제를 해결할 수 있습니다. 서명은 요청 헤더에 포함되며, 이를 통해 웹훅이 모아폼에서 온 것임을 검증할 수 있습니다.

웹훅 비밀코드는 user interface를 이용해서 웹훅을 생성할 때 설정할 수 있고, API로 웹훅을 새로 만들때도 설정할 수 있습니다.

 

모아폼에서 온 페이로드 검증

모아폼에서 수신한 페이로드 내 서명을 검증하기 위해서는, 웹훅 설정시 입력한 비밀 코드 값으로 직접 서명을 생성한 다음, 웹훅 페이로드에서 받은 서명과 비교해야 합니다.

비밀 코드 값으로 직접 서명을 생성하는 방법은 아래와 같습니다.

  1. HMAC SHA-256 알고리즘을 사용하여, 전체 받은 페이로드의 해시(secret 을 키로 사용)를 binary 형태로 생성합니다.
  2. Binary 해시를 base64 형식으로 인코딩합니다.
  3. Binary 해시 앞에 sha256= 접두사를 추가합니다.
  4. 모아폼에서 moaform-signature 헤더로 받은 서명과 생성한 값을 비교합니다.

아래는 몇가지 언어별 예시입니다.

 

Java

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.IOException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;

import org.apache.commons.lang3.StringUtils; // For secure string comparison

public class WebhookHandler {

    private static final String SECRET_TOKEN = System.getenv("SECRET_TOKEN"); // Retrieve from environment

    public void handleWebhook(HttpServletRequest request, HttpServletResponse response) throws IOException {
        try {
            String payloadBody = request.getReader().lines().collect(Collectors.joining());
            verifySignature(request.getHeader("moaform-signature"), payloadBody);
            response.getWriter().println("Payload received: " + payloadBody);
        } catch (SignatureMismatchException e) {
            response.sendError(500, "Signatures don't match!");
        }
    }

    private void verifySignature(String receivedSignature, String payloadBody) throws SignatureMismatchException {
        try {
            String actualSignature = calculateSignature(payloadBody);
            if (!StringUtils.equals(actualSignature, receivedSignature)) { // Secure comparison
                throw new SignatureMismatchException();
            }
        } catch (NoSuchAlgorithmException | InvalidKeyException e) {
            throw new RuntimeException("Error calculating signature", e);
        }
    }

    private String calculateSignature(String payloadBody) throws NoSuchAlgorithmException, InvalidKeyException {
        Mac hmac = Mac.getInstance("HmacSHA256");
        SecretKeySpec secretKey = new SecretKeySpec(SECRET_TOKEN.getBytes(), "HmacSHA256");
        hmac.init(secretKey);
        byte[] hash = hmac.doFinal(payloadBody.getBytes());
        return "sha256=" + Base64.getEncoder().encodeToString(hash);
    }
}

class SignatureMismatchException extends Exception {
}

 

Ruby

post '/webhook' do
  request.body.rewind
  payload_body = request.body.read
  verify_signature(request.env['moaform-signature'], payload_body)
  "Payload received: #{payload_body.inspect}"
end

def verify_signature(received_signature, payload_body)
  hash = OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha256'), ENV['SECRET_TOKEN'], payload_body)
  actual_signature = 'sha256=' + Base64.strict_encode64(hash)
  return halt 500, "Signatures don't match!" unless Rack::Utils.secure_compare(actual_signature, received_signature)
end

 

Node.js with Express

const crypto = require('crypto')
app.use(express.raw({ type: 'application/json' }))
app.post('/webhook', async (request, response) => {
  const signature = request.headers['moaform-signature']
  const isValid = verifySignature(signature, request.body.toString())
})

const verifySignature = function (receivedSignature, payload) {
  const hash = crypto
    .createHmac('sha256', process.env.SECRET_TOKEN)
    .update(payload)
    .digest('base64')
  return receivedSignature === `sha256=${hash}`}

 

Python

from fastapi import FastAPI,Request,HTTPException

import hashlib
import hmac
import json
import base64
import os

app = FastAPI()

@app.post("/hook")
async def recWebHook(req: Request):
  body = await req.json()
  raw = await req.body()

  receivedSignature = req.headers.get("moaform-signature")

  if receivedSignature is None:
    return HTTPException(403, detail="Permission denied.")

  sha_name, signature = receivedSignature.split('=', 1)
  if sha_name != 'sha256':
    return HTTPException(501, detail="Operation not supported.")

  is_valid = verifySignature(signature, raw)

  if(is_valid != True):
    return HTTPException(403, detail="Invalid signature. Permission Denied.")


def verifySignature(receivedSignature: str, payload):
  WEBHOOK_SECRET = os.environ.get('MOAFORM_SECRET_KEY')
  digest = hmac.new(WEBHOOK_SECRET.encode('utf-8'), payload, hashlib.sha256).digest()
  e = base64.b64encode(digest).decode()
  if(e == receivedSignature):
    return True
  return False