Skip to content
Open
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
Expand Up @@ -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
Expand All @@ -43,6 +44,7 @@ public class IdempotencyFilter extends OncePerRequestFilter {
private final IdempotencyManager manager;
private final IdempotencyProperties properties;
private final ObjectProvider<RequestMappingHandlerMapping> handlerMappingProvider;
private final Pattern keyPattern;
private volatile RequestMappingHandlerMapping handlerMapping;

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}.
*
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -253,4 +295,3 @@ public EvictionProperties() {
public void setIntervalMs(long intervalMs) { this.intervalMs = intervalMs; }
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down