Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package com.eatsfine.eatsfine.domain.payment.controller;

import com.eatsfine.eatsfine.domain.payment.dto.request.PaymentWebhookDTO;
import com.eatsfine.eatsfine.domain.payment.exception.PaymentException;
import com.eatsfine.eatsfine.domain.payment.service.PaymentService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.web.bind.annotation.RequestHeader;
import jakarta.validation.Validator;
import jakarta.validation.ConstraintViolation;
import java.util.Set;

@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/payments/webhook")
@Tag(name = "Payment Webhook Controller", description = "Toss Payments 웹훅 수신 전용 컨트롤러")
public class PaymentWebhookController {

private final PaymentService paymentService;
private final com.eatsfine.eatsfine.domain.payment.service.TossPaymentService tossPaymentService;
private final ObjectMapper objectMapper;
private final Validator validator;

@Operation(summary = "Toss Payments 웹훅 수신", description = "Toss Payments 서버로부터 결제/취소 결과(PaymentKey, Status 등)를 수신하여 서버 상태를 동기화합니다.")
@PostMapping
public ResponseEntity<String> handleWebhook(
@RequestBody String jsonBody,
@RequestHeader("tosspayments-webhook-signature") String signature,
@RequestHeader("tosspayments-webhook-transmission-time") String timestamp) throws JsonProcessingException {
Comment on lines +38 to +39
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The webhook endpoint accepts String parameters for signature and timestamp headers without any validation (null checks, format validation, etc.) before using them. If these headers are missing or null, the verifyWebhookSignature method will fail with a NullPointerException instead of returning a clear error. Consider adding @RequestHeader(required = true) or manual null checks with appropriate error responses.

Copilot uses AI. Check for mistakes.
Comment on lines +36 to +39
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JsonProcessingException from objectMapper.readValue is thrown in the method signature but will never actually be thrown because it's caught by the general Exception handler below. This makes the 'throws JsonProcessingException' declaration misleading and unnecessary. Consider either removing it from the method signature or handling it explicitly before the general catch block.

Copilot uses AI. Check for mistakes.

try {
tossPaymentService.verifyWebhookSignature(jsonBody, signature, timestamp);
} catch (Exception e) {
log.error("Webhook signature verification failed", e);
return ResponseEntity.status(401).body("Invalid Signature");
}

PaymentWebhookDTO dto = objectMapper.readValue(jsonBody, PaymentWebhookDTO.class);

if (hasValidationErrors(dto)) {
return ResponseEntity.badRequest().body("Validation failed");
}

log.info("Webhook received: orderId={}, status={}", dto.data().orderId(), dto.data().status());

try {
paymentService.processWebhook(dto);
} catch (PaymentException e) {
log.error("Webhook processing failed (Business Logic): {}", e.getMessage());
return ResponseEntity.ok("Ignored: " + e.getMessage());
Comment on lines +58 to +60
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The exception handling returns 200 OK with "Ignored: " message for PaymentException. According to webhook best practices and Toss Payments documentation, returning 200 OK tells Toss that the webhook was successfully processed and prevents retries. However, for genuine business logic errors (like PAYMENT_NOT_FOUND), you might want the webhook to be retried. Consider returning 4xx status codes for unrecoverable errors and 5xx for temporary errors that should be retried.

Copilot uses AI. Check for mistakes.
} catch (Exception e) {
log.error("Webhook processing failed (System Error)", e);
return ResponseEntity.internalServerError().body("Internal Server Error");
}

return ResponseEntity.ok("Received");
}
Comment on lines +35 to +67
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The webhook functionality lacks test coverage. Given that this is a critical payment integration feature with security implications (signature verification), state transitions, and concurrency handling (optimistic locking), comprehensive tests are essential. The codebase shows evidence of testing practices (e.g., HealthControllerTest at src/test/java/com/eatsfine/eatsfine/controller/HealthControllerTest.java). Consider adding tests for: webhook signature verification, amount validation, state transition logic, concurrent webhook handling, and edge cases like missing headers or malformed payloads.

Copilot uses AI. Check for mistakes.

private boolean hasValidationErrors(PaymentWebhookDTO dto) {
Set<ConstraintViolation<PaymentWebhookDTO>> violations = validator.validate(dto);
if (!violations.isEmpty()) {
StringBuilder sb = new StringBuilder();
for (ConstraintViolation<PaymentWebhookDTO> violation : violations) {
sb.append(violation.getPropertyPath()).append(" ").append(violation.getMessage()).append("; ");
}
log.error("Webhook validation failed: {}", sb.toString());
return true;
}
return false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.eatsfine.eatsfine.domain.payment.dto.request;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;

@JsonIgnoreProperties(ignoreUnknown = true)
public record PaymentWebhookDTO(
@NotBlank String eventType,
@Valid @NotNull PaymentData data) {
@JsonIgnoreProperties(ignoreUnknown = true)
public record PaymentData(
@NotBlank String paymentKey,
@NotBlank String orderId,
@NotBlank String status,
BigDecimal totalAmount,
EasyPay easyPay) {
}

@JsonIgnoreProperties(ignoreUnknown = true)
public record EasyPay(
String provider) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ public class Payment extends BaseEntity {
@Column(name = "payment_id")
private Long id;

// 낙관적 락을 위한 버전 필드
@Version
private Long version;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "booking_id", nullable = false)
private Booking booking;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.eatsfine.eatsfine.domain.booking.entity.Booking;
import com.eatsfine.eatsfine.domain.booking.repository.BookingRepository;
import com.eatsfine.eatsfine.domain.payment.dto.request.PaymentWebhookDTO;
import com.eatsfine.eatsfine.domain.payment.dto.request.PaymentConfirmDTO;
import com.eatsfine.eatsfine.domain.payment.dto.request.PaymentRequestDTO;
import com.eatsfine.eatsfine.domain.payment.dto.response.PaymentResponseDTO;
Expand Down Expand Up @@ -220,4 +221,80 @@ public PaymentResponseDTO.PaymentDetailResultDTO getPaymentDetail(Long paymentId
null // 환불 상세 정보는 현재 null 처리
);
}

@Transactional
public void processWebhook(PaymentWebhookDTO dto) {
Comment on lines +225 to +226
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The @transactional annotation on processWebhook doesn't handle OptimisticLockException (or its Spring equivalent ObjectOptimisticLockingFailureException) that can be thrown when the version field conflicts during concurrent webhook deliveries. Toss Payments may deliver the same webhook multiple times, and without retry logic or proper exception handling, legitimate updates could be lost. Consider adding retry logic with @retryable or catching and handling the optimistic lock exception appropriately.

Copilot uses AI. Check for mistakes.
// 이벤트 타입 검증
if (!"PAYMENT_STATUS_CHANGED".equals(dto.eventType())) {
log.info("Webhook skipped: Unhandled event type {}", dto.eventType());
return;
}

PaymentWebhookDTO.PaymentData data = dto.data();

Payment payment = paymentRepository.findByOrderId(data.orderId())
.orElseThrow(() -> new PaymentException(PaymentErrorStatus._PAYMENT_NOT_FOUND));

PaymentStatus targetStatus = null;
if ("DONE".equals(data.status())) {
targetStatus = PaymentStatus.COMPLETED;
} else if ("CANCELED".equals(data.status())) {
targetStatus = PaymentStatus.REFUNDED;
}

if (targetStatus == null) {
log.info("Webhook skipped: Unknown or unhandled status {}", data.status());
return;
}

if (payment.getPaymentStatus() == targetStatus) {
log.info("Webhook skipped: Payment {} already in status {}", data.orderId(), targetStatus);
return;
}

// 상태 전환 유효성 검사
// COMPLETED 완료 처리는 오직 PENDING 상태에서만 가능
if (targetStatus == PaymentStatus.COMPLETED && payment.getPaymentStatus() != PaymentStatus.PENDING) {
log.warn("Webhook skipped: Invalid state transition from {} to {} for OrderID {}",
payment.getPaymentStatus(), targetStatus, data.orderId());
return;
}
if (targetStatus == PaymentStatus.REFUNDED && payment.getPaymentStatus() != PaymentStatus.COMPLETED) {
log.warn("Webhook skipped: Invalid state transition from {} to {} for OrderID {}",
payment.getPaymentStatus(), targetStatus, data.orderId());
return;
}
Comment on lines +255 to +266
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The state transition validation only checks transitions to COMPLETED and REFUNDED, but doesn't handle the FAILED status. If a payment is in FAILED status and a webhook arrives with DONE status, the validation at line 257 will reject it (FAILED != PENDING), which is correct. However, this edge case isn't explicitly documented and could be confusing. Consider adding an explicit check or comment explaining that FAILED payments cannot transition to COMPLETED.

Copilot uses AI. Check for mistakes.

if (targetStatus == PaymentStatus.COMPLETED) {
// 금액 검증
if (data.totalAmount() == null || payment.getAmount().compareTo(data.totalAmount()) != 0) {
log.error("Webhook amount verification failed for OrderID: {}. Expected: {}, Received: {}",
data.orderId(), payment.getAmount(), data.totalAmount());
payment.failPayment();
return;
}
Comment on lines +270 to +275
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When amount verification fails and failPayment() is called, the method returns immediately without throwing an exception or providing proper feedback to Toss Payments. This could cause webhook retry storms because Toss will keep retrying when it doesn't receive a 200 OK response for a successfully processed (even if invalid) webhook. Consider whether this should throw an exception or return a specific response code to prevent unnecessary retries.

Copilot uses AI. Check for mistakes.

// Provider 파싱
PaymentProvider provider = null;
if (data.easyPay() != null) {
String providerCode = data.easyPay().provider();
if ("토스페이".equals(providerCode)) {
provider = PaymentProvider.TOSS;
} else if ("카카오페이".equals(providerCode)) {
provider = PaymentProvider.KAKAOPAY;
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The provider mapping is incomplete and may silently fail for other payment providers. According to Toss Payments documentation, there are additional providers like 네이버페이, 페이코, 삼성페이, etc. Currently, if a user pays with an unsupported provider, the payment will complete but the provider field will be null, causing data loss. Consider either adding comprehensive provider mapping or logging a warning when an unknown provider is encountered.

Suggested change
provider = PaymentProvider.KAKAOPAY;
provider = PaymentProvider.KAKAOPAY;
} else if (providerCode != null) {
// 지원하지 않는 간편결제 제공자 코드 처리
log.warn("Unknown easyPay provider '{}' for orderId {}", providerCode, data.orderId());

Copilot uses AI. Check for mistakes.
}
Comment on lines +280 to +285
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential NullPointerException when accessing data.easyPay().provider(). The code checks if data.easyPay() is not null but then immediately calls .provider() on it without null-checking the provider field itself. If the easyPay object exists but provider is null, the string comparison will fail. Consider checking if providerCode is not null before performing string comparisons.

Copilot uses AI. Check for mistakes.
}

payment.completePayment(
LocalDateTime.now(),
PaymentMethod.SIMPLE_PAYMENT,
Comment on lines +288 to +290
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoded PaymentMethod.SIMPLE_PAYMENT may not match the actual payment method used. In the confirmPayment method (lines 110-116), the payment method is set based on the Toss API response, but here it's hardcoded. The webhook payload may contain method information that should be parsed similarly to how it's done in confirmPayment to ensure consistency. This could lead to data inconsistencies where the payment method stored differs depending on whether it was set via the confirm API or the webhook.

Suggested change
payment.completePayment(
LocalDateTime.now(),
PaymentMethod.SIMPLE_PAYMENT,
// Determine payment method from existing payment or webhook payload, instead of hardcoding.
PaymentMethod paymentMethod = payment.getPaymentMethod();
try {
// If the webhook provides a method value, try to map it to the PaymentMethod enum.
// If dto.method() is null or invalid, we keep the existing payment method.
if (dto.method() != null) {
paymentMethod = PaymentMethod.valueOf(dto.method());
}
} catch (IllegalArgumentException e) {
log.warn("Unknown payment method '{}' in webhook for order {}. Keeping existing method {}.",
dto.method(), dto.orderId(), paymentMethod, e);
}
payment.completePayment(
LocalDateTime.now(),
paymentMethod,

Copilot uses AI. Check for mistakes.
data.paymentKey(),
provider,
null);
log.info("Webhook processed: Payment {} status updated to COMPLETED", data.orderId());
} else if (targetStatus == PaymentStatus.REFUNDED) {
payment.cancelPayment();
log.info("Webhook processed: Payment {} status updated to REFUNDED", data.orderId());
}
}
Comment on lines +225 to +299
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The processWebhook method lacks test coverage for critical business logic including state transition validation, amount verification, and provider parsing. The codebase demonstrates testing practices (e.g., HealthControllerTest). Consider adding unit tests for: event type filtering, status mapping (DONE→COMPLETED, CANCELED→REFUNDED), state transition rules (PENDING→COMPLETED, COMPLETED→REFUNDED), amount mismatch handling, provider mapping edge cases, and optimistic lock conflict scenarios.

Copilot uses AI. Check for mistakes.
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,17 @@
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import org.springframework.beans.factory.annotation.Value;
Comment on lines 9 to +12
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Import statements are not properly ordered. The @value import from org.springframework.beans.factory.annotation should appear before @qualifier import to maintain alphabetical ordering of Spring Framework imports.

Suggested change
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;

Copilot uses AI. Check for mistakes.

@Slf4j
@Service
public class TossPaymentService {

private final RestClient tossPaymentClient;

@Value("${payment.toss.widget-secret-key}")
private String widgetSecretKey;

public TossPaymentService(@Qualifier("tossPaymentClient") RestClient tossPaymentClient) {
this.tossPaymentClient = tossPaymentClient;
}
Expand Down Expand Up @@ -45,4 +49,20 @@ public TossPaymentResponse cancel(String paymentKey, PaymentRequestDTO.CancelPay
throw new GeneralException(ErrorStatus._INTERNAL_SERVER_ERROR);
}
}

public void verifyWebhookSignature(String jsonBody, String signature, String timestamp) throws Exception {
String payload = timestamp + "." + jsonBody;
String calculatedSignature = hmacSha256(payload, widgetSecretKey);

if (!signature.contains("v1:" + calculatedSignature)) {
Comment on lines +56 to +57
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The signature verification uses contains() which is vulnerable to substring attacks. An attacker could potentially send a signature like "v1:validSignatureFromAnotherRequestXXXX" and it would pass validation. Use equals() with proper prefix handling instead. The signature should be exactly "v1:" + calculatedSignature, not just contain it.

Suggested change
if (!signature.contains("v1:" + calculatedSignature)) {
String expectedSignature = "v1:" + calculatedSignature;
if (signature == null || !expectedSignature.equals(signature)) {

Copilot uses AI. Check for mistakes.
throw new SecurityException("Signature verification failed");
}
Comment on lines +53 to +59
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

Toss Payments webhook signature verification payload format HMAC

💡 Result:

For Toss Payments (토스페이먼츠) webhooks that include a signature (payout.changed, seller.changed), the HMAC input (“signed payload”) format is:

  • message to sign:
    {WEBHOOK_PAYLOAD}:{tosspayments-webhook-transmission-time}
    
  • HMAC algorithm: HMAC-SHA256
  • HMAC key: your Toss Payments 보안 키 (security key) (the webhook signing secret) (docs.tosspayments.com)

Signature header format

  • Header: tosspayments-webhook-signature
  • It contains two v1 signatures, e.g.:
    v1:<sigA>,v1:<sigB>
    
  • Verification:
    1. Base64-decode both <sigA> and <sigB>
    2. Compute HMAC-SHA256(key=securityKey, msg="{payload}:{transmissionTime}")
    3. If your computed HMAC matches either decoded value, the webhook is valid (docs.tosspayments.com)

Notes:

  • tosspayments-webhook-transmission-time is the timestamp header you must append after : in the signed payload (docs.tosspayments.com)

Citations:


🌐 Web query:

Toss Payments tosspayments-webhook-transmission-time signature format documentation

💡 Result:

Toss Payments 웹훅에서 tosspayments-webhook-transmission-time / signature 형식은 아래처럼 문서에 정의돼 있습니다.

  • tosspayments-webhook-transmission-time: “웹훅이 발송된 시간” 헤더이며, 예시처럼 ISO 8601 타임스탬프 문자열로 옵니다. (docs.tosspayments.com)
  • tosspayments-webhook-signature 포함 범위: 모든 웹훅에 항상 오는 값이 아니라, 문서 기준으로 payout.changed, seller.changed 웹훅에만 포함됩니다. (docs.tosspayments.com)
  • signature 헤더 문자열 형식: v1:<sig1>,v1:<sig2> 처럼 v1: 프리픽스가 붙은 값 2개가 콤마로 구분되어 전달됩니다. (toss.tech)
  • 검증(verification) 데이터 포맷: 아래 문자열을 만들고 보안 키로 HMAC-SHA256을 계산합니다. (docs.tosspayments.com)
    • {WEBHOOK_PAYLOAD}:{tosspayments-webhook-transmission-time}
  • 비교 방식: 헤더의 두 v1: 값 각각에서 v1: 뒤를 떼어 base64 디코딩한 바이트 배열과, HMAC 결과를 비교해서 둘 중 하나라도 일치하면 유효합니다. (docs.tosspayments.com)

추가로, 개발자 커뮤니티 사례에서도 tosspayments-webhook-transmission-time, -id, -retried-count는 보이는데 signature가 없는 경우가 언급되며(즉 “항상 있는 헤더가 아님” 상황), 위 “payout/seller에만 포함” 규정과 맥락이 맞습니다. (techchat.tosspayments.com)

Citations:


🏁 Script executed:

# Check the codebase for any Toss documentation references or comments
rg -i "toss|webhook|signature" src/main/java/com/eatsfine/eatsfine/domain/payment/service/TossPaymentService.java -B 2 -A 2

Repository: Eatsfine/BE

Length of output: 2160


🏁 Script executed:

# Look for any test files that might clarify the expected signature format
fd -e java -path "*test*" -o -path "*spec*" | xargs rg -l "TossPayment|webhook.*signature" 2>/dev/null | head -20

Repository: Eatsfine/BE

Length of output: 656


🏁 Script executed:

# Check the webhook controller to see how signature is extracted
cat src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentWebhookController.java

Repository: Eatsfine/BE

Length of output: 3675


🏁 Script executed:

# Look for any test files related to webhook signature verification
fd -type f -name "*.java" | xargs grep -l "verifyWebhookSignature\|webhook.*signature" 2>/dev/null

Repository: Eatsfine/BE

Length of output: 227


🏁 Script executed:

# Search for test files with webhook or signature tests
find . -name "*.java" -type f 2>/dev/null | xargs grep -l "webhook.*signature\|verifyWebhook" 2>/dev/null

Repository: Eatsfine/BE

Length of output: 233


🏁 Script executed:

# Check if hmacSha256 method is using String or byte comparison
cat src/main/java/com/eatsfine/eatsfine/domain/payment/service/TossPaymentService.java | tail -20

Repository: Eatsfine/BE

Length of output: 1042


Fix payload format and use constant-time comparison for signature verification.

The signature verification has two critical issues:

  1. Incorrect payload format: Current code uses timestamp + "." + jsonBody, but Toss Payments documentation specifies {WEBHOOK_PAYLOAD}:{tosspayments-webhook-transmission-time} (body first, colon separator). This will cause verification to fail with real Toss webhooks.

  2. Timing attack vulnerability: String.contains() is not a constant-time comparison and is unsafe for cryptographic operations. Use MessageDigest.isEqual() instead.

Additionally, per Toss documentation, the signature header contains two v1-prefixed values (e.g., v1:<sigA>,v1:<sigB>), and verification should succeed if either matches.

🔐 Suggested fix
     public void verifyWebhookSignature(String jsonBody, String signature, String timestamp) throws Exception {
-        String payload = timestamp + "." + jsonBody;
+        String payload = jsonBody + ":" + timestamp;
         String calculatedSignature = hmacSha256(payload, widgetSecretKey);
 
-        if (!signature.contains("v1:" + calculatedSignature)) {
+        boolean valid = false;
+        for (String part : signature.split(",")) {
+            String sigValue = part.trim().replace("v1:", "");
+            if (java.security.MessageDigest.isEqual(
+                    calculatedSignature.getBytes(java.nio.charset.StandardCharsets.UTF_8),
+                    sigValue.getBytes(java.nio.charset.StandardCharsets.UTF_8))) {
+                valid = true;
+                break;
+            }
+        }
+        if (!valid) {
             throw new SecurityException("Signature verification failed");
         }
     }
🤖 Prompt for AI Agents
In
`@src/main/java/com/eatsfine/eatsfine/domain/payment/service/TossPaymentService.java`
around lines 53 - 59, In verifyWebhookSignature, the payload is built
incorrectly and the signature check is vulnerable to timing attacks: build
payload as jsonBody + ":" + timestamp (body first, colon separator), split the
incoming signature header by commas to handle multiple "v1:<sig>" entries, for
each entry strip the "v1:" prefix and compute hmacSha256(payload,
widgetSecretKey) once, then compare the expected signature to each provided
signature using a constant-time comparison (MessageDigest.isEqual) and accept if
any match; update references to widgetSecretKey, hmacSha256, and
verifyWebhookSignature accordingly.

}

private String hmacSha256(String data, String key) throws Exception {
javax.crypto.Mac sha256_HMAC = javax.crypto.Mac.getInstance("HmacSHA256");
javax.crypto.spec.SecretKeySpec secret_key = new javax.crypto.spec.SecretKeySpec(key.getBytes(java.nio.charset.StandardCharsets.UTF_8), "HmacSHA256");
sha256_HMAC.init(secret_key);
return java.util.Base64.getEncoder().encodeToString(sha256_HMAC.doFinal(data.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
}
Comment on lines +53 to +67
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The verifyWebhookSignature method lacks test coverage. This is a critical security component that protects against webhook tampering. The codebase demonstrates testing practices (e.g., HealthControllerTest). Consider adding tests for: valid signature verification, invalid signature rejection, malformed signature handling, timestamp manipulation attempts, and edge cases like empty or null inputs.

Copilot uses AI. Check for mistakes.
}
Loading