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:

  1. 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.
  2. 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).
  3. 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: CachedBodyHttpServletRequest memuat seluruh payload ke dalam memory heap (RAM). Pastikan membatasi ukuran request maksimum melalui konfigurasi Spring spring.servlet.multipart.max-request-size atau 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 SETNX dan TTL 24 jam.
  • Jika Redis mengembalikan 0 (key sudah ada), respons langsung dengan HTTP 200 OK tanpa memproses ulang mutasi database untuk menjaga status idempotensi.