diff --git a/.trivyignore b/.trivyignore index ce9759b..5a14f02 100644 --- a/.trivyignore +++ b/.trivyignore @@ -12,4 +12,7 @@ CVE-2025-64720 exp:2026-06-05 CVE-2025-65018 exp:2026-06-05 # UID2-6385 -CVE-2025-66293 exp:2026-06-15 \ No newline at end of file +CVE-2025-66293 exp:2026-06-15 + +#for test +CVE-2025-68973 exp:2026-01-09 \ No newline at end of file diff --git a/pom.xml b/pom.xml index 579d311..5153cd7 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.uid2 uid2-optout - 4.7.5 + 4.7.8-alpha-155-SNAPSHOT uid2-optout https://github.com/IABTechLab/uid2-optout diff --git a/src/main/java/com/uid2/optout/vertx/GenericFailureHandler.java b/src/main/java/com/uid2/optout/vertx/GenericFailureHandler.java new file mode 100644 index 0000000..19b5b09 --- /dev/null +++ b/src/main/java/com/uid2/optout/vertx/GenericFailureHandler.java @@ -0,0 +1,86 @@ +package com.uid2.optout.vertx; + +import io.vertx.core.Handler; +import io.vertx.core.http.HttpClosedException; +import io.vertx.core.http.HttpServerResponse; +import io.vertx.ext.web.RoutingContext; +import org.apache.http.impl.EnglishReasonPhraseCatalog; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class GenericFailureHandler implements Handler { + private static final Logger LOGGER = LoggerFactory.getLogger(GenericFailureHandler.class); + + @Override + public void handle(RoutingContext ctx) { + int statusCode = ctx.statusCode(); + HttpServerResponse response = ctx.response(); + String url = ctx.normalizedPath(); + Throwable t = ctx.failure(); + + // Handle case where failure() is null (e.g., when rc.fail(401) is called without exception) + // In this case, just use the status code that was set + if (t == null) { + int finalStatusCode = statusCode == -1 ? 500 : statusCode; + if (finalStatusCode >= 500 && finalStatusCode < 600) { + LOGGER.error("URL: [{}] - Error response code: [{}]", url, finalStatusCode); + } else if (finalStatusCode >= 400 && finalStatusCode < 500) { + LOGGER.warn("URL: [{}] - Error response code: [{}]", url, finalStatusCode); + } + if (!response.ended() && !response.closed()) { + response.setStatusCode(finalStatusCode) + .end(EnglishReasonPhraseCatalog.INSTANCE.getReason(finalStatusCode, null)); + } + return; + } + + String errorMsg = t.getMessage(); + String className = t.getClass().getName(); + + // Handle multipart method mismatch (IllegalStateException) + if (t instanceof IllegalStateException && + errorMsg != null && + errorMsg.equalsIgnoreCase( + "Request method must be one of POST, PUT, PATCH or DELETE to decode a multipart request")) { + if (!response.ended() && !response.closed()) { + response.setStatusCode(400).end(errorMsg); + } + LOGGER.warn("URL: [{}] - Multipart method mismatch - Error:", url, t); + return; + } + + // Handle TooManyFormFieldsException + if (className.contains("TooManyFormFieldsException")) { + if (!response.ended() && !response.closed()) { + response.setStatusCode(400) + .end("Bad Request: Too many form fields"); + } + LOGGER.warn("URL: [{}] - Too many form fields - Error:", url, t); + return; + } + + // Handle HttpClosedException - ignore as it's usually caused by users and has no impact + if (t instanceof HttpClosedException) { + LOGGER.warn("Ignoring exception - URL: [{}] - Error:", url, t); + if (!response.ended() && !response.closed()) { + response.end(); + } + return; + } + + // Handle other exceptions based on status code + // If no status code was set, default to 500 + int finalStatusCode = statusCode == -1 ? 500 : statusCode; + + if (finalStatusCode >= 500 && finalStatusCode < 600) { // 5xx is server error, so error + LOGGER.error("URL: [{}] - Error response code: [{}] - Error:", url, finalStatusCode, t); + } else if (finalStatusCode >= 400 && finalStatusCode < 500) { // 4xx is user error, so just warn + LOGGER.warn("URL: [{}] - Error response code: [{}] - Error:", url, finalStatusCode, t); + } + + if (!response.ended() && !response.closed()) { + response.setStatusCode(finalStatusCode) + .end(EnglishReasonPhraseCatalog.INSTANCE.getReason(finalStatusCode, null)); + } + } +} diff --git a/src/main/java/com/uid2/optout/vertx/OptOutServiceVerticle.java b/src/main/java/com/uid2/optout/vertx/OptOutServiceVerticle.java index 5197ee4..e652b7c 100644 --- a/src/main/java/com/uid2/optout/vertx/OptOutServiceVerticle.java +++ b/src/main/java/com/uid2/optout/vertx/OptOutServiceVerticle.java @@ -231,6 +231,8 @@ private Router createRouter() { //// if enabled, this would add handler for exposing prometheus metrics // router.route("/metrics").handler(PrometheusScrapingHandler.create()); + router.route().failureHandler(new GenericFailureHandler()); + return router; } diff --git a/src/test/java/com/uid2/optout/vertx/GenericFailureHandlerTest.java b/src/test/java/com/uid2/optout/vertx/GenericFailureHandlerTest.java new file mode 100644 index 0000000..9b16859 --- /dev/null +++ b/src/test/java/com/uid2/optout/vertx/GenericFailureHandlerTest.java @@ -0,0 +1,353 @@ +package com.uid2.optout.vertx; + +import io.vertx.core.http.HttpClosedException; +import io.vertx.core.http.HttpServerResponse; +import io.vertx.ext.web.RoutingContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class GenericFailureHandlerTest { + @Mock + private RoutingContext routingContext; + @Mock + private HttpServerResponse response; + + private GenericFailureHandler handler; + + @BeforeEach + public void setup() { + handler = new GenericFailureHandler(); + when(routingContext.response()).thenReturn(response); + when(routingContext.normalizedPath()).thenReturn("/test/path"); + when(response.ended()).thenReturn(false); + when(response.closed()).thenReturn(false); + // Mock setStatusCode to return response for method chaining + when(response.setStatusCode(anyInt())).thenReturn(response); + } + + @Test + public void testMultipartMethodMismatch_returns400() { + String errorMsg = "Request method must be one of POST, PUT, PATCH or DELETE to decode a multipart request"; + IllegalStateException exception = new IllegalStateException(errorMsg); + + when(routingContext.statusCode()).thenReturn(-1); + when(routingContext.failure()).thenReturn(exception); + + handler.handle(routingContext); + + ArgumentCaptor statusCaptor = ArgumentCaptor.forClass(Integer.class); + ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(String.class); + + verify(response).setStatusCode(statusCaptor.capture()); + verify(response).end(bodyCaptor.capture()); + + assertEquals(400, statusCaptor.getValue()); + assertEquals(errorMsg, bodyCaptor.getValue()); + } + + @Test + public void testMultipartMethodMismatch_caseInsensitive() { + IllegalStateException exception = new IllegalStateException( + "REQUEST METHOD MUST BE ONE OF POST, PUT, PATCH OR DELETE TO DECODE A MULTIPART REQUEST"); + + when(routingContext.statusCode()).thenReturn(-1); + when(routingContext.failure()).thenReturn(exception); + + handler.handle(routingContext); + + ArgumentCaptor statusCaptor = ArgumentCaptor.forClass(Integer.class); + verify(response).setStatusCode(statusCaptor.capture()); + assertEquals(400, statusCaptor.getValue()); + } + + @Test + public void testIllegalStateException_withDifferentMessage_handledNormally() { + IllegalStateException exception = new IllegalStateException("Different error message"); + + when(routingContext.statusCode()).thenReturn(500); + when(routingContext.failure()).thenReturn(exception); + + handler.handle(routingContext); + + // Should not return 400, but use the status code from context + ArgumentCaptor statusCaptor = ArgumentCaptor.forClass(Integer.class); + verify(response).setStatusCode(statusCaptor.capture()); + assertEquals(500, statusCaptor.getValue()); + } + + @Test + public void testMultipartMethodMismatch_withNullMessage_handledNormally() { + IllegalStateException exception = new IllegalStateException((String) null); + + when(routingContext.statusCode()).thenReturn(500); + when(routingContext.failure()).thenReturn(exception); + + handler.handle(routingContext); + + // Should not return 400, but use the status code from context + ArgumentCaptor statusCaptor = ArgumentCaptor.forClass(Integer.class); + verify(response).setStatusCode(statusCaptor.capture()); + assertEquals(500, statusCaptor.getValue()); + } + + @Test + public void testTooManyFormFieldsException_withNettyClassNamePattern_returns400() { + // Test with an exception that has a class name matching the Netty pattern + // Actual Netty exception: io.netty.handler.codec.http.multipart.HttpPostRequestDecoder$TooManyFormFieldsException + // The handler checks if className.contains("TooManyFormFieldsException"), so this should work + NettyTooManyFormFieldsException exception = new NettyTooManyFormFieldsException(); + + when(routingContext.statusCode()).thenReturn(-1); + when(routingContext.failure()).thenReturn(exception); + + handler.handle(routingContext); + + ArgumentCaptor statusCaptor = ArgumentCaptor.forClass(Integer.class); + ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(String.class); + + verify(response).setStatusCode(statusCaptor.capture()); + verify(response).end(bodyCaptor.capture()); + + assertEquals(400, statusCaptor.getValue()); + assertEquals("Bad Request: Too many form fields", bodyCaptor.getValue()); + + // Verify the class name contains "TooManyFormFieldsException" (matching handler logic) + assertTrue(exception.getClass().getName().contains("TooManyFormFieldsException")); + } + + @Test + public void testHttpClosedException_ignored() { + HttpClosedException exception = new HttpClosedException("Connection closed"); + + when(routingContext.statusCode()).thenReturn(-1); + when(routingContext.failure()).thenReturn(exception); + + handler.handle(routingContext); + + // Should only call end() without setting status code + verify(response, never()).setStatusCode(anyInt()); + verify(response).end(); + } + + @Test + public void testHttpClosedException_responseAlreadyEnded_doesNotCallEnd() { + HttpClosedException exception = new HttpClosedException("Connection closed"); + + when(routingContext.statusCode()).thenReturn(-1); + when(routingContext.failure()).thenReturn(exception); + when(response.ended()).thenReturn(true); + + handler.handle(routingContext); + + verify(response, never()).setStatusCode(anyInt()); + verify(response, never()).end(); + } + + @Test + public void testHttpClosedException_responseClosed_doesNotCallEnd() { + HttpClosedException exception = new HttpClosedException("Connection closed"); + + when(routingContext.statusCode()).thenReturn(-1); + when(routingContext.failure()).thenReturn(exception); + when(response.closed()).thenReturn(true); + + handler.handle(routingContext); + + verify(response, never()).setStatusCode(anyInt()); + verify(response, never()).end(); + } + + @Test + public void test500StatusCode_logsError() { + RuntimeException exception = new RuntimeException("Internal server error"); + + when(routingContext.statusCode()).thenReturn(500); + when(routingContext.failure()).thenReturn(exception); + + handler.handle(routingContext); + + ArgumentCaptor statusCaptor = ArgumentCaptor.forClass(Integer.class); + verify(response).setStatusCode(statusCaptor.capture()); + assertEquals(500, statusCaptor.getValue()); + verify(response).end(anyString()); + } + + @Test + public void test503StatusCode_logsError() { + RuntimeException exception = new RuntimeException("Service unavailable"); + + when(routingContext.statusCode()).thenReturn(503); + when(routingContext.failure()).thenReturn(exception); + + handler.handle(routingContext); + + ArgumentCaptor statusCaptor = ArgumentCaptor.forClass(Integer.class); + verify(response).setStatusCode(statusCaptor.capture()); + assertEquals(503, statusCaptor.getValue()); + verify(response).end(anyString()); + } + + @Test + public void test400StatusCode_logsWarning() { + IllegalArgumentException exception = new IllegalArgumentException("Bad request"); + + when(routingContext.statusCode()).thenReturn(400); + when(routingContext.failure()).thenReturn(exception); + + handler.handle(routingContext); + + ArgumentCaptor statusCaptor = ArgumentCaptor.forClass(Integer.class); + verify(response).setStatusCode(statusCaptor.capture()); + assertEquals(400, statusCaptor.getValue()); + verify(response).end(anyString()); + } + + @Test + public void test404StatusCode_logsWarning() { + RuntimeException exception = new RuntimeException("Not found"); + + when(routingContext.statusCode()).thenReturn(404); + when(routingContext.failure()).thenReturn(exception); + + handler.handle(routingContext); + + ArgumentCaptor statusCaptor = ArgumentCaptor.forClass(Integer.class); + verify(response).setStatusCode(statusCaptor.capture()); + assertEquals(404, statusCaptor.getValue()); + verify(response).end(anyString()); + } + + @Test + public void testNoStatusCode_defaultsTo500() { + RuntimeException exception = new RuntimeException("Unknown error"); + + when(routingContext.statusCode()).thenReturn(-1); + when(routingContext.failure()).thenReturn(exception); + + handler.handle(routingContext); + + ArgumentCaptor statusCaptor = ArgumentCaptor.forClass(Integer.class); + verify(response).setStatusCode(statusCaptor.capture()); + assertEquals(500, statusCaptor.getValue()); + verify(response).end(anyString()); + } + + @Test + public void testSuccessStatus_status200_responseAlreadyEnded() { + // Sucess status case: Status code is 200 (success) and response is already ended + // The handler should not modify anything since the response is already complete + RuntimeException exception = new RuntimeException("Exception but response already sent"); + + when(routingContext.statusCode()).thenReturn(200); + when(routingContext.failure()).thenReturn(exception); + when(response.ended()).thenReturn(true); // Response already ended - happy case + + handler.handle(routingContext); + + // Handler should not modify the response since it's already ended + verify(response, never()).setStatusCode(anyInt()); + verify(response, never()).end(); + verify(response, never()).end(anyString()); + } + + @Test + public void testResponseAlreadyEnded_doesNotSetStatusCode() { + RuntimeException exception = new RuntimeException("Error"); + + when(routingContext.statusCode()).thenReturn(500); + when(routingContext.failure()).thenReturn(exception); + when(response.ended()).thenReturn(true); + + handler.handle(routingContext); + + verify(response, never()).setStatusCode(anyInt()); + verify(response, never()).end(); + } + + @Test + public void testResponseClosed_doesNotSetStatusCode() { + RuntimeException exception = new RuntimeException("Error"); + + when(routingContext.statusCode()).thenReturn(500); + when(routingContext.failure()).thenReturn(exception); + when(response.closed()).thenReturn(true); + + handler.handle(routingContext); + + verify(response, never()).setStatusCode(anyInt()); + verify(response, never()).end(); + } + + @Test + public void testExceptionWithNullMessage() { + // RuntimeException with no message will have getMessage() return null + RuntimeException exception = new RuntimeException((String) null); + + when(routingContext.statusCode()).thenReturn(500); + when(routingContext.failure()).thenReturn(exception); + + handler.handle(routingContext); + + ArgumentCaptor statusCaptor = ArgumentCaptor.forClass(Integer.class); + verify(response).setStatusCode(statusCaptor.capture()); + assertEquals(500, statusCaptor.getValue()); + verify(response).end(anyString()); + } + + @Test + public void testMultipartMethodMismatch_responseAlreadyEnded_doesNotCallEnd() { + IllegalStateException exception = new IllegalStateException( + "Request method must be one of POST, PUT, PATCH or DELETE to decode a multipart request"); + + when(routingContext.statusCode()).thenReturn(-1); + when(routingContext.failure()).thenReturn(exception); + when(response.ended()).thenReturn(true); + + handler.handle(routingContext); + + verify(response, never()).setStatusCode(anyInt()); + verify(response, never()).end(); + } + + @Test + public void testTooManyFormFieldsException_responseAlreadyEnded_doesNotCallEnd() { + TestTooManyFormFieldsException exception = new TestTooManyFormFieldsException("Too many form fields"); + + when(routingContext.statusCode()).thenReturn(-1); + when(routingContext.failure()).thenReturn(exception); + when(response.ended()).thenReturn(true); + + handler.handle(routingContext); + + verify(response, never()).setStatusCode(anyInt()); + verify(response, never()).end(); + } + + // Helper class to simulate TooManyFormFieldsException + // The class name must contain "TooManyFormFieldsException" for the handler to recognize it + private static class TestTooManyFormFieldsException extends RuntimeException { + public TestTooManyFormFieldsException(String message) { + super(message); + } + } + + // Helper class to simulate the actual Netty exception class name pattern + // This matches: io.netty.handler.codec.http.multipart.HttpPostRequestDecoder$TooManyFormFieldsException + // The $ indicates it's an inner class in Netty + private static class NettyTooManyFormFieldsException extends RuntimeException { + public NettyTooManyFormFieldsException() { + super(); + } + } +}