From 188a6aa75d293b8a20590e809662c5a7b884e8d5 Mon Sep 17 00:00:00 2001 From: Ashu Date: Mon, 3 Aug 2026 10:52:09 +0530 Subject: [PATCH] feat: configurable idempotency key validation (max length + pattern) --- .../avoonce/spring/IdempotencyFilter.java | 35 ++++++++++ .../avoonce/spring/IdempotencyProperties.java | 43 ++++++++++++- .../avoonce/spring/IdempotencyFilterTest.java | 64 +++++++++++++++++++ 3 files changed, 141 insertions(+), 1 deletion(-) diff --git a/idempotency-spring-boot-starter/src/main/java/io/github/ravocode/avoonce/spring/IdempotencyFilter.java b/idempotency-spring-boot-starter/src/main/java/io/github/ravocode/avoonce/spring/IdempotencyFilter.java index 38c9eb6..bbc39e3 100644 --- a/idempotency-spring-boot-starter/src/main/java/io/github/ravocode/avoonce/spring/IdempotencyFilter.java +++ b/idempotency-spring-boot-starter/src/main/java/io/github/ravocode/avoonce/spring/IdempotencyFilter.java @@ -24,6 +24,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.regex.Pattern; /** * Spring Web Servlet Filter that provides selective idempotency protection for @@ -43,6 +44,7 @@ public class IdempotencyFilter extends OncePerRequestFilter { private final IdempotencyManager manager; private final IdempotencyProperties properties; private final ObjectProvider handlerMappingProvider; + private final Pattern keyPattern; private volatile RequestMappingHandlerMapping handlerMapping; /** @@ -70,6 +72,9 @@ public IdempotencyFilter(final IdempotencyManager manager, this.manager = manager; this.properties = properties; this.handlerMappingProvider = handlerMappingProvider; + this.keyPattern = (properties.getKeyPattern() == null || properties.getKeyPattern().isBlank()) + ? null + : Pattern.compile(properties.getKeyPattern()); } @Override @@ -100,6 +105,14 @@ protected void doFilterInternal(final HttpServletRequest request, return; } + final String validationError = validateKey(key); + if (validationError != null) { + log.debug("[idempotency] Rejecting request: {}", validationError); + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + response.getWriter().write(validationError); + return; + } + final CachedBodyHttpServletRequest cachedRequest = new CachedBodyHttpServletRequest(request); final ContentCachingResponseWrapper cachedResponse = new ContentCachingResponseWrapper(response); @@ -172,6 +185,28 @@ protected void doFilterInternal(final HttpServletRequest request, } } + /** + * Validates the extracted idempotency key against the configured + * constraints (non-blank, maximum length, optional pattern). + * + * @param key the raw key extracted from the request. + * @return an error message when the key is invalid, or {@code null} when + * the key passes validation. + */ + private String validateKey(final String key) { + if (key.isBlank()) { + return "Invalid " + properties.getHeaderName() + " header: key must not be blank"; + } + if (key.length() > properties.getMaxKeyLength()) { + return "Invalid " + properties.getHeaderName() + " header: key exceeds maximum length of " + + properties.getMaxKeyLength() + " characters"; + } + if (keyPattern != null && !keyPattern.matcher(key).matches()) { + return "Invalid " + properties.getHeaderName() + " header: key does not match required pattern"; + } + return null; + } + private boolean isIdempotentTarget(final HttpServletRequest request) { final RequestMappingHandlerMapping mapping = getHandlerMapping(); if (mapping == null) { diff --git a/idempotency-spring-boot-starter/src/main/java/io/github/ravocode/avoonce/spring/IdempotencyProperties.java b/idempotency-spring-boot-starter/src/main/java/io/github/ravocode/avoonce/spring/IdempotencyProperties.java index 677088c..6e2ecd0 100644 --- a/idempotency-spring-boot-starter/src/main/java/io/github/ravocode/avoonce/spring/IdempotencyProperties.java +++ b/idempotency-spring-boot-starter/src/main/java/io/github/ravocode/avoonce/spring/IdempotencyProperties.java @@ -28,6 +28,22 @@ public IdempotencyProperties() { private boolean hashBody = true; private boolean enforce = false; + /** + * Optional regular expression the idempotency key must fully match, e.g. + * {@code ^[a-fA-F0-9\-]{36}$} to require UUID-shaped keys + * ({@code avoonce.idempotency.key-pattern}). {@code null} or blank (the + * default) disables pattern validation. + */ + private String keyPattern; + + /** + * Maximum accepted idempotency key length + * ({@code avoonce.idempotency.max-key-length}). Requests whose key exceeds + * this length are rejected with HTTP 400, guarding the backing store + * against abusive keys. Default: 255. + */ + private int maxKeyLength = 255; + /** * Which backing store to use: {@code auto}, {@code caffeine}, {@code jdbc}, or {@code redis}. * @@ -130,6 +146,32 @@ public IdempotencyProperties() { * @param enforce {@code true} to reject missing keys */ public void setEnforce(boolean enforce) { this.enforce = enforce; } + /** + * Returns the regular expression the idempotency key must fully match, or + * {@code null} when pattern validation is disabled. + * + * @return the key validation pattern + */ + public String getKeyPattern() { return keyPattern; } + /** + * Sets the regular expression the idempotency key must fully match. + * {@code null} or blank disables pattern validation. + * + * @param keyPattern the key validation pattern to use + */ + public void setKeyPattern(String keyPattern) { this.keyPattern = keyPattern; } + /** + * Returns the maximum accepted idempotency key length. + * + * @return the maximum key length + */ + public int getMaxKeyLength() { return maxKeyLength; } + /** + * Sets the maximum accepted idempotency key length. + * + * @param maxKeyLength the maximum key length to use + */ + public void setMaxKeyLength(int maxKeyLength) { this.maxKeyLength = maxKeyLength; } /** * Returns the selected backing store name. * @@ -253,4 +295,3 @@ public EvictionProperties() { public void setIntervalMs(long intervalMs) { this.intervalMs = intervalMs; } } } - diff --git a/idempotency-spring-boot-starter/src/test/java/io/github/ravocode/avoonce/spring/IdempotencyFilterTest.java b/idempotency-spring-boot-starter/src/test/java/io/github/ravocode/avoonce/spring/IdempotencyFilterTest.java index cd44f7c..f616fb3 100644 --- a/idempotency-spring-boot-starter/src/test/java/io/github/ravocode/avoonce/spring/IdempotencyFilterTest.java +++ b/idempotency-spring-boot-starter/src/test/java/io/github/ravocode/avoonce/spring/IdempotencyFilterTest.java @@ -147,6 +147,70 @@ void testIdempotencyFilter_selectivelyProtectsAnnotatedEndpointOnly() throws Exc assertEquals(2, controller.unannotatedExecutionCount.get(), "Unannotated endpoint should bypass filter"); } + @Test + void testIdempotencyFilter_rejectsBlankKey() throws Exception { + mockMvc.perform(post("/test") + .header("Idempotency-Key", " ") + .content("{\"data\":\"test\"}") + .contentType("application/json")) + .andExpect(status().isBadRequest()); + assertEquals(0, controller.executionCount.get(), "Controller should not execute for a blank key"); + } + + @Test + void testIdempotencyFilter_rejectsKeyExceedingMaxLength() throws Exception { + IdempotencyProperties props = new IdempotencyProperties(); + props.setMaxKeyLength(16); + MockMvc strictMvc = buildMvcWith(props); + + strictMvc.perform(post("/test") + .header("Idempotency-Key", "this-key-is-definitely-longer-than-sixteen-characters") + .content("{\"data\":\"test\"}") + .contentType("application/json")) + .andExpect(status().isBadRequest()); + assertEquals(0, controller.executionCount.get(), "Controller should not execute for an oversized key"); + + // A key within the limit passes through normally + strictMvc.perform(post("/test") + .header("Idempotency-Key", "short-key-1") + .content("{\"data\":\"test\"}") + .contentType("application/json")) + .andExpect(status().isCreated()); + assertEquals(1, controller.executionCount.get(), "A key within the limit should be accepted"); + } + + @Test + void testIdempotencyFilter_rejectsKeyFailingPattern() throws Exception { + IdempotencyProperties props = new IdempotencyProperties(); + props.setKeyPattern("^[a-fA-F0-9\\-]{36}$"); // UUID format + MockMvc strictMvc = buildMvcWith(props); + + strictMvc.perform(post("/test") + .header("Idempotency-Key", "not-a-uuid") + .content("{\"data\":\"test\"}") + .contentType("application/json")) + .andExpect(status().isBadRequest()); + assertEquals(0, controller.executionCount.get(), "Controller should not execute for a non-matching key"); + + // A UUID-shaped key passes validation + strictMvc.perform(post("/test") + .header("Idempotency-Key", "11111111-2222-3333-4444-555555555555") + .content("{\"data\":\"test\"}") + .contentType("application/json")) + .andExpect(status().isCreated()); + assertEquals(1, controller.executionCount.get(), "A matching key should be accepted"); + } + + private MockMvc buildMvcWith(IdempotencyProperties props) { + IdempotencyManager localManager = new IdempotencyManager( + new CaffeineIdempotencyRepository(new IdempotencyConfig())); + IdempotencyFilter strictFilter = new IdempotencyFilter(localManager, props, + context.getBeanProvider(RequestMappingHandlerMapping.class)); + return MockMvcBuilders.webAppContextSetup(context) + .addFilters(strictFilter) + .build(); + } + @Configuration @org.springframework.web.servlet.config.annotation.EnableWebMvc static class TestConfig {