참고
- 보안을 위해 웹훅 endpoint URL은 가급적 https 사용을 권장합니다. https가 http보다 안전하며, https를 사용할 경우 SSL/TLS 인증서가 유효해야 합니다. (Self-signed certificates 는 동작하지 않습니다.)
- 현재 모아폼 웹훅 요청에 대한 지정된 IP 주소가 없습니다. 모아폼은 AWS 서버에서 호스팅되며, 동적 IP 주소를 사용하므로 고정 IP 주소나 IP 주소 범위를 보장할 수 없습니다.
웹훅을 수신하기 위한 endpoint URL은 외부에 노출될 수 있으므로 보안에 취약합니다. 웹훅 설정에서 '비밀 코드'를 입력하고, 각 웹훅 페이로드를 이 비밀코드로 서명하도록 하여 이 문제를 해결할 수 있습니다. 서명은 요청 헤더에 포함되며, 이를 통해 웹훅이 모아폼에서 온 것임을 검증할 수 있습니다.
웹훅 비밀코드는 user interface를 이용해서 웹훅을 생성할 때 설정할 수 있고, API로 웹훅을 새로 만들때도 설정할 수 있습니다.
모아폼에서 온 페이로드 검증
모아폼에서 수신한 페이로드 내 서명을 검증하기 위해서는, 웹훅 설정시 입력한 비밀 코드 값으로 직접 서명을 생성한 다음, 웹훅 페이로드에서 받은 서명과 비교해야 합니다.
비밀 코드 값으로 직접 서명을 생성하는 방법은 아래와 같습니다.
- HMAC SHA-256 알고리즘을 사용하여, 전체 받은 페이로드의 해시(
secret
을 키로 사용)를 binary 형태로 생성합니다. - Binary 해시를 base64 형식으로 인코딩합니다.
- Binary 해시 앞에
sha256=
접두사를 추가합니다. - 모아폼에서
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