SonarQube | Webhook HMAC 검증 적용기

2026. 6. 12. 08:06·🏰 Back-end

목차

  1. HMAC이란 무엇인가
  2. SonarQube에서 HMAC secret을 설정하는 방법
  3. Spring Boot webhook endpoint에서 HMAC을 검증하는 방법
  4. Postman으로 HMAC webhook을 테스트하는 방법

작성 배경

보안 리포트를 받았는데, SonarQube 웹훅에서 HMAC을 검증하지 않는 이슈가 하나 잡혔다. 심각도도 High였다.

고치는 것도 중요했지만,  이번 기회에 HMAC이 뭔지, 왜 필요한지, 그리고 실제로 어떻게 붙였는지 한 번 정리해 봤다.

 

SonarQube webhook은 분석이 끝났을 때 특정 서버로 JSON payload를 보낼 수 있다.

처음에는 HMAC을 왜 이렇게까지 해야 하는지 잘 와닿지 않았다.

그냥 SonarQube webhook을 endpoint에서 받기만 하면 되는 것 아닌가? 

 

근데 바로 그 생각 자체가 HMAC이 필요한 이유였다.

이 payload를 받아 SonarQube 정적 분석 결과를 확정하는 비즈니스 로직 있다고 가정해 보자. 

 

누군가 webhook 요청을 임의로 조작해 이 endpoint를 호출한다면 어떻게 될까. 실제 정적 분석 결과는 실패인데, 성공한 것처럼 꾸며서 보낸다면? 그러면 서버 입장에서는 이 요청이 정말 SonarQube에서 온 건지, 아니면 중간에서 바뀐 건지 구분할 수가 없어진다. 

 

의심스러운데

 

1. HMAC이란?

HMAC은 Hash-based Message Authentication Code의 약자다. 단순한 hash와 달리 secret key를 같이 써서 메시지 인증 코드를 만든다.

처음에는 X-Sonar-Webhook-HMAC-SHA256 자체가 secret key인 줄 알았다. 이름도 묘하게 "HMAC SHA256 key"처럼 보여서, Postman에서 이 값을 헤더에 넣고 아무리 보내도 계속 401 Unauthorized가 떨어졌다. secret을 넣었는데 왜 인증이 안 되는지 한참 헷갈렸다.

 

SonarQube 공식 문서를 다시 보고 나서야 내가 완전히 반대로 이해하고 있었다는 걸 알게 됐다. SonarQube는 webhook을 만들 때 지정한 secret key로 payload마다 HMAC hex digest를 계산하고, 그 결과를 X-Sonar-Webhook-HMAC-SHA256 HTTP header로 보낸다. 그러니까 헤더에 들어가는 값은 secret key가 아니라, payload와 secret key로 계산한 signature다.

 

 

SonarQube webhook HMAC에서 중요한 값은 아래 네 가지다.

  • 입력 메시지: HTTP request body의 raw bytes
  • secret key: SonarQube webhook 설정에 입력한 공유 secret
  • 알고리즘: HMAC-SHA256
  • 결과 형식: 64자리 lower-case hex 문자열
  • 전달 header: X-Sonar-Webhook-HMAC-SHA256

즉 X-Sonar-Webhook-HMAC-SHA256 header에는 secret key 원문이 오지 않는다.

SonarQube가 HMAC-SHA256(raw body bytes, secret key)를 계산한 결과만 들어온다.

 

흐름은 다음과 같다.

  1. SonarQube가 webhook을 보내기 직전
    • Message, 즉 raw request body를 준비한다.
    • SonarQube에 저장된 Secret Key를 함께 사용한다.
    • HMAC-SHA256(Message, Secret Key)를 계산한다.
    • 이 결과가 X-Sonar-Webhook-HMAC-SHA256 header에 들어가는 Signature다.
  2. Server가 webhook을 받은 직후
    • request body를 아직 DTO로 파싱 하지 않고 raw body bytes 상태로 둔다.
    • Server에 설정된 같은 Secret Key를 사용한다.
    • HMAC-SHA256(받은 Message, Secret Key)를 다시 계산한다.
    • 이 결과가 Server 쪽 Signature다.
  3. Server가 비교
    • SonarQube가 header로 보낸 Signature
    • Server가 직접 계산한 Signature
    • 두 값이 같으면 통과, 다르면 401

