Mengamankan endpoint penerima webhook membutuhkan verifikasi integritas data sebelum payload diproses oleh aplikasi. Pendekatan standar industri menggunakan signature berbasis HMAC-SHA256 yang dikirimkan melalui HTTP header oleh penyedia webhook seperti Stripe, GitHub, atau Xendit.
Namun, dua kendala teknis sering muncul saat mengimplementasikan verifikasi ini di Spring Boot: stream exhausted (kegagalan membaca HttpServletRequest.getInputStream() lebih dari satu kali) dan kerentanan terhadap replay attack jika validasi timestamp diabaikan. Artikel ini menyajikan solusi arsitektural dan implementasi kode untuk mengatasi kedua masalah tersebut.
Penyebab Stream Exhausted pada HttpServletRequest
Spesifikasi Servlet mendesain ServletInputStream sebagai forward-only read stream. Saat data selesai dibaca pada level Filter atau HandlerInterceptor untuk menghitung signature HMAC, pointer stream berpindah ke posisi akhir (EOF) atau stream ditandai closed.
Ketika request diteruskan ke controller, HttpMessageConverter milik Spring (seperti Jackson) akan mencoba membaca kembali stream body untuk membungkus data ke dalam anotasi @RequestBody. Operasi ini langsung memicu error:
java.io.IOException: Stream closed
at org.apache.catalina.connector.InputBuffer.read(InputBuffer.java:312)
at org.apache.catalina.connector.CoyoteInputStream.read(CoyoteInputStream.java:108)Spring menyediakan ContentCachingRequestWrapper, tetapi kelas bawaan ini baru menyimpan cache setelah ada pemanggilan method baca pada controller. Untuk verifikasi di level filter sebelum controller dieksekusi, kita perlu membungkus request dengan implementasi kustom yang mencatat payload ke byte array saat inisialisasi.
Solusi: Implementasi CachedBodyHttpServletRequest
Gunakan HttpServletRequestWrapper untuk membaca payload raw ke memori sekali, lalu menyajikan byte array tersebut berulang kali melalui instance ServletInputStream baru setiap kali getInputStream() dipanggil.
package com.example.webhook.security;
import jakarta.servlet.ReadListener;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
public class CachedBodyHttpServletRequest extends HttpServletRequestWrapper {
private final byte[] cachedBody;
public CachedBodyHttpServletRequest(HttpServletRequest request) throws IOException {
super(request);
this.cachedBody = request.getInputStream().readAllBytes();
}
@Override
public ServletInputStream getInputStream() {
return new CachedServletInputStream(this.cachedBody);
}
@Override
public BufferedReader getReader() {
return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8));
}
public byte[] getCachedBody() {
return this.cachedBody;
}
private static class CachedServletInputStream extends ServletInputStream {
private final ByteArrayInputStream buffer;
public CachedServletInputStream(byte[] contents) {
this.buffer = new ByteArrayInputStream(contents);
}
@Override
public int read() {
return buffer.read();
}
@Override
public boolean isFinished() {
return buffer.available() == 0;
}
@Override
public boolean isReady() {
return true;
}
@Override
public void setReadListener(ReadListener listener) {
// Tidak digunakan untuk blocking I/O standard servlet container
}
}
}Arsitektur Pertahanan Terhadap Replay Attack
Verifikasi signature saja tidak cukup. Penyerang yang menyadap lalu lintas jaringan dapat mengambil payload valid beserta signature-nya, lalu mengirim ulang request yang sama (replay attack). Untuk mencegahnya, terapkan tiga lapisan validasi:
- HMAC-SHA256 Signature Verification: Hitung hash berbasis secret key dari kombinasi timestamp dan raw payload. Bandingkan dengan header signature menggunakan perbandingan konstan (constant-time comparison) untuk menghindari timing attacks.
- Timestamp Drift Tolerance: Periksa header timestamp pengiriman. Tolak request jika selisih waktu antara timestamp header dan waktu server lokal melebihi ambang batas toleransi (misalnya 300 detik atau 5 menit).
- Idempotency via Event Deduplication: Simpan event ID webhook ke shared storage (misalnya Redis dengan TTL) untuk memastikan event yang sama tidak diproses dua kali.
Implementasi WebhookVerificationFilter
Filter berikut bertugas membungkus request, memeriksa header, mengeksekusi kalkulasi HMAC, dan menolak request tidak valid sebelum masuk ke controller Spring.
package com.example.webhook.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpStatus;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.HexFormat;
public class WebhookVerificationFilter extends OncePerRequestFilter {
private static final String SIGNATURE_HEADER = "X-Signature";
private static final String TIMESTAMP_HEADER = "X-Timestamp";
private static final long MAX_DRIFT_SECONDS = 300;
private final String secretKey;
public WebhookVerificationFilter(String secretKey) {
this.secretKey = secretKey;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
if (!request.getRequestURI().startsWith("/api/v1/webhooks")) {
filterChain.doFilter(request, response);
return;
}
String signature = request.getHeader(SIGNATURE_HEADER);
String timestampStr = request.getHeader(TIMESTAMP_HEADER);
if (signature == null || timestampStr == null) {
response.sendError(HttpStatus.UNAUTHORIZED.value(), "Missing webhook authentication headers");
return;
}
long timestamp;
try {
timestamp = Long.parseLong(timestampStr);
} catch (NumberFormatException e) {
response.sendError(HttpStatus.BAD_REQUEST.value(), "Invalid timestamp format");
return;
}
// 1. Mitigasi Replay Attack: Verifikasi clock drift
long currentTimestamp = Instant.now().getEpochSecond();
if (Math.abs(currentTimestamp - timestamp) > MAX_DRIFT_SECONDS) {
response.sendError(HttpStatus.UNAUTHORIZED.value(), "Timestamp drift exceeds limit");
return;
}
// 2. Wrap request untuk caching body
CachedBodyHttpServletRequest wrappedRequest = new CachedBodyHttpServletRequest(request);
byte[] body = wrappedRequest.getCachedBody();
// 3. Verifikasi Signature HMAC-SHA256
if (!isValidSignature(body, timestampStr, signature)) {
response.sendError(HttpStatus.UNAUTHORIZED.value(), "Invalid signature");
return;
}
// Lanjutkan request chain menggunakan wrappedRequest
filterChain.doFilter(wrappedRequest, response);
}
private boolean isValidSignature(byte[] body, String timestamp, String expectedHexSignature) {
try {
Mac hmac = Mac.getInstance("HmacSHA256");
SecretKeySpec secretKeySpec = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
hmac.init(secretKeySpec);
// Skema signing: timestamp + "." + payload
hmac.update(timestamp.getBytes(StandardCharsets.UTF_8));
hmac.update(".".getBytes(StandardCharsets.UTF_8));
byte[] calculatedHash = hmac.doFinal(body);
byte[] expectedHash = HexFormat.of().parseHex(expectedHexSignature);
// Hindari vulnerability timing attacks
return MessageDigest.isEqual(calculatedHash, expectedHash);
} catch (Exception e) {
return false;
}
}
}Registrasi Filter ke Application Context
Daftarkan filter menggunakan FilterRegistrationBean untuk mengatur urutan eksekusi secara eksplisit dalam Servlet pipeline Spring Boot:
package com.example.webhook.config;
import com.example.webhook.security.WebhookVerificationFilter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class WebhookFilterConfig {
@Bean
public FilterRegistrationBean<WebhookVerificationFilter> webhookVerificationFilterRegistration(
@Value("${webhook.secret:super-secret-signing-key}") String secretKey) {
FilterRegistrationBean<WebhookVerificationFilter> registration = new FilterRegistrationBean<>();
registration.setFilter(new WebhookVerificationFilter(secretKey));
registration.addUrlPatterns("/api/v1/webhooks/*");
registration.setOrder(1);
return registration;
}
}Unit Testing Signature Verification
Pengujian memverifikasi bahwa filter mengizinkan request valid dan menolak request yang dimanipulasi atau kedaluwarsa.
package com.example.webhook.security;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockFilterChain;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.HexFormat;
import static org.junit.jupiter.api.Assertions.assertEquals;
class WebhookVerificationFilterTest {
private static final String SECRET = "test-secret-key-12345";
private WebhookVerificationFilter filter;
@BeforeEach
void setUp() {
filter = new WebhookVerificationFilter(SECRET);
}
@Test
void shouldPassValidSignatureAndTimestamp() throws Exception {
String payload = "{\"event\":\"payment_success\",\"amount\":50000}";
long timestamp = Instant.now().getEpochSecond();
String signature = computeHmac(SECRET, timestamp + "." + payload);
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/webhooks/listener");
request.setContent(payload.getBytes(StandardCharsets.UTF_8));
request.addHeader("X-Signature", signature);
request.addHeader("X-Timestamp", String.valueOf(timestamp));
MockHttpServletResponse response = new MockHttpServletResponse();
MockFilterChain chain = new MockFilterChain();
filter.doFilter(request, response, chain);
assertEquals(200, response.getStatus());
}
@Test
void shouldRejectExpiredTimestamp() throws Exception {
String payload = "{\"event\":\"payment_success\"}";
// 10 menit yang lalu (melebihi limit drift 300 detik)
long expiredTimestamp = Instant.now().getEpochSecond() - 600;
String signature = computeHmac(SECRET, expiredTimestamp + "." + payload);
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/webhooks/listener");
request.setContent(payload.getBytes(StandardCharsets.UTF_8));
request.addHeader("X-Signature", signature);
request.addHeader("X-Timestamp", String.valueOf(expiredTimestamp));
MockHttpServletResponse response = new MockHttpServletResponse();
MockFilterChain chain = new MockFilterChain();
filter.doFilter(request, response, chain);
assertEquals(401, response.getStatus());
}
private String computeHmac(String secret, String data) throws Exception {
Mac hmac = Mac.getInstance("HmacSHA256");
SecretKeySpec keySpec = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
hmac.init(keySpec);
return HexFormat.of().formatHex(hmac.doFinal(data.getBytes(StandardCharsets.UTF_8)));
}
}Pertimbangan Produksi dan Trade-Off
Peringatan Memori:
CachedBodyHttpServletRequestmemuat seluruh payload ke dalam memory heap (RAM). Pastikan membatasi ukuran request maksimum melalui konfigurasi Springspring.servlet.multipart.max-request-sizeatau filter ukuran body untuk mencegah serangan Out-Of-Memory (OOM) Denial-of-Service.
Untuk melengkapi proteksi replay attack di sistem terdistribusi, periksa X-Event-ID header yang unik untuk setiap webhook:
- Simpan event ID ke Redis dengan command
SETNXdan TTL 24 jam. - Jika Redis mengembalikan
0(key sudah ada), respons langsung dengan HTTP200 OKtanpa memproses ulang mutasi database untuk menjaga status idempotensi.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!