-
Notifications
You must be signed in to change notification settings - Fork 0
[FEAT] Toss Payments Webhook 연동 및 상태 동기화 로직 구현 #95
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3018322
9afbcf5
9d56c4c
c87d4c3
eb1b99d
8b3d10b
80b5f68
592d38e
f747e46
971c9bc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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
+36
to
+39
|
||
|
|
||
| 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
|
||
| } 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
|
||
|
|
||
| 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 | ||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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; | ||||||||||||||||||||||||||||||||||||||||
|
|
@@ -220,4 +221,80 @@ public PaymentResponseDTO.PaymentDetailResultDTO getPaymentDetail(Long paymentId | |||||||||||||||||||||||||||||||||||||||
| null // 환불 상세 정보는 현재 null 처리 | ||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| @Transactional | ||||||||||||||||||||||||||||||||||||||||
| public void processWebhook(PaymentWebhookDTO dto) { | ||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+225
to
+226
|
||||||||||||||||||||||||||||||||||||||||
| // 이벤트 타입 검증 | ||||||||||||||||||||||||||||||||||||||||
| 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
|
||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| 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
|
||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| // 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; | ||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||
| provider = PaymentProvider.KAKAOPAY; | |
| provider = PaymentProvider.KAKAOPAY; | |
| } else if (providerCode != null) { | |
| // 지원하지 않는 간편결제 제공자 코드 처리 | |
| log.warn("Unknown easyPay provider '{}' for orderId {}", providerCode, data.orderId()); |
Copilot
AI
Feb 3, 2026
There was a problem hiding this comment.
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
AI
Feb 2, 2026
There was a problem hiding this comment.
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.
| 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
AI
Feb 3, 2026
There was a problem hiding this comment.
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.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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
|
||||||||||||||||||
| 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
AI
Feb 3, 2026
There was a problem hiding this comment.
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.
| if (!signature.contains("v1:" + calculatedSignature)) { | |
| String expectedSignature = "v1:" + calculatedSignature; | |
| if (signature == null || !expectedSignature.equals(signature)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 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:
- Base64-decode both
<sigA>and<sigB> - Compute
HMAC-SHA256(key=securityKey, msg="{payload}:{transmissionTime}") - If your computed HMAC matches either decoded value, the webhook is valid (docs.tosspayments.com)
- Base64-decode both
Notes:
tosspayments-webhook-transmission-timeis the timestamp header you must append after:in the signed payload (docs.tosspayments.com)
Citations:
- 1: https://docs.tosspayments.com/reference/using-api/webhook-events
- 2: https://docs.tosspayments.com/reference/using-api/webhook-events
- 3: https://docs.tosspayments.com/reference/using-api/webhook-events
🌐 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:
- 1: https://docs.tosspayments.com/reference/using-api/webhook-events?utm_source=openai
- 2: https://docs.tosspayments.com/reference/using-api/webhook-events?utm_source=openai
- 3: https://toss.tech/article/payments-legacy-4?utm_source=openai
- 4: https://docs.tosspayments.com/reference/using-api/webhook-events?utm_source=openai
- 5: https://docs.tosspayments.com/reference/using-api/webhook-events?utm_source=openai
- 6: https://techchat.tosspayments.com/m/1400303384015405158?utm_source=openai
🏁 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 2Repository: 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 -20Repository: 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.javaRepository: 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/nullRepository: 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/nullRepository: 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 -20Repository: Eatsfine/BE
Length of output: 1042
Fix payload format and use constant-time comparison for signature verification.
The signature verification has two critical issues:
-
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. -
Timing attack vulnerability:
String.contains()is not a constant-time comparison and is unsafe for cryptographic operations. UseMessageDigest.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.
Copilot
AI
Feb 3, 2026
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.