여기서 hash는 별도 값으로 먼저 만들어져 전달되는 게 아니다. Message + Secret Key를 HMAC-SHA256 알고리즘에 넣어 Signature를 만드는 과정에서 내부적으로 SHA-256 hash 계산이 수행된다. 즉 hash는 SonarQube와 Server가 각각 Signature를 만드는 순간에 같이 들어간다고 보면 된다.

 

더 쉽게 정리하면 다음과 같다. 

Message + Secret Key
        |
        v
HMAC-SHA256 내부에서 hash 계산
        |
        v
Signature

HMAC 흐름 도식

2. SonarQube에서 HMAC 설정하기

SonarQube Server 공식 문서 기준으로 webhook은 project level 또는 global level에 설정할 수 있다.

Project level webhook은 Project Settings > Webhooks에서 생성한다. Global webhook은 시스템 관리자 권한으로 Administration > Configuration > Webhooks에서 생성한다.

 

 

 

 

📌 주의할 점

소나큐브에서는 webhook URL host가 localhost,127.0.0.1, 0.0.0.0 등  loopback, wildcard 또는 SonarQube 서버 자신의 local address로 해석될 경우 Invalid URL: loopback and wildcard addresses are not allowed for webhooks. 예외를 발생시킨다.  따라서 SonarQube 서버에서 접근 가능한 도메인 주소로 구성해야 한다. [출처]

 

서버에서 설정 값은 다음과 같이 구성하면 된다.

SonarQube는 이 Secret 값을 사용해 webhook payload의 HMAC hex digest를 생성하고, 그 결과를 X-Sonar-Webhook-HMAC-SHA256 header에 담아 보낸다. 운영 환경에서는 이 값을 plain YAML에 고정하기보다 Secret, 환경 변수, 외부 설정 저장소 등을 통해 주입해서 사용해야 한다. 

server:
  sonarqube:
    webhook:
      secret-key: sonarqube-webhook-secret

3. Webhook endpoint에서 HMAC 검증하기

현재 HMAC 적용 endpoint는 POST /sonarqube/v1/webhook이다.

Controller는 request body를 DTO로 바로 받지 않고 byte []로 받는다.

@PostMapping("/webhook")
public ResponseEntity<Void> webhookHmac(
        @RequestHeader(name = "X-Sonar-Webhook-HMAC-SHA256", required = false) String signature,
        @RequestBody byte[] body
) {
    log.info("SonarQube HMAC header present={}, length={}",
            signature != null,
            signature == null ? 0 : signature.length());

    // X-Sonar-Webhook-HMAC-SHA256 값은 secret key 원문이 아니라
    // HMAC-SHA256(raw body bytes, secret key) 결과인 64자리 lower-case hex 서명값이다.
    // Jackson DTO로 변환하면 공백/필드 순서/escaping 등 원문 정보가 사라질 수 있으므로
    // raw body bytes로 먼저 서명을 검증한 뒤 SonarQubeWebhookRequest로 파싱한다.
    if (!signatureVerifier.isValid(body, signature)) {
        throw new InvalidSonarQubeWebhookSignatureException();
    }

    parseRequest(body);
    return ResponseEntity.ok().build();
}

private SonarQubeWebhookRequest parseRequest(byte[] body) {
    try {
        return objectMapper.readValue(body, SonarQubeWebhookRequest.class);
    } catch (IOException e) {
        throw new InvalidSonarQubeWebhookPayloadException(e);
    }
}

 

핵심은 HMAC 검증을 JSON DTO 파싱보다 먼저 해야 한다는 점이다.

JSON은 의미상 같은 데이터라도 문자열 표현이 달라질 수 있다. 공백, 줄 바꿈, 필드 순서, escaping 방식이 달라지면 JSON 객체로는 같아 보여도 raw body bytes는 달라진다. SonarQube가 서명한 대상은 JSON의 의미 구조가 아니라 실제 HTTP request body 이므로

서버에서도 반드시 raw body bytes로 먼저 서명을 검증하고, 검증이 끝난 뒤 비즈니스 DTO로 파싱해야 한다.

 

Verifier 구현 흐름은 다음과 같다.

public boolean isValid(byte[] body, String receivedSignature) {
	// X-Sonar-Webhook-HMAC-SHA256 header가 없거나 blank면 실패한다.
    if (!StringUtils.hasText(receivedSignature)) {
        return false;
    }
	
    byte[] receivedDigest = decodeHex(receivedSignature.trim());
    if (receivedDigest == null) {
        return false;
    }
	// 서버의 secret key와 raw body bytes로 HMAC-SHA256 digest를 계산한다.
    byte[] expectedDigest = hmac(body == null ? new byte[0] : body);
    
    // 계산한 digest와 header digest를 MessageDigest.isEqual로 비교한다.
    return MessageDigest.isEqual(expectedDigest, receivedDigest);
}

private byte[] hmac(byte[] body) {
    try {
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secretKey, "HmacSHA256"));
        return mac.doFinal(body);
    } catch (Exception e) {
        throw new IllegalStateException("failed to verify SonarQube webhook signature", e);
    }
}

private byte[] decodeHex(String value) {
    try {
        return HexFormat.of().parseHex(value);
    } catch (IllegalArgumentException exception) {
        return null;
    }
}

 

검증에 실패하면 Controller에서 401 Unauthorized를 반환하도록 했다. 

@ExceptionHandler(InvalidSonarQubeWebhookSignatureException.class)
public ResponseEntity<Void> handleInvalidSignature(InvalidSonarQubeWebhookSignatureException e) {
    return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}

4. Postman에서 테스트하기

Postman으로 직접 테스트할 때는 request body와 secret으로 HMAC을 계산한 뒤 header에 넣어 보내면 된다.

먼저 environment variable을 만든다.

Postman > 요청 탭 오른쪽 + 아이콘

sonarqube_webhook_secret = sonarqube-webhook-secret

 

Body는 raw JSON으로 작성한다.

{
  "taskId": "task-hmac-1",
  "status": "SUCCESS",
  "project": {
    "key": "project-a",
    "name": "Project A"
  },
  "qualityGate": {
    "status": "OK"
  },
  "properties": {
    "sonar.analysis.sonarqube-branch": "main"
  }
}

 

Pre-request Script에는 다음을 넣는다.

  const secret = pm.environment.get("sonarqube-webhook-secret");
  const rawBody = pm.request.body && pm.request.body.raw ?
  pm.request.body.raw : "";

  console.log("secret exists:", !!secret);
  console.log("rawBody:", rawBody);

  if (!secret) {
    throw new Error("sonarqube-webhook-secret environment variable is missing");
  }

  const signature = CryptoJS.HmacSHA256(rawBody,
  secret).toString(CryptoJS.enc.Hex);

  pm.request.headers.upsert({
    key: "X-Sonar-Webhook-HMAC-SHA256",
    value: signature
  });

 

 

검증 시나리오별 응답 결과

시나리오  상태코드 응답 캡쳐
정상 secret + 정상 body 200 OK



header 누락 또는 잘못된 signature 401 Unauthorized

signature는 맞지만 JSON 형식이 깨진 body 400 Bad Request

 

 

마무리

HMAC을 어떻게 비유하면 좋을까 고민하다. 가장 근접한 비슷하다고 생각되는 상황으로 풀어봤다. 


편지를 봉투에 넣고 둘만 아는 문양의 실링왁스로 봉인하는 것과 비슷하다.

보내는 쪽은 편지 내용(body)을 확인하고, 고유한 문양(secret key)으로 봉인(signature)을 찍어 보낸다.

받는 쪽도 같은 내용과 같은 문양을 기준으로 다시 봉인을 찍어 보고, 서로 같은지 확인한다.

결국 중요한 건 봉인 문양(signature)이 같은지, 그리고 편지 내용(raw body)이 그대로 인지다.

 

둘이 같을 때만 "이 요청은 믿을 수 있다"라고 판단한다.

 

HMAC 설정 덕분에 외부로 열려 있던 webhook endpoint를 최소한의 비용으로 안전하게 지킬 수 있었다. 여기서 안전하게 지킨다는 건 누군가 임의로 SonarQube webhook 결과를 조작하지 못하게 막고, 결국 비즈니스 로직의 무결성을 지킬 수 있게 됐다는 뜻이다. 로컬에서 테스트할 때도 예외는 아니다. ^^; (Postman을 쓰면 되긴 하지만 Pre-request Script 설정은 꼭 필요하다.)

 

한 줄 요약

HMAC은 같은 secret과 같은 raw body로 계산한 서명이 같은지 확인해 무결성과 인증성을 검증하는 해시 기반 메시지 인증 코드다.

 

'🏰 Back-end' 카테고리의 다른 글

SonarQube | 네? 소나큐브에서 10,000개 이상 이슈는 안 보인다고요?  (2) 2026.07.04
SonarQube | 같은 소스코드를 분석했는데 분석 시간이 14배 늘어났다.  (0) 2026.06.02
255개는 되는데 256개부터 터진다고요? Spring @ModelAttribute 컬렉션 바인딩 제한으로 대량 요청이 실패한 이유  (0) 2026.03.17
Template Method + Reader 추상화로 확장 가능한 Validator 만들기  (0) 2026.01.22
[SonarQube] SonarQube 업그레이드에 따른 SonarScanner 버전을 확인해보자 (SonarQube 10.5.1)  (0) 2024.10.27
'🏰 Back-end' 카테고리의 다른 글
  • SonarQube | 네? 소나큐브에서 10,000개 이상 이슈는 안 보인다고요?
  • SonarQube | 같은 소스코드를 분석했는데 분석 시간이 14배 늘어났다.
  • 255개는 되는데 256개부터 터진다고요? Spring @ModelAttribute 컬렉션 바인딩 제한으로 대량 요청이 실패한 이유
  • Template Method + Reader 추상화로 확장 가능한 Validator 만들기
seongit
seongit
기술 뒤 사람 있어요!
  • seongit
    사람과 사람을 IT는 개발자
    seongit
  • 전체
    오늘
    어제
    • 분류 전체보기 (236) N
      • 🏰 Back-end (39)
        • Server (8)
        • Spring Boot (10)
        • DB (4)
      • 🎨 Front-end (18)
        • JavaScript (5)
        • HTML + CSS (0)
      • 📓 Study (79)
        • CS (9)
        • Tech Interview (37)
        • Algorithm (5)
        • Coding Test (25)
        • Design Pattern (1)
      • 📝 Review (41) N
        • 💡 인사이트 (9)
        • 📝 후기와 회고 (31) N
      • 🚨 Error (43)
        • JAVA (8)
        • ORACLE (11)
        • JavaScript (10)
      • 🌿 Project (12)
        • 기능 구현 (12)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    코딩테스트
    파이널프로젝트회고
    글또10기
    java
    길벗리뷰어
    백준
    토스트UI
    21년3회정보처리기사
    클래스와객체
    회고
    springboot
    스프링부트독학
    BOJ
    스프링부트
    초보개발자
    코테
    개발약어
    TOAST UI Editor
    정처기필기합격후기
    카카오 로그인 API
    토스트UI에디터
    It
    정보처리기능사독학
    기술면접
    javascript
    백준2735번
    개발면접
    생활코딩
    CORS란
    자바
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
seongit
SonarQube | Webhook HMAC 검증 적용기
상단으로

티스토리툴바