diff --git a/NEWS.md b/NEWS.md
index 9aa28604..06ef7004 100644
--- a/NEWS.md
+++ b/NEWS.md
@@ -1,3 +1,8 @@
+## 3.2.2 2024-05-28
+* [SIP2-200](https://issues.folio.org/browse/SIP2-200): Enhance WebClient TLS Configuration for Secure Connections to OKAPI
+* [SIP2-201](https://issues.folio.org/browse/SIP2-201): Enhance SIP2 Endpoint Security with TLS and FIPS-140-2 Compliant Cryptography
+* [SIP2-202](https://issues.folio.org/browse/SIP2-202): Vert.x 4.5.7 fixing netty form POST OOM CVE-2024-29025
+
## 3.2.1 2024-04-19
* [SIP2-195](https://issues.folio.org/browse/SIP2-195): FolioResourceProvider is submitting expired access tokens to FOLIO
diff --git a/README.md b/README.md
index 5da0b4aa..4c534cde 100644
--- a/README.md
+++ b/README.md
@@ -53,7 +53,7 @@ command line arguments to point it to the right path (e.g. `-conf /path/to/confi
|Config option|Type|Description|
|-------------|--|-----------|
-|`port`|int|The port the module will use to bind, typically 1024 < port < 65,535.|
+|`port`|int|The port the module will use to bind, typically 1024 <= port <= 65,535; must not be 8081 that is used for health check.|
|`okapiUrl`|string|The URL of the Okapi server used by FOLIO.|
|`tenantConfigRetrieverOptions`|JSON object|Location for tenant configuration.|
|`scanPeriod`|int|Frequency in msec that sip2 will check for and reload tenant configuration changes.|
@@ -304,6 +304,10 @@ All permission associated with edge-sip2
For local development, there is no requirement to encrypt communications from a SIP2 client to edge-sip2. Unencrypted TCP sockets are the default when launching edge-sip2 as described in the [Configuration](#configuration) section. Encrypted communication from a SIP2 client is only required when explicitly configured via the [above options](#security) and is up to the developer to provide that secure connection for edge-sip2.
+## Health check
+
+A GET /admin/health request sent to port 8081 gets a response with 200 HTTP status code.
+
## Metrics
This module makes use of [Micrometer](https://micrometer.io) to collect SIP2, Vert.x and JVM metrics. The metrics need to be collected by a monitoring system backed. This is where Micrometer provides flexibility, by allowing the module to code to the Micrometer interface, which is vendor neutral. Once determined, the vendor specific backend binding is provided runtime and can be easily replaced.
diff --git a/pom.xml b/pom.xml
index 6ea886c5..8be1931a 100644
--- a/pom.xml
+++ b/pom.xml
@@ -3,7 +3,7 @@
4.0.0
org.folio
edge-sip2
- 3.2.2-SNAPSHOT
+ 3.2.3-SNAPSHOT
Standard Interchange Protocol v2 (SIP2)
https://github.com/folio-org/edge-sip2
Support for SIP2 in FOLIO. This allow self service circulation and patron services stations to perform supported operations in FOLIO.
@@ -42,7 +42,7 @@
UTF-8
org.folio.edge.sip2.MainVerticle
- 4.5.4
+ 4.5.7
2.23.0
1.9.4
3.8.1
@@ -188,7 +188,12 @@
org.bouncycastle
bcpkix-jdk15to18
- 1.74
+ 1.78
+ test
+
+
+ io.netty
+ netty-codec-http
test
diff --git a/src/main/java/org/folio/edge/sip2/MainVerticle.java b/src/main/java/org/folio/edge/sip2/MainVerticle.java
index 498c87af..690dafea 100644
--- a/src/main/java/org/folio/edge/sip2/MainVerticle.java
+++ b/src/main/java/org/folio/edge/sip2/MainVerticle.java
@@ -62,6 +62,7 @@
import org.folio.edge.sip2.parser.Parser;
import org.folio.edge.sip2.session.SessionData;
import org.folio.edge.sip2.utils.TenantUtils;
+import org.folio.edge.sip2.utils.WebClientUtils;
public class MainVerticle extends AbstractVerticle {
@@ -320,7 +321,7 @@ private void resendPreviousMessage(SessionData sessionData,
private void setupHanlders() {
if (handlers == null) {
String okapiUrl = config().getString("okapiUrl");
- final WebClient webClient = WebClient.create(vertx);
+ final WebClient webClient = WebClientUtils.create(vertx, config());
final Injector injector = Guice.createInjector(
new FolioResourceProviderModule(okapiUrl, webClient),
new ApplicationModule());
@@ -358,10 +359,10 @@ private void callAdminHealthCheckService() {
}
});
+ var msg = "Listening for /admin/health requests at port " + HEALTH_CHECK_PORT;
httpServer.listen(HEALTH_CHECK_PORT)
- .onSuccess(x -> log.info("Health endpoint is listening now"))
- .onFailure(e -> log.error("The call to admin health check service "
- + "failed due to : {}", e.getMessage(), e));
+ .onSuccess(x -> log.info("{} now", msg))
+ .onFailure(e -> log.error("{} failed: {}", msg, e.getMessage(), e));
}
@Override
diff --git a/src/main/java/org/folio/edge/sip2/utils/WebClientConfigException.java b/src/main/java/org/folio/edge/sip2/utils/WebClientConfigException.java
new file mode 100644
index 00000000..2840e51c
--- /dev/null
+++ b/src/main/java/org/folio/edge/sip2/utils/WebClientConfigException.java
@@ -0,0 +1,8 @@
+package org.folio.edge.sip2.utils;
+
+public class WebClientConfigException extends RuntimeException {
+
+ public WebClientConfigException(String message) {
+ super(message);
+ }
+}
diff --git a/src/main/java/org/folio/edge/sip2/utils/WebClientUtils.java b/src/main/java/org/folio/edge/sip2/utils/WebClientUtils.java
new file mode 100644
index 00000000..b7df512f
--- /dev/null
+++ b/src/main/java/org/folio/edge/sip2/utils/WebClientUtils.java
@@ -0,0 +1,55 @@
+package org.folio.edge.sip2.utils;
+
+import io.vertx.core.Vertx;
+import io.vertx.core.json.JsonArray;
+import io.vertx.core.json.JsonObject;
+import io.vertx.core.net.PemTrustOptions;
+import io.vertx.ext.web.client.WebClient;
+import io.vertx.ext.web.client.WebClientOptions;
+import java.util.Objects;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+
+public class WebClientUtils {
+
+ public static final String SYS_PORT = "port";
+ public static final String SYS_NET_SERVER_OPTIONS = "netServerOptions";
+ public static final String SYS_PEM_KEY_CERT_OPTIONS = "pemKeyCertOptions";
+ public static final String SYS_CERT_PATHS = "certPaths";
+ private static final Logger log = LogManager.getLogger();
+
+ private WebClientUtils() {
+ }
+
+ /**
+ * Create WebClient with TLS.
+ * @param vertx instance
+ * @param config json config
+ * @return WebClient
+ */
+ public static WebClient create(Vertx vertx, JsonObject config) {
+ JsonObject netServerOptions = config.getJsonObject(SYS_NET_SERVER_OPTIONS);
+ if (Objects.nonNull(netServerOptions)
+ && netServerOptions.containsKey(SYS_PEM_KEY_CERT_OPTIONS)) {
+ log.info("Creating WebClient with TLS on...");
+
+ JsonArray certPaths = netServerOptions.getJsonObject(SYS_PEM_KEY_CERT_OPTIONS)
+ .getJsonArray(SYS_CERT_PATHS);
+ if (Objects.isNull(certPaths) || certPaths.isEmpty()) {
+ throw new WebClientConfigException("No TLS certPaths were found in config");
+ }
+
+ final PemTrustOptions pemTrustOptions = new PemTrustOptions();
+ certPaths.forEach(entry -> pemTrustOptions.addCertPath((String) entry));
+
+ final WebClientOptions webClientOptions = new WebClientOptions()
+ .setSsl(true)
+ .setTrustOptions(pemTrustOptions);
+ return WebClient.create(vertx, webClientOptions);
+ } else {
+ log.info("Creating WebClient with TLS off...");
+ return WebClient.create(vertx);
+ }
+ }
+}
diff --git a/src/test/java/org/folio/edge/sip2/api/MainVerticleTests.java b/src/test/java/org/folio/edge/sip2/api/MainVerticleTests.java
index 757f2752..1c5fe881 100644
--- a/src/test/java/org/folio/edge/sip2/api/MainVerticleTests.java
+++ b/src/test/java/org/folio/edge/sip2/api/MainVerticleTests.java
@@ -7,7 +7,6 @@
import io.vertx.core.Vertx;
import io.vertx.core.http.HttpClient;
import io.vertx.core.http.HttpClientRequest;
-import io.vertx.core.http.HttpClientResponse;
import io.vertx.core.http.HttpMethod;
import io.vertx.junit5.VertxTestContext;
import java.text.SimpleDateFormat;
diff --git a/src/test/java/org/folio/edge/sip2/utils/WebClientUtilsTests.java b/src/test/java/org/folio/edge/sip2/utils/WebClientUtilsTests.java
new file mode 100644
index 00000000..b6ba2cf3
--- /dev/null
+++ b/src/test/java/org/folio/edge/sip2/utils/WebClientUtilsTests.java
@@ -0,0 +1,166 @@
+package org.folio.edge.sip2.utils;
+
+import static org.folio.edge.sip2.utils.WebClientUtils.SYS_CERT_PATHS;
+import static org.folio.edge.sip2.utils.WebClientUtils.SYS_NET_SERVER_OPTIONS;
+import static org.folio.edge.sip2.utils.WebClientUtils.SYS_PEM_KEY_CERT_OPTIONS;
+import static org.folio.edge.sip2.utils.WebClientUtils.SYS_PORT;
+
+import io.netty.handler.codec.http.HttpResponseStatus;
+import io.vertx.core.Vertx;
+import io.vertx.core.file.FileSystem;
+import io.vertx.core.http.HttpHeaders;
+import io.vertx.core.http.HttpServer;
+import io.vertx.core.http.HttpServerOptions;
+import io.vertx.core.json.JsonArray;
+import io.vertx.core.json.JsonObject;
+import io.vertx.core.net.SelfSignedCertificate;
+import io.vertx.ext.web.client.WebClient;
+import io.vertx.junit5.VertxExtension;
+import io.vertx.junit5.VertxTestContext;
+import java.io.IOException;
+import java.net.ServerSocket;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+@ExtendWith(VertxExtension.class)
+public class WebClientUtilsTests {
+
+ private static final String RESPONSE_MESSAGE = "";
+ private static final Logger log = LogManager.getLogger();
+ private Integer serverPort;
+ private SelfSignedCertificate selfSignedCertificate;
+
+ @BeforeEach
+ void setup() {
+ this.serverPort = getAvailablePort();
+ this.selfSignedCertificate = SelfSignedCertificate.create();
+ }
+
+ @AfterEach
+ void tearDown() {
+ this.serverPort = null;
+ this.selfSignedCertificate = null;
+ }
+
+ @Test
+ void testCreateWebClientTlsOff(Vertx vertx) {
+ JsonObject config = new JsonObject();
+ Assertions.assertDoesNotThrow(() -> WebClientUtils.create(vertx, config));
+ }
+
+ @Test
+ void testCreateWebClientTlsOn(Vertx vertx) {
+ JsonObject config = new JsonObject().put(SYS_NET_SERVER_OPTIONS, new JsonObject()
+ .put(SYS_PEM_KEY_CERT_OPTIONS, selfSignedCertificate.keyCertOptions().toJson()));
+ Assertions.assertDoesNotThrow(() -> WebClientUtils.create(vertx, config));
+ }
+
+ @Test
+ void testCreateWebClientTlsOnMultipleCerts(Vertx vertx) {
+ JsonArray certPathsArray = new JsonArray()
+ .add(SelfSignedCertificate.create().certificatePath())
+ .add(SelfSignedCertificate.create().certificatePath())
+ .add(SelfSignedCertificate.create().certificatePath());
+ JsonObject certPaths = new JsonObject().put(SYS_CERT_PATHS, certPathsArray);
+
+ JsonObject config = new JsonObject().put(SYS_NET_SERVER_OPTIONS, new JsonObject()
+ .put(SYS_PEM_KEY_CERT_OPTIONS, certPaths));
+ Assertions.assertDoesNotThrow(() -> WebClientUtils.create(vertx, config));
+ }
+
+ @Test
+ void testCreateWebClientWithMissingCertPaths(Vertx vertx) {
+ JsonObject config = new JsonObject().put(SYS_NET_SERVER_OPTIONS, new JsonObject()
+ .put(SYS_PEM_KEY_CERT_OPTIONS, new JsonObject()));
+ Assertions.assertThrows(WebClientConfigException.class,
+ () -> WebClientUtils.create(vertx, config));
+ }
+
+ @Test
+ void testCreateWebClientWithEmptyCertPaths(Vertx vertx) {
+ JsonObject config = new JsonObject().put(SYS_NET_SERVER_OPTIONS, new JsonObject()
+ .put(SYS_PEM_KEY_CERT_OPTIONS, new JsonObject().put(SYS_CERT_PATHS, new JsonArray())));
+ Assertions.assertThrows(WebClientConfigException.class,
+ () -> WebClientUtils.create(vertx, config));
+ }
+
+ @Test
+ void testWebClientServerCommunication(Vertx vertx, VertxTestContext testContext) {
+ JsonObject sipConfig = getCommonSipConfig(vertx);
+
+ sipConfig.put(SYS_PORT, serverPort);
+ sipConfig.put(SYS_NET_SERVER_OPTIONS, new JsonObject()
+ .put(SYS_PEM_KEY_CERT_OPTIONS, selfSignedCertificate.keyCertOptions().toJson()));
+
+ createServerTlsOn(vertx, testContext);
+
+ final WebClient webClient = WebClientUtils.create(vertx, sipConfig);
+ webClient.get(serverPort, "localhost", "/")
+ .send()
+ .onComplete(testContext.succeeding(response -> {
+ String message = response.body().toString();
+ log.info("WebClient sent message to port {}, message: {}", serverPort, message);
+ Assertions.assertEquals(HttpResponseStatus.OK.code(), response.statusCode());
+ Assertions.assertEquals(RESPONSE_MESSAGE, message);
+ testContext.completeNow();
+ }));
+ }
+
+ @Test
+ void testFailingWebClientServerCommunication(Vertx vertx, VertxTestContext testContext) {
+ JsonObject sipConfig = getCommonSipConfig(vertx);
+
+ sipConfig.put(SYS_PORT, serverPort);
+ sipConfig.put(SYS_NET_SERVER_OPTIONS, new JsonObject());
+
+ createServerTlsOn(vertx, testContext);
+
+ final WebClient webClient = WebClientUtils.create(vertx, sipConfig);
+ webClient.get(serverPort, "localhost", "/")
+ .send()
+ .onComplete(testContext.failing(err -> {
+ log.info("Connection error: ", err);
+ testContext.completeNow();
+ }));
+ }
+
+ private void createServerTlsOn(Vertx vertx, VertxTestContext testContext) {
+ final HttpServerOptions httpServerOptions = new HttpServerOptions()
+ .setPort(serverPort)
+ .setSsl(true)
+ .setKeyCertOptions(selfSignedCertificate.keyCertOptions());
+
+ final HttpServer httpServer = vertx.createHttpServer(httpServerOptions);
+ httpServer
+ .requestHandler(req -> req.response()
+ .putHeader(HttpHeaders.CONTENT_TYPE, "text/plain")
+ .end(RESPONSE_MESSAGE))
+ .listen(serverPort, http -> {
+ if (http.succeeded()) {
+ log.info("Server started on port: {}", serverPort);
+ } else {
+ testContext.failNow(http.cause());
+ }
+ });
+ }
+
+ private static JsonObject getCommonSipConfig(Vertx vertx) {
+ final FileSystem fileSystem = vertx.fileSystem();
+ return fileSystem.readFileBlocking("test-sip2.conf").toJsonObject();
+ }
+
+ private static int getAvailablePort() {
+ do {
+ try (ServerSocket socket = new ServerSocket(0)) {
+ return socket.getLocalPort();
+ } catch (IOException e) {
+ // ignore
+ }
+ } while (true);
+ }
+}