diff --git a/plume-conf/pom.xml b/plume-conf/pom.xml index cfe7154..d231b2e 100644 --- a/plume-conf/pom.xml +++ b/plume-conf/pom.xml @@ -47,7 +47,7 @@ - + diff --git a/plume-web-jersey/README.md b/plume-web-jersey/README.md index d22c9f3..07dd03d 100644 --- a/plume-web-jersey/README.md +++ b/plume-web-jersey/README.md @@ -27,6 +27,34 @@ To use it, register this feature in Jersey: `resourceConfig.register(RequireExpl Any custom annotation can be added (as long as the corresponding Jersey access control feature is configured...). In a doubt to configure the Jersey access control feature, see as an example the existing class `PermissionFeature` that checks the `RestrictTo` annotation access control. +Content size limit +------------------ +In order to protect the backend against attack that would send huge content, it is possible to limit the size of the content that can be sent to the backend. + +To do so, register the `ContentControlFeature` in Jersey: `resourceConfig.register(ContentControlFeature.class);` +By default the content size of body is limited to 500 KB. This limit can be override for the whole api by using the `ContentControlFeatureFactory` to specify your own limit. + +Usage example: +```java +resourceConfig.register(new AbstractBinder() { + @Override + protected void configure() { + bindFactory(new ContentControlFeatureFactory(1000 * 1024 /* 1MB */)).to(ContentControlFeature.class); + } +}); +``` + +You can also override only a specific endpoint by using the `@ContentSizeLimit` annotation: +```java +@POST + @Path("/test") + @Operation(description = "Example web-service") + @ContentSizeLimit(1024 * 1000 * 5) // 5MB + public void test(Test test) { + logger.info("Test: {}", test.getName()); + } +``` + Data validation --------------- To validate web-service input data, an easy solution is to use `WsException`: diff --git a/plume-web-jersey/pom.xml b/plume-web-jersey/pom.xml index 1e6587f..4491317 100644 --- a/plume-web-jersey/pom.xml +++ b/plume-web-jersey/pom.xml @@ -100,16 +100,39 @@ provided + - junit - junit - test - + com.carlosbecker + guice-junit-test-runner + + + guice + com.google.inject + + + - org.assertj - assertj-core - test - + junit + junit + test + + + org.assertj + assertj-core + test + + + org.glassfish.jersey.test-framework + jersey-test-framework-core + 3.1.1 + test + + + org.glassfish.jersey.test-framework.providers + jersey-test-framework-provider-grizzly2 + 3.1.1 + test + diff --git a/plume-web-jersey/src/main/java/com/coreoz/plume/jersey/security/control/ContentControlFeature.java b/plume-web-jersey/src/main/java/com/coreoz/plume/jersey/security/control/ContentControlFeature.java new file mode 100644 index 0000000..f70c740 --- /dev/null +++ b/plume-web-jersey/src/main/java/com/coreoz/plume/jersey/security/control/ContentControlFeature.java @@ -0,0 +1,172 @@ +package com.coreoz.plume.jersey.security.control; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.AnnotatedElement; + +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.container.DynamicFeature; +import jakarta.ws.rs.container.ResourceInfo; +import jakarta.ws.rs.core.FeatureContext; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.ReaderInterceptor; +import jakarta.ws.rs.ext.ReaderInterceptorContext; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class ContentControlFeature implements DynamicFeature { + + public static final int DEFAULT_MAX_SIZE = 500 * 1024; // 500 KB + private final Integer maxSize; + + public ContentControlFeature(int maxSize) { + this.maxSize = maxSize; + } + + public ContentControlFeature() { + this.maxSize = DEFAULT_MAX_SIZE; + } + + public Integer getContentSizeLimit() { + if (maxSize == null) { + return DEFAULT_MAX_SIZE; + } + return maxSize; + } + + @Override + public void configure(ResourceInfo resourceInfo, FeatureContext context) { + addContentSizeFilter(resourceInfo.getResourceMethod(), context); + } + + private void addContentSizeFilter(AnnotatedElement annotatedElement, FeatureContext methodResourcecontext) { + ContentSizeLimit contentSizeLimit = annotatedElement.getAnnotation(ContentSizeLimit.class); + methodResourcecontext.register(new ContentSizeLimitInterceptor( + contentSizeLimit != null ? contentSizeLimit.value() : maxSize + )); + } + + public static class ContentSizeLimitInterceptor implements ReaderInterceptor { + + private final int maxSize; + + public ContentSizeLimitInterceptor(int maxSize) { + this.maxSize = maxSize; + } + + // https://stackoverflow.com/questions/24516444/best-way-to-make-jersey-2-x-refuse-requests-with-incorrect-content-length + @Override + public Object aroundReadFrom(ReaderInterceptorContext context) throws IOException { + int headerContentLength = maxSize; // default value for GET or chunked body + String contentLengthHeader = context.getHeaders().getFirst(HttpHeaders.CONTENT_LENGTH); + if (contentLengthHeader != null) { + try { + headerContentLength = Integer.parseInt(contentLengthHeader); + } catch (NumberFormatException e) { + logger.warn("Wrong content length header received: {}", contentLengthHeader); + } + } + + if (headerContentLength > maxSize) { + throw new WebApplicationException( + Response.status(Response.Status.REQUEST_ENTITY_TOO_LARGE) + .entity("Content size limit exceeded.") + .build() + ); + } + + final InputStream contextInputStream = context.getInputStream(); + context.setInputStream(new SizeLimitingInputStream(contextInputStream, headerContentLength)); + + return context.proceed(); + } + + public static final class SizeLimitingInputStream extends InputStream { + private long length = 0; + private int mark = 0; + + private final int maxSize; + + private final InputStream delegateInputStream; + + public SizeLimitingInputStream(InputStream delegateInputStream, int maxSize) { + this.delegateInputStream = delegateInputStream; + this.maxSize = maxSize; + } + + @Override + public int read() throws IOException { + final int read = delegateInputStream.read(); + readAndCheck(read != -1 ? 1 : 0); + return read; + } + + @Override + public int read(final byte[] b) throws IOException { + final int read = delegateInputStream.read(b); + readAndCheck(read != -1 ? read : 0); + return read; + } + + @Override + public int read(final byte[] b, final int off, final int len) throws IOException { + final int read = delegateInputStream.read(b, off, len); + readAndCheck(read != -1 ? read : 0); + return read; + } + + @Override + public long skip(final long n) throws IOException { + final long skip = delegateInputStream.skip(n); + readAndCheck(skip != -1 ? skip : 0); + return skip; + } + + @Override + public int available() throws IOException { + return delegateInputStream.available(); + } + + @Override + public void close() throws IOException { + delegateInputStream.close(); + } + + @Override + public synchronized void mark(final int readlimit) { + mark += readlimit; + delegateInputStream.mark(readlimit); + } + + @Override + public synchronized void reset() throws IOException { + this.length = 0; + readAndCheck(mark); + delegateInputStream.reset(); + } + + @Override + public boolean markSupported() { + return delegateInputStream.markSupported(); + } + + private void readAndCheck(final long read) { + this.length += read; + + if (this.length > maxSize) { + try { + this.close(); + } catch (IOException e) { + logger.error("Error while closing the input stream", e); + } + throw new WebApplicationException( + Response.status(Response.Status.REQUEST_ENTITY_TOO_LARGE) + .entity("Content size limit exceeded.") + .build() + ); + } + } + } + } +} diff --git a/plume-web-jersey/src/main/java/com/coreoz/plume/jersey/security/control/ContentControlFeatureFactory.java b/plume-web-jersey/src/main/java/com/coreoz/plume/jersey/security/control/ContentControlFeatureFactory.java new file mode 100644 index 0000000..f00b669 --- /dev/null +++ b/plume-web-jersey/src/main/java/com/coreoz/plume/jersey/security/control/ContentControlFeatureFactory.java @@ -0,0 +1,21 @@ +package com.coreoz.plume.jersey.security.control; + +import org.glassfish.hk2.api.Factory; + +public class ContentControlFeatureFactory implements Factory { + private final Integer maxSize; + + public ContentControlFeatureFactory(int maxSize) { + this.maxSize = maxSize; + } + + @Override + public ContentControlFeature provide() { + return new ContentControlFeature(maxSize); + } + + @Override + public void dispose(ContentControlFeature instance) { + // unused + } +} diff --git a/plume-web-jersey/src/main/java/com/coreoz/plume/jersey/security/control/ContentSizeLimit.java b/plume-web-jersey/src/main/java/com/coreoz/plume/jersey/security/control/ContentSizeLimit.java new file mode 100644 index 0000000..fcbb049 --- /dev/null +++ b/plume-web-jersey/src/main/java/com/coreoz/plume/jersey/security/control/ContentSizeLimit.java @@ -0,0 +1,24 @@ +package com.coreoz.plume.jersey.security.control; + +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + + +/** + * Modify the default Content size limit handled by the backend + */ +@Documented +@Retention (RUNTIME) +@Target({TYPE, METHOD}) +public @interface ContentSizeLimit { + + /** + * The maximum size of the content in bytes + */ + int value(); +} diff --git a/plume-web-jersey/src/test/java/com/coreoz/plume/jersey/control/ContentSizeLimitTest.java b/plume-web-jersey/src/test/java/com/coreoz/plume/jersey/control/ContentSizeLimitTest.java new file mode 100644 index 0000000..f23232f --- /dev/null +++ b/plume-web-jersey/src/test/java/com/coreoz/plume/jersey/control/ContentSizeLimitTest.java @@ -0,0 +1,90 @@ +package com.coreoz.plume.jersey.control; + +import org.junit.Test; + +import jakarta.ws.rs.client.Entity; +import jakarta.ws.rs.core.Application; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.client.Invocation.Builder; + +import org.glassfish.jersey.server.ResourceConfig; +import org.glassfish.jersey.test.JerseyTest; + +import static org.junit.Assert.assertEquals; + +import com.coreoz.plume.jersey.security.control.ContentControlFeature; +import com.coreoz.plume.jersey.security.control.ContentControlFeatureFactory; + +public class ContentSizeLimitTest extends JerseyTest { + + @Override + protected Application configure() { + ResourceConfig config = new ResourceConfig(TestContentSizeResource.class); + config.register(ContentControlFeature.class); + return config; + } + + @Test + public void checkContentSize_withBody_whenWithinDefaultLimit_shouldReturn200() { + byte[] data = "12345".getBytes(); + Response response = target("/test/upload-default").request().post(Entity.entity(data, MediaType.APPLICATION_OCTET_STREAM)); + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + } + + @Test + public void checkContentSize_withBody_whenBeyondDefaultLimit_shouldReturn413() { + // Generate a byte array of ContentControlFeature.DEFAULT_MAX_SIZE + 1 + byte[] data = new byte[ContentControlFeature.DEFAULT_MAX_SIZE + 1]; + Builder request = target("/test/upload-default").request(); + Entity entity = Entity.entity(data, MediaType.APPLICATION_OCTET_STREAM); + assertEquals(Response.Status.REQUEST_ENTITY_TOO_LARGE.getStatusCode(), request.post(entity).getStatus()); + } + + @Test + public void checkContentSize_withBody_whenContentLengthIsWrong_shouldReturn413() { + // Generate a byte array of ContentControlFeature.DEFAULT_MAX_SIZE + 1 + byte[] data = new byte[ContentControlFeature.DEFAULT_MAX_SIZE + 1]; + Builder request = target("/test/upload-default").request(); + request.header(HttpHeaders.CONTENT_LENGTH, null); + assertEquals(Response.Status.REQUEST_ENTITY_TOO_LARGE.getStatusCode(), request.post(Entity.entity(data, MediaType.APPLICATION_OCTET_STREAM)).getStatus()); + } + + @Test + public void checkContentSize_withoutBody_whenDefaultLimit_shouldReturn200() { + Builder request = target("/test/upload-default").request(); + assertEquals(Response.Status.OK.getStatusCode(), request.get().getStatus()); + } + + @Test + public void checkContentSize_withBody_whenWithinCustomLimit_shouldReturn200() { + byte[] data = "12345".getBytes(); + Response response = target("/test/upload-custom").request().post(Entity.entity(data, MediaType.APPLICATION_OCTET_STREAM)); + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + } + + @Test + public void checkContentSize_withBody_whenBeyondCustomLimit_shouldReturn413() { + // Generate a byte array of CUSTOM_MAX_SIZE + 1 + byte[] data = new byte[TestContentSizeResource.CUSTOM_MAX_SIZE + 1]; + Builder request = target("/test/upload-custom").request(); + Entity entity = Entity.entity(data, MediaType.APPLICATION_OCTET_STREAM); + assertEquals(Response.Status.REQUEST_ENTITY_TOO_LARGE.getStatusCode(), request.post(entity).getStatus()); + } + + @Test + public void checkContentSize_withoutBody_whenCustomLimit_shouldReturn200() { + Builder request = target("/test/upload-custom").request(); + assertEquals(Response.Status.OK.getStatusCode(), request.get().getStatus()); + } + + @Test + public void checkMaxSize_whenCustomControlFeature_shouldSuccess() { + // Custom max size + Integer customMaxSize = 300; + ContentControlFeatureFactory contentControlFeatureFactory = new ContentControlFeatureFactory(customMaxSize); + ContentControlFeature contentControlFeature = contentControlFeatureFactory.provide(); + assertEquals(customMaxSize, contentControlFeature.getContentSizeLimit()); + } +} diff --git a/plume-web-jersey/src/test/java/com/coreoz/plume/jersey/control/SizeLimitingInputStreamTest.java b/plume-web-jersey/src/test/java/com/coreoz/plume/jersey/control/SizeLimitingInputStreamTest.java new file mode 100644 index 0000000..81175a1 --- /dev/null +++ b/plume-web-jersey/src/test/java/com/coreoz/plume/jersey/control/SizeLimitingInputStreamTest.java @@ -0,0 +1,126 @@ +package com.coreoz.plume.jersey.control; + +import org.junit.Test; +import org.junit.After; + +import java.io.IOException; + +import jakarta.ws.rs.WebApplicationException; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +import java.io.ByteArrayInputStream; + +import com.coreoz.plume.jersey.security.control.ContentControlFeature.ContentSizeLimitInterceptor.SizeLimitingInputStream; + +public class SizeLimitingInputStreamTest { + + private static final int LIMIT = 10; + private ByteArrayInputStream byteArrayInputStream; + private SizeLimitingInputStream sizeLimitingInputStream; + + @After + public void tearDown() throws IOException { + if (sizeLimitingInputStream != null) { + sizeLimitingInputStream.close(); + } + } + + @Test + public void testRead_whenWithinLimit_shouldSuccess() throws IOException { + byte[] data = "12345".getBytes(); + byteArrayInputStream = new ByteArrayInputStream(data); + sizeLimitingInputStream = new SizeLimitingInputStream(byteArrayInputStream, LIMIT); + + byte[] buffer = new byte[data.length]; + int bytesRead = sizeLimitingInputStream.read(buffer); + + assertEquals(data.length, bytesRead); + assertArrayEquals(data, buffer); + } + + @Test + public void testRead_whenBeyondLimit_shouldThrow() { + byte[] data = "12345678901".getBytes(); // 11 bytes, 1 byte over the limit + byteArrayInputStream = new ByteArrayInputStream(data); + sizeLimitingInputStream = new SizeLimitingInputStream(byteArrayInputStream, LIMIT); + + byte[] buffer = new byte[data.length]; + assertThrows(WebApplicationException.class, () -> { + sizeLimitingInputStream.read(buffer); + }); + } + + @Test + public void testRead_whenExactLimit_shouldSuccess() throws IOException { + byte[] data = "1234567890".getBytes(); // 10 bytes, exactly at the limit + byteArrayInputStream = new ByteArrayInputStream(data); + sizeLimitingInputStream = new SizeLimitingInputStream(byteArrayInputStream, LIMIT); + + byte[] buffer = new byte[data.length]; + int bytesRead = sizeLimitingInputStream.read(buffer); + + assertEquals(data.length, bytesRead); + assertArrayEquals(data, buffer); + } + + @Test + public void testRead_whenEmpty_shouldSuccessWithoutReading() throws IOException { + byte[] data = "".getBytes(); + byteArrayInputStream = new ByteArrayInputStream(data); + sizeLimitingInputStream = new SizeLimitingInputStream(byteArrayInputStream, LIMIT); + + byte[] buffer = new byte[10]; + int bytesRead = sizeLimitingInputStream.read(buffer); + + assertEquals(-1, bytesRead); + } + + @Test + public void testRead_withoutOffsetAndLength_shouldSuccess() throws IOException { + byte[] data = "1234567890".getBytes(); // 10 bytes, exactly at the limit + byteArrayInputStream = new ByteArrayInputStream(data); + sizeLimitingInputStream = new SizeLimitingInputStream(byteArrayInputStream, LIMIT); + int result = sizeLimitingInputStream.read(data, 0, 5); + assertEquals(5, result); + } + + @Test + public void testRead_withoutOffsetAndWithoutLength_shouldSuccess() throws IOException { + byte[] data = "1234567890".getBytes(); // 10 bytes, exactly at the limit + byteArrayInputStream = new ByteArrayInputStream(data); + sizeLimitingInputStream = new SizeLimitingInputStream(byteArrayInputStream, LIMIT); + int result = sizeLimitingInputStream.read(); + assertEquals('1', result); + } + + @Test + public void testRead_withOffsetAndLength_shouldSuccess() throws IOException { + byte[] data = "1234567890".getBytes(); // 10 bytes, exactly at the limit + byteArrayInputStream = new ByteArrayInputStream(data); + sizeLimitingInputStream = new SizeLimitingInputStream(byteArrayInputStream, LIMIT); + int result = sizeLimitingInputStream.read(data, 3, 5); + assertEquals(5, result); + } + + @Test + public void testSkip_withinLength_shouldSuccess() throws IOException { + byte[] data = "1234567890".getBytes(); // 10 bytes, exactly at the limit + byteArrayInputStream = new ByteArrayInputStream(data); + sizeLimitingInputStream = new SizeLimitingInputStream(byteArrayInputStream, LIMIT); + long result = sizeLimitingInputStream.skip(3); + assertEquals(3, result); + } + + @Test + public void testSkip_beyondLength_shouldSuccess() throws IOException { + byte[] data = "1234567890".getBytes(); // 10 bytes, exactly at the limit + byteArrayInputStream = new ByteArrayInputStream(data); + sizeLimitingInputStream = new SizeLimitingInputStream(byteArrayInputStream, LIMIT); + long result = sizeLimitingInputStream.skip(11); + assertEquals(10, result); + } + +} diff --git a/plume-web-jersey/src/test/java/com/coreoz/plume/jersey/control/TestContentSizeResource.java b/plume-web-jersey/src/test/java/com/coreoz/plume/jersey/control/TestContentSizeResource.java new file mode 100644 index 0000000..e6e6b59 --- /dev/null +++ b/plume-web-jersey/src/test/java/com/coreoz/plume/jersey/control/TestContentSizeResource.java @@ -0,0 +1,41 @@ +package com.coreoz.plume.jersey.control; + +import com.coreoz.plume.jersey.security.control.ContentSizeLimit; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Response; + +@Path("/test") +public class TestContentSizeResource { + + static final int CUSTOM_MAX_SIZE = 10; + + @POST + @Path("/upload-default") + public Response uploadDefaultLimit(byte[] data) { + return Response.ok("Upload successful").build(); + } + + @GET + @Path("/upload-default") + public Response getDefaultLimit(byte[] data) { + return Response.ok("get successful").build(); + } + + @POST + @Path("/upload-custom") + @ContentSizeLimit(CUSTOM_MAX_SIZE) + public Response uploadCustomLimit(byte[] data) { + return Response.ok("Upload successful").build(); + } + + @GET + @Path("/upload-custom") + @ContentSizeLimit(CUSTOM_MAX_SIZE) + public Response getCustomLimit(byte[] data) { + return Response.ok("get successful").build(); + } + +}