diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml deleted file mode 100644 index ec879d84c..000000000 --- a/.github/workflows/build.yaml +++ /dev/null @@ -1,42 +0,0 @@ -name: Build OpenIntegrationEngine - -on: - push: - branches: - - main - pull_request: - branches: - - main - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Set up JDK - uses: actions/setup-java@v4 - with: - java-version: '17' - java-package: 'jdk+fx' - distribution: 'zulu' - - - name: Build OIE (signed) - if: github.ref == 'refs/heads/main' - working-directory: server - run: ant -f mirth-build.xml - - - name: Build OIE (unsigned) - if: github.ref != 'refs/heads/main' - working-directory: server - run: ant -f mirth-build.xml -DdisableSigning=true - - - name: Package distribution - run: tar czf openintegrationengine.tar.gz -C server/ setup --transform 's|^setup|openintegrationengine/|' - - - name: Create artifact - uses: actions/upload-artifact@v4 - with: - name: oie-build - path: openintegrationengine.tar.gz diff --git a/client/src/com/mirth/connect/client/ui/KeystoreWarningDialog.java b/client/src/com/mirth/connect/client/ui/KeystoreWarningDialog.java index a2232b9a4..5d0d5804e 100644 --- a/client/src/com/mirth/connect/client/ui/KeystoreWarningDialog.java +++ b/client/src/com/mirth/connect/client/ui/KeystoreWarningDialog.java @@ -35,19 +35,18 @@ public KeystoreWarningDialog(Window owner) { private void initComponents() { setDefaultCloseOperation(javax.swing.WindowConstants.DISPOSE_ON_CLOSE); - JLabel iconLabel = new JLabel(UIManager.getIcon("OptionPane.warningIcon")); + javax.swing.Icon warningIcon = UIManager.getIcon("OptionPane.warningIcon"); + JLabel iconLabel = new JLabel(warningIcon); + int iconWidth = (warningIcon != null) ? warningIcon.getIconWidth() : 32; JLabel messageLabel = new JLabel( "The keystore passwords for this BridgeLink instance are still set to " + "default values.

" + "The keystore stores the SSL/TLS certificate for the BridgeLink API and
" + "Administrator (port 8443).

" - + "Note: If any channels have message-level encryption enabled
" - + "(encryptData=true), those stored messages will become unreadable after
" - + "regeneration. Channels will continue to process new messages normally.

" - + "Note: If encrypt.properties=true in mirth.properties, the encrypted
" - + "database.password will also become unreadable after regeneration.
" - + "This setting defaults to false in most installations.

" + + "Note: Channel message encryption (encryptData=true) and encrypted
" + + "database passwords (encrypt.properties=true) are NOT affected — they
" + + "use a separate AES key that is preserved during regeneration.

" + "Note: BridgeLink must be restarted after regenerating the keystore
" + "for the new SSL certificate to take effect."); @@ -87,7 +86,9 @@ public void actionPerformed(ActionEvent evt) { .addGroup(layout.createSequentialGroup() .addComponent(iconLabel) .addComponent(messageLabel)) - .addComponent(regenerateCheckBox) + .addGroup(layout.createSequentialGroup() + .addGap(iconWidth + 6) + .addComponent(regenerateCheckBox)) .addComponent(buttonPanel, javax.swing.GroupLayout.DEFAULT_SIZE, javax.swing.GroupLayout.DEFAULT_SIZE, Short.MAX_VALUE) ); diff --git a/server/conf/mirth.properties b/server/conf/mirth.properties index efb5d7bee..999702d01 100644 --- a/server/conf/mirth.properties +++ b/server/conf/mirth.properties @@ -8,9 +8,6 @@ dir.tempdata = ${dir.appdata}/temp http.port = 8080 https.port = 8443 -# Windows installer sets this for fresh installs; leave commented for upgrades. -# default.firstLogin.password = - # password requirements password.minlength = 8 password.minupper = 1 @@ -89,7 +86,7 @@ database = derby # SQL Server/Sybase (jTDS) jdbc:jtds:sqlserver://localhost:1433/mirthdb # Microsoft SQL Server jdbc:sqlserver://localhost:1433;databaseName=mirthdb # If you are using the Microsoft SQL Server driver, please also specify database.driver below -database.url = jdbc:derby:${dir.appdata}/mirthdb;create=true;upgrade=true +database.url = jdbc:derby:${dir.appdata}/mirthdb;create=true;upgrade=false # If using a custom or non-default driver, specify it here. # example: diff --git a/server/src/com/mirth/connect/connectors/http/HttpReceiver.java b/server/src/com/mirth/connect/connectors/http/HttpReceiver.java index 84430abd3..a7427d689 100644 --- a/server/src/com/mirth/connect/connectors/http/HttpReceiver.java +++ b/server/src/com/mirth/connect/connectors/http/HttpReceiver.java @@ -228,7 +228,14 @@ public void onStart() throws ConnectorTaskException { server = new Server(); configuration.configureReceiver(this); + // All static resource handlers are embedded inside the channel ContextHandler rather + // than given their own separate ContextHandlers. CoreContextHandler.handle() returns + // true once the request path matches the context prefix — even when the inner EE8 + // handler never sets isHandled(). A separate ContextHandler per resource therefore + // blocks fallthrough to the channel for any sub-path the resource cannot serve + // (e.g. GET /test/data/wrong when the resource is the CUSTOM type at /test/data). HandlerCollection handlers = new HandlerCollection(); + List resourceHandlers = new ArrayList<>(); // Add handlers for each static resource if (getConnectorProperties().getStaticResources() != null) { @@ -278,33 +285,67 @@ public void onStart() throws ConnectorTaskException { for (List staticResourcesList : staticResourcesMap.descendingMap().values()) { for (HttpStaticResource staticResource : staticResourcesList) { logger.debug("Adding static resource handler for context path: " + staticResource.getContextPath()); - ContextHandler resourceContextHandler = new ContextHandler(); - resourceContextHandler.setContextPath(staticResource.getContextPath()); - // This allows resources to be requested without a relative context path (e.g. "/") - resourceContextHandler.setAllowNullPathInfo(true); - resourceContextHandler.setHandler(new StaticResourceHandler(staticResource)); - handlers.addHandler(resourceContextHandler); + org.eclipse.jetty.ee8.nested.Handler resourceInner = new StaticResourceHandler(staticResource); + if (authenticatorProvider != null) { + resourceInner = createSecurityHandler(resourceInner); + } + // Collected here; embedded inside the channel context below. + resourceHandlers.add(resourceInner); } } } - // Add the main request handler + // Build the channel context handler. All static resource handlers are placed + // before RequestHandler inside a stop-on-first-handled HandlerCollection so that + // any request the resource handler cannot serve falls through to the channel. + // Using HandlerCollection (not an anonymous AbstractHandler) ensures Jetty's + // lifecycle methods (setServer, start) propagate to all inner handlers via addHandler(). ContextHandler contextHandler = new ContextHandler(); contextHandler.setContextPath(contextPath); - contextHandler.setHandler(new RequestHandler()); + contextHandler.setAllowNullPathInfo(true); + org.eclipse.jetty.ee8.nested.Handler channelBase = new RequestHandler(); + if (authenticatorProvider != null) { + channelBase = createSecurityHandler(channelBase); + } + if (resourceHandlers.isEmpty()) { + contextHandler.setHandler(channelBase); + } else { + final org.eclipse.jetty.ee8.nested.Handler finalChannelBase = channelBase; + HandlerCollection resourceChain = new HandlerCollection() { + @Override + public void handle(String target, Request baseRequest, + HttpServletRequest servletRequest, HttpServletResponse servletResponse) + throws IOException, ServletException { + for (org.eclipse.jetty.ee8.nested.Handler h : getHandlers()) { + h.handle(target, baseRequest, servletRequest, servletResponse); + if (baseRequest.isHandled()) { + return; + } + } + } + }; + for (org.eclipse.jetty.ee8.nested.Handler h : resourceHandlers) { + resourceChain.addHandler(h); + } + resourceChain.addHandler(finalChannelBase); + contextHandler.setHandler(resourceChain); + } handlers.addHandler(contextHandler); - // Wrap the handler collection in a security handler if needed - org.eclipse.jetty.ee8.nested.Handler serverHandler = handlers; - if (authenticatorProvider != null) { - serverHandler = createSecurityHandler(handlers); + org.eclipse.jetty.server.Handler.Sequence handlerSequence = + new org.eclipse.jetty.server.Handler.Sequence(); + for (org.eclipse.jetty.ee8.nested.Handler h : handlers.getHandlers()) { + if (h instanceof ContextHandler ch) { + ch.setServer(server); + handlerSequence.addHandler(ch.getCoreContextHandler()); + } } - - // In Jetty 12, we need to wrap the EE8 handler in a ContextHandler and get the core handler - ContextHandler rootContextHandler = new ContextHandler(); - rootContextHandler.setContextPath("/"); - rootContextHandler.setHandler(serverHandler); - server.setHandler(rootContextHandler.getCoreContextHandler()); + org.eclipse.jetty.server.handler.DefaultHandler defaultHandler = + new org.eclipse.jetty.server.handler.DefaultHandler(); + defaultHandler.setServeFavIcon(false); + defaultHandler.setShowContexts(false); + handlerSequence.addHandler(defaultHandler); + server.setHandler(handlerSequence); logger.debug("starting HTTP server with address: " + host + ":" + port); server.start(); @@ -346,6 +387,10 @@ protected String getConfigurationClass() { private class RequestHandler extends AbstractHandler { @Override public void handle(String target, Request baseRequest, HttpServletRequest servletRequest, HttpServletResponse servletResponse) throws IOException, ServletException { + // Skip if a static resource handler already served this request (Jetty 12 EE8 ContextHandler no longer guards on isHandled()) + if (baseRequest.isHandled()) { + return; + } logger.debug("received HTTP request"); eventController.dispatchEvent(new ConnectionStatusEvent(getChannelId(), getMetaDataId(), getSourceName(), ConnectionStatusEventType.CONNECTED)); DispatchResult dispatchResult = null; @@ -514,6 +559,7 @@ protected void sendResponse(Request baseRequest, HttpServletResponse servletResp gzipOutputStream.write(responseBytes); gzipOutputStream.finish(); } else { + servletResponse.setContentLength(responseBytes.length); responseOutputStream.write(responseBytes); } @@ -546,9 +592,11 @@ protected void sendErrorResponse(Request baseRequest, HttpServletResponse servle } } + byte[] errBytes = responseError.getBytes(); servletResponse.setContentType("text/plain"); servletResponse.setStatus(HttpStatus.SC_INTERNAL_SERVER_ERROR); - servletResponse.getOutputStream().write(responseError.getBytes()); + servletResponse.setContentLength(errBytes.length); + servletResponse.getOutputStream().write(errBytes); } protected Object getMessage(Request request, Map sourceMap, List attachments) throws IOException, ChannelException, MessagingException, DonkeyElementException, ParserConfigurationException { @@ -679,6 +727,7 @@ public void handle(String target, Request baseRequest, HttpServletRequest servle String originalThreadName = Thread.currentThread().getName(); + boolean responded = false; try { Thread.currentThread().setName("HTTP Receiver Thread on " + getChannel().getName() + " (" + getChannelId() + ") < " + originalThreadName); HttpRequestMessage requestMessage = createRequestMessage(baseRequest, true); @@ -724,12 +773,14 @@ public void handle(String target, Request baseRequest, HttpServletRequest servle OutputStream responseOutputStream = servletResponse.getOutputStream(); // If the client accepts GZIP compression, compress the content + boolean gzipOutput = false; List acceptEncodingList = requestMessage.getCaseInsensitiveHeaders().get("Accept-Encoding"); if (CollectionUtils.isNotEmpty(acceptEncodingList)) { for (String acceptEncoding : acceptEncodingList) { if (acceptEncoding != null && acceptEncoding.contains("gzip")) { servletResponse.setHeader(HTTP.CONTENT_ENCODING, "gzip"); responseOutputStream = new GZIPOutputStream(responseOutputStream); + gzipOutput = true; break; } } @@ -739,7 +790,11 @@ public void handle(String target, Request baseRequest, HttpServletRequest servle // Just stream the file itself back to the client InputStream is = null; try { - is = new FileInputStream(value); + File f = new File(value); + if (!gzipOutput) { + servletResponse.setContentLengthLong(f.length()); + } + is = new FileInputStream(f); IOUtils.copy(is, responseOutputStream); } finally { ResourceUtil.closeResourceQuietly(is); @@ -787,6 +842,9 @@ public void handle(String target, Request baseRequest, HttpServletRequest servle // A valid file was found; stream it back to the client InputStream is = null; try { + if (!gzipOutput) { + servletResponse.setContentLengthLong(file.length()); + } is = new FileInputStream(file); IOUtils.copy(is, responseOutputStream); } finally { @@ -799,26 +857,38 @@ public void handle(String target, Request baseRequest, HttpServletRequest servle } } else { // Stream the value string back to the client - IOUtils.write(value, responseOutputStream, charset); + byte[] valueBytes = value.getBytes(charset); + if (!gzipOutput) { + servletResponse.setContentLength(valueBytes.length); + } + responseOutputStream.write(valueBytes); } // If we gzipped, we need to finish the stream now - if (responseOutputStream instanceof GZIPOutputStream) { + if (gzipOutput) { ((GZIPOutputStream) responseOutputStream).finish(); } + responded = true; } catch (Throwable t) { logger.error("Error handling static HTTP resource request (" + getConnectorProperties().getName() + " \"Source\" on channel " + getChannelId() + ").", t); eventController.dispatchEvent(new ErrorEvent(getChannelId(), getMetaDataId(), null, ErrorEventType.SOURCE_CONNECTOR, getSourceName(), getConnectorProperties().getName(), "Error handling static HTTP resource request", t)); - servletResponse.reset(); - servletResponse.setContentType(ContentType.TEXT_PLAIN.toString()); - servletResponse.setStatus(HttpStatus.SC_INTERNAL_SERVER_ERROR); - servletResponse.getOutputStream().write(ExceptionUtils.getStackTrace(t).getBytes()); + try { + servletResponse.reset(); + servletResponse.setContentType(ContentType.TEXT_PLAIN.toString()); + servletResponse.setStatus(HttpStatus.SC_INTERNAL_SERVER_ERROR); + servletResponse.getOutputStream().write(ExceptionUtils.getStackTrace(t).getBytes()); + } catch (IllegalStateException ise) { + logger.debug("Could not reset already-committed response after static resource error", ise); + } + responded = true; } finally { Thread.currentThread().setName(originalThreadName); } - baseRequest.setHandled(true); + if (responded) { + baseRequest.setHandled(true); + } } } diff --git a/server/src/com/mirth/connect/server/MirthWebServer.java b/server/src/com/mirth/connect/server/MirthWebServer.java index fc4681715..0e655b98f 100644 --- a/server/src/com/mirth/connect/server/MirthWebServer.java +++ b/server/src/com/mirth/connect/server/MirthWebServer.java @@ -528,50 +528,7 @@ private ServletContextHandler createApiServletContextHandler(String contextPath, // Swagger UI filter - must be BEFORE RequestedWithFilter so that static file requests // (index.html, css, js) don't require the X-Requested-With header final String swaggerUiBase = ControllerFactory.getFactory().createConfigurationController().getBaseDir() + File.separator + "public_api_html"; - apiServletContextHandler.addFilter(new FilterHolder(new javax.servlet.Filter() { - @Override - public void init(javax.servlet.FilterConfig filterConfig) throws javax.servlet.ServletException {} - @Override - public void destroy() {} - @Override - public void doFilter(javax.servlet.ServletRequest request, javax.servlet.ServletResponse response, javax.servlet.FilterChain chain) throws java.io.IOException, javax.servlet.ServletException { - javax.servlet.http.HttpServletRequest httpRequest = (javax.servlet.http.HttpServletRequest) request; - javax.servlet.http.HttpServletResponse httpResponse = (javax.servlet.http.HttpServletResponse) response; - String pathInfo = httpRequest.getPathInfo(); - - // Serve index.html for root /api/ request - if (pathInfo == null || pathInfo.equals("/") || pathInfo.isEmpty()) { - java.io.File indexFile = new java.io.File(swaggerUiBase, "index.html"); - if (indexFile.exists()) { - httpResponse.setContentType("text/html; charset=UTF-8"); - httpResponse.setStatus(javax.servlet.http.HttpServletResponse.SC_OK); - java.nio.file.Files.copy(indexFile.toPath(), httpResponse.getOutputStream()); - return; - } - } - - // Serve static files from public_api_html if they exist (css, js, images, etc.) - if (pathInfo != null && !pathInfo.equals("/")) { - java.io.File staticFile = new java.io.File(swaggerUiBase, pathInfo); - if (staticFile.exists() && staticFile.isFile() && staticFile.getCanonicalPath().startsWith(new java.io.File(swaggerUiBase).getCanonicalPath())) { - String fileName = staticFile.getName(); - if (fileName.endsWith(".css")) httpResponse.setContentType("text/css"); - else if (fileName.endsWith(".js")) httpResponse.setContentType("application/javascript"); - else if (fileName.endsWith(".html")) httpResponse.setContentType("text/html"); - else if (fileName.endsWith(".png")) httpResponse.setContentType("image/png"); - else if (fileName.endsWith(".json")) httpResponse.setContentType("application/json"); - else if (fileName.endsWith(".map")) httpResponse.setContentType("application/json"); - else httpResponse.setContentType("application/octet-stream"); - httpResponse.setStatus(javax.servlet.http.HttpServletResponse.SC_OK); - java.nio.file.Files.copy(staticFile.toPath(), httpResponse.getOutputStream()); - return; - } - } - - // Not a static file - pass through to remaining filters and Jersey - chain.doFilter(request, response); - } - }), "/*", EnumSet.of(DispatcherType.REQUEST)); + apiServletContextHandler.addFilter(new FilterHolder(new SwaggerUiFilter(swaggerUiBase)), "/*", EnumSet.of(DispatcherType.REQUEST)); apiServletContextHandler.addFilter(new FilterHolder(new RequestedWithFilter(mirthProperties)), "/*", EnumSet.of(DispatcherType.REQUEST)); apiServletContextHandler.addFilter(new FilterHolder(new MethodFilter()), "/*", EnumSet.of(DispatcherType.REQUEST)); @@ -935,14 +892,20 @@ public void handle(String target, Request baseRequest, HttpServletRequest reques OutputStream responseOutputStream = response.getOutputStream(); // If the client accepts GZIP compression, compress the content + boolean gzip = false; for (Enumeration en = request.getHeaders("Accept-Encoding"); en.hasMoreElements();) { if (StringUtils.contains(en.nextElement(), "gzip")) { response.setHeader(HTTP.CONTENT_ENCODING, "gzip"); responseOutputStream = new GZIPOutputStream(responseOutputStream); + gzip = true; break; } } + if (!gzip) { + response.setContentLengthLong(file.length()); + } + fis = new FileInputStream(file); IOUtils.copy(fis, responseOutputStream); @@ -951,9 +914,14 @@ public void handle(String target, Request baseRequest, HttpServletRequest reques ((GZIPOutputStream) responseOutputStream).finish(); } } catch (Throwable t) { + try { + response.reset(); + response.setStatus(HttpStatus.SC_INTERNAL_SERVER_ERROR); + } catch (IllegalStateException ise) { + logger.debug("Response already committed", ise); + } + } finally { IOUtils.closeQuietly(fis); - response.reset(); - response.setStatus(HttpStatus.SC_INTERNAL_SERVER_ERROR); } } } else { @@ -963,4 +931,63 @@ public void handle(String target, Request baseRequest, HttpServletRequest reques baseRequest.setHandled(true); } } + + public static class SwaggerUiFilter implements javax.servlet.Filter { + private final String swaggerUiBase; + + public SwaggerUiFilter(String swaggerUiBase) { + this.swaggerUiBase = swaggerUiBase; + } + + @Override + public void init(javax.servlet.FilterConfig filterConfig) throws javax.servlet.ServletException {} + + @Override + public void destroy() {} + + @Override + public void doFilter(javax.servlet.ServletRequest request, javax.servlet.ServletResponse response, javax.servlet.FilterChain chain) throws java.io.IOException, javax.servlet.ServletException { + javax.servlet.http.HttpServletRequest httpRequest = (javax.servlet.http.HttpServletRequest) request; + javax.servlet.http.HttpServletResponse httpResponse = (javax.servlet.http.HttpServletResponse) response; + String pathInfo = httpRequest.getPathInfo(); + + // Serve index.html for root /api/ request + if (pathInfo == null || pathInfo.equals("/") || pathInfo.isEmpty()) { + java.io.File indexFile = new java.io.File(swaggerUiBase, "index.html"); + if (indexFile.exists()) { + httpResponse.setContentType("text/html; charset=UTF-8"); + httpResponse.setStatus(javax.servlet.http.HttpServletResponse.SC_OK); + httpResponse.setContentLengthLong(java.nio.file.Files.size(indexFile.toPath())); + try (java.io.OutputStream out = httpResponse.getOutputStream()) { + java.nio.file.Files.copy(indexFile.toPath(), out); + } + return; + } + } + + // Serve static files from public_api_html if they exist (css, js, images, etc.) + if (pathInfo != null && !pathInfo.equals("/")) { + java.io.File staticFile = new java.io.File(swaggerUiBase, pathInfo); + if (staticFile.exists() && staticFile.isFile() && staticFile.getCanonicalPath().startsWith(new java.io.File(swaggerUiBase).getCanonicalPath())) { + String fileName = staticFile.getName(); + if (fileName.endsWith(".css")) httpResponse.setContentType("text/css"); + else if (fileName.endsWith(".js")) httpResponse.setContentType("application/javascript"); + else if (fileName.endsWith(".html")) httpResponse.setContentType("text/html"); + else if (fileName.endsWith(".png")) httpResponse.setContentType("image/png"); + else if (fileName.endsWith(".json")) httpResponse.setContentType("application/json"); + else if (fileName.endsWith(".map")) httpResponse.setContentType("application/json"); + else httpResponse.setContentType("application/octet-stream"); + httpResponse.setStatus(javax.servlet.http.HttpServletResponse.SC_OK); + httpResponse.setContentLengthLong(java.nio.file.Files.size(staticFile.toPath())); + try (java.io.OutputStream out = httpResponse.getOutputStream()) { + java.nio.file.Files.copy(staticFile.toPath(), out); + } + return; + } + } + + // Not a static file - pass through to remaining filters and Jersey + chain.doFilter(request, response); + } + } } diff --git a/server/src/com/mirth/connect/server/servlets/WebStartServlet.java b/server/src/com/mirth/connect/server/servlets/WebStartServlet.java index 0aa6dfceb..76e52f46f 100644 --- a/server/src/com/mirth/connect/server/servlets/WebStartServlet.java +++ b/server/src/com/mirth/connect/server/servlets/WebStartServlet.java @@ -24,6 +24,8 @@ import java.io.IOException; import java.io.InputStream; import java.io.PrintWriter; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.ArrayList; import java.util.Base64; @@ -80,28 +82,32 @@ public void doGet(HttpServletRequest request, HttpServletResponse response) thro response.setCharacterEncoding("UTF-8"); try { - response.setContentType("application/x-java-jnlp-file"); response.setHeader("Pragma", "no-cache"); response.setHeader("X-Content-Type-Options", "nosniff"); - PrintWriter out = response.getWriter(); Document jnlpDocument = null; - + PropertiesConfiguration mirthProperties = getMirthProperties(); String contextPathProp = getContextPathProp(mirthProperties); - + if ((request.getRequestURI().equals(contextPathProp + "/webstart.jnlp") || request.getRequestURI().equals(contextPathProp + "/webstart")) && isWebstartRequestValid(request)) { jnlpDocument = getAdministratorJnlp(request); response.setHeader("Content-Disposition", "attachment; filename = \"webstart.jnlp\""); } else if (request.getServletPath().equals("/webstart/extensions") && isWebstartExtensionsRequestValid(request, contextPathProp)) { String extensionPath = getExtensionPath(request); - jnlpDocument = getExtensionJnlp(getExtensionPath(request)); - response.setHeader("Content-Disposition", "attachment; filename = \"" + extensionPath + ".jnlp\""); + jnlpDocument = getExtensionJnlp(extensionPath); + response.setHeader("Content-Disposition", "attachment; filename = \"" + extensionPath + ".jnlp\""); } else { - response.setContentType(""); + response.sendError(HttpServletResponse.SC_NOT_FOUND); + return; } + response.setContentType("application/x-java-jnlp-file"); DocumentSerializer docSerializer = new DocumentSerializer(true); - docSerializer.toXML(jnlpDocument, out); + StringWriter sw = new StringWriter(); + docSerializer.toXML(jnlpDocument, new PrintWriter(sw)); + byte[] bytes = sw.toString().getBytes(StandardCharsets.UTF_8); + response.setContentLength(bytes.length); + response.getOutputStream().write(bytes); } catch (RuntimeIOException rio) { logger.debug(rio); } catch (Throwable t) { diff --git a/server/test/com/mirth/commons/encryption/test/KeyEncryptorTest.java b/server/test/com/mirth/commons/encryption/test/KeyEncryptorTest.java index a2d0f1276..ed1eca481 100644 --- a/server/test/com/mirth/commons/encryption/test/KeyEncryptorTest.java +++ b/server/test/com/mirth/commons/encryption/test/KeyEncryptorTest.java @@ -35,7 +35,6 @@ import com.mirth.commons.encryption.Output; import com.mirth.commons.encryption.util.EncryptionUtil; import com.mirth.connect.model.EncryptionSettings; -import com.sun.crypto.provider.SunJCE; public class KeyEncryptorTest { @@ -65,7 +64,7 @@ public void testAESCBC128SunJCE() throws Exception { EncryptionSettings encryptionSettings = new EncryptionSettings(); encryptionSettings.setEncryptionAlgorithm("AES/CBC/PKCS5Padding"); encryptionSettings.setEncryptionKeyLength(128); - encryptionSettings.setSecurityProvider(SunJCE.class.getName()); + encryptionSettings.setSecurityProvider("com.sun.crypto.provider.SunJCE"); encryptionSettings.setEncryptionCharset(StandardCharsets.UTF_8.name()); testEncryptAndDecrypt(encryptionSettings); } @@ -76,7 +75,7 @@ public void testAESCBC128SunJCE() throws Exception { // EncryptionSettings encryptionSettings = new EncryptionSettings(); // encryptionSettings.setEncryptionAlgorithm("AES/CBC/PKCS5Padding"); // encryptionSettings.setEncryptionKeyLength(256); -// encryptionSettings.setSecurityProvider(SunJCE.class.getName()); +// encryptionSettings.setSecurityProvider("com.sun.crypto.provider.SunJCE"); // encryptionSettings.setEncryptionCharset(StandardCharsets.UTF_8.name()); // testEncryptAndDecrypt(encryptionSettings); // } @@ -161,7 +160,7 @@ public void testAESCBC128SunJCE_AESGCM128BC() throws Exception { EncryptionSettings oldEncryptionSettings = new EncryptionSettings(); oldEncryptionSettings.setEncryptionAlgorithm("AES/CBC/PKCS5Padding"); oldEncryptionSettings.setEncryptionKeyLength(128); - oldEncryptionSettings.setSecurityProvider(SunJCE.class.getName()); + oldEncryptionSettings.setSecurityProvider("com.sun.crypto.provider.SunJCE"); oldEncryptionSettings.setEncryptionCharset(StandardCharsets.UTF_8.name()); EncryptionSettings encryptionSettings = new EncryptionSettings(); @@ -225,7 +224,7 @@ public void testDESCBC56SunJCE() throws Exception { EncryptionSettings encryptionSettings = new EncryptionSettings(); encryptionSettings.setEncryptionAlgorithm("DES/CBC/PKCS5Padding"); encryptionSettings.setEncryptionKeyLength(56); - encryptionSettings.setSecurityProvider(SunJCE.class.getName()); + encryptionSettings.setSecurityProvider("com.sun.crypto.provider.SunJCE"); encryptionSettings.setEncryptionCharset(StandardCharsets.UTF_8.name()); testEncryptAndDecrypt(encryptionSettings); } diff --git a/server/test/com/mirth/connect/connectors/http/HttpReceiverStaticResourceIntegrationTest.java b/server/test/com/mirth/connect/connectors/http/HttpReceiverStaticResourceIntegrationTest.java new file mode 100644 index 000000000..8c0d6eb17 --- /dev/null +++ b/server/test/com/mirth/connect/connectors/http/HttpReceiverStaticResourceIntegrationTest.java @@ -0,0 +1,635 @@ +package com.mirth.connect.connectors.http; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Random; +import java.util.zip.GZIPInputStream; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.apache.commons.io.IOUtils; +import org.apache.http.Header; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.eclipse.jetty.ee8.nested.AbstractHandler; +import org.eclipse.jetty.ee8.nested.ContextHandler; +import org.eclipse.jetty.ee8.nested.HandlerCollection; +import org.eclipse.jetty.ee8.nested.Request; +import org.eclipse.jetty.server.HttpConnectionFactory; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.junit.After; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import com.google.inject.AbstractModule; +import com.google.inject.Guice; + +import com.mirth.connect.connectors.http.HttpStaticResource.ResourceType; +import com.mirth.connect.donkey.server.channel.Channel; +import com.mirth.connect.server.controllers.ConfigurationController; +import com.mirth.connect.server.controllers.ControllerFactory; +import com.mirth.connect.server.controllers.EventController; + +/** + * Integration tests for IRT-828: verifies that Content-Length is correctly set on the wire for + * large static resources served by {@link HttpReceiver}, using a real Jetty 12 server on loopback. + * + *

These tests close the coverage gap left by {@link HttpReceiverStaticResourceTest}: the unit + * tests verify the fix is present (Content-Length set in the mock response), but cannot + * prove the fix resolves the runtime behavior — specifically that Jetty 12 does not prematurely + * commit the response when Content-Length is set. Running against a real Jetty 12 + HTTP/1.1 + * stack proves the full write path behaves correctly for large (100 KB) resources. + * + *

HTTP/1.1 is sufficient: the {@code ERR_HTTP2_PROTOCOL_ERROR} the browser reports is a + * symptom of Jetty sending a malformed HTTP/2 stream when Content-Length is absent. Once + * Content-Length is set correctly on HTTP/1.1, the HTTP/2 path is also fixed because Jetty uses + * Content-Length to correctly signal end-of-stream in both protocols. + * + *

Verified against Jetty 12.0.x. + */ +public class HttpReceiverStaticResourceIntegrationTest { + + private static final String LOOPBACK = "127.0.0.1"; + private static final int LARGE_SIZE = 100 * 1024; // 100 KB — well above the ~30 KB failure threshold + + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + + private HttpReceiver receiver; + private Server jettyServer; + private ServerConnector serverConnector; + + // ------------------------------------------------------------------------- + // Class-level setup — Guice / Mockito + // ------------------------------------------------------------------------- + + @BeforeClass + public static void setUpClass() { + ControllerFactory controllerFactory = mock(ControllerFactory.class); + ConfigurationController configurationController = mock(ConfigurationController.class); + when(controllerFactory.createConfigurationController()).thenReturn(configurationController); + EventController eventController = mock(EventController.class); + when(controllerFactory.createEventController()).thenReturn(eventController); + Guice.createInjector(new AbstractModule() { + @Override + protected void configure() { + requestStaticInjection(ControllerFactory.class); + bind(ControllerFactory.class).toInstance(controllerFactory); + } + }).getInstance(ControllerFactory.class); + } + + // ------------------------------------------------------------------------- + // Per-test setup / teardown + // ------------------------------------------------------------------------- + + @Before + public void setUp() throws Exception { + receiver = new HttpReceiver(); + HttpReceiverProperties props = new HttpReceiverProperties(); + props.setCharset("UTF-8"); + + Channel channel = mock(Channel.class); + doReturn("test-channel-id").when(channel).getChannelId(); + doReturn("Test Channel").when(channel).getName(); + receiver.setChannel(channel); + receiver.setConnectorProperties(props); + + Field arrayField = HttpReceiver.class.getDeclaredField("binaryMimeTypesArray"); + arrayField.setAccessible(true); + arrayField.set(receiver, new String[0]); + + Field regexField = HttpReceiver.class.getDeclaredField("binaryMimeTypesRegex"); + regexField.setAccessible(true); + regexField.set(receiver, java.util.regex.Pattern.compile("$^")); + + HttpConfiguration httpConfig = mock(HttpConfiguration.class); + when(httpConfig.getRequestInformation(any())).thenReturn(new HashMap<>()); + Field configField = HttpReceiver.class.getDeclaredField("configuration"); + configField.setAccessible(true); + configField.set(receiver, httpConfig); + } + + @After + public void tearDown() throws Exception { + if (jettyServer != null && jettyServer.isRunning()) { + jettyServer.stop(); + jettyServer = null; + } + } + + // ========================================================================= + // GROUP A — ResourceType.CUSTOM (inline string value) + // ========================================================================= + + @Test + public void testCustom_large_contentLengthPresentOnWire() throws Exception { + String content = repeat('A', LARGE_SIZE); + long expectedLen = content.getBytes(StandardCharsets.UTF_8).length; + int port = startServer(new HttpStaticResource("/resource", ResourceType.CUSTOM, content, "text/plain", Collections.emptyMap())); + + try (CloseableHttpClient client = noCompressionClient(); + CloseableHttpResponse resp = client.execute(get(port, "/resource"))) { + assertEquals(200, resp.getStatusLine().getStatusCode()); + Header h = resp.getFirstHeader("Content-Length"); + assertNotNull("Content-Length must be present for large CUSTOM resource", h); + assertEquals(expectedLen, Long.parseLong(h.getValue())); + } + } + + @Test + public void testCustom_large_bodyIntegrity() throws Exception { + String content = repeat('A', LARGE_SIZE); + int port = startServer(new HttpStaticResource("/resource", ResourceType.CUSTOM, content, "text/plain", Collections.emptyMap())); + + try (CloseableHttpClient client = noCompressionClient(); + CloseableHttpResponse resp = client.execute(get(port, "/resource"))) { + byte[] body = IOUtils.toByteArray(resp.getEntity().getContent()); + assertArrayEquals(content.getBytes(StandardCharsets.UTF_8), body); + } + } + + @Test + public void testCustom_gzip_contentEncodingHeaderPresent() throws Exception { + String content = repeat('A', LARGE_SIZE); + int port = startServer(new HttpStaticResource("/resource", ResourceType.CUSTOM, content, "text/plain", Collections.emptyMap())); + + try (CloseableHttpClient client = noCompressionClient(); + CloseableHttpResponse resp = client.execute(getGzip(port, "/resource"))) { + assertEquals(200, resp.getStatusLine().getStatusCode()); + Header h = resp.getFirstHeader("Content-Encoding"); + assertNotNull("Content-Encoding header must be present for gzip CUSTOM response", h); + assertTrue("Content-Encoding must be gzip", h.getValue().contains("gzip")); + } + } + + @Test + public void testCustom_gzip_bodyDecompressesToOriginalContent() throws Exception { + String content = repeat('A', LARGE_SIZE); + int port = startServer(new HttpStaticResource("/resource", ResourceType.CUSTOM, content, "text/plain", Collections.emptyMap())); + + try (CloseableHttpClient client = noCompressionClient(); + CloseableHttpResponse resp = client.execute(getGzip(port, "/resource"))) { + byte[] compressed = IOUtils.toByteArray(resp.getEntity().getContent()); + String decompressed; + try (GZIPInputStream gzis = new GZIPInputStream(new ByteArrayInputStream(compressed))) { + decompressed = new String(gzis.readAllBytes(), StandardCharsets.UTF_8); + } + assertEquals(content, decompressed); + } + } + + // ========================================================================= + // GROUP B — ResourceType.FILE + // ========================================================================= + + @Test + public void testFile_large_contentLengthPresentOnWire() throws Exception { + byte[] content = randomBytes(LARGE_SIZE); + File f = writeTempFile("large.bin", content); + int port = startServer(new HttpStaticResource("/resource", ResourceType.FILE, f.getAbsolutePath(), "application/octet-stream", Collections.emptyMap())); + + try (CloseableHttpClient client = noCompressionClient(); + CloseableHttpResponse resp = client.execute(get(port, "/resource"))) { + assertEquals(200, resp.getStatusLine().getStatusCode()); + Header h = resp.getFirstHeader("Content-Length"); + assertNotNull("Content-Length must be present for large FILE resource", h); + assertEquals(content.length, Long.parseLong(h.getValue())); + } + } + + @Test + public void testFile_large_bodyIntegrity() throws Exception { + byte[] content = randomBytes(LARGE_SIZE); + File f = writeTempFile("large-body.bin", content); + int port = startServer(new HttpStaticResource("/resource", ResourceType.FILE, f.getAbsolutePath(), "application/octet-stream", Collections.emptyMap())); + + try (CloseableHttpClient client = noCompressionClient(); + CloseableHttpResponse resp = client.execute(get(port, "/resource"))) { + byte[] body = IOUtils.toByteArray(resp.getEntity().getContent()); + assertArrayEquals(content, body); + } + } + + @Test + public void testFile_gzip_contentEncodingHeaderPresent() throws Exception { + byte[] content = new byte[LARGE_SIZE]; + Arrays.fill(content, (byte) 'Z'); + File f = writeTempFile("gzip-check.bin", content); + int port = startServer(new HttpStaticResource("/resource", ResourceType.FILE, f.getAbsolutePath(), "application/octet-stream", Collections.emptyMap())); + + try (CloseableHttpClient client = noCompressionClient(); + CloseableHttpResponse resp = client.execute(getGzip(port, "/resource"))) { + assertEquals(200, resp.getStatusLine().getStatusCode()); + Header h = resp.getFirstHeader("Content-Encoding"); + assertNotNull("Content-Encoding header must be present for gzip FILE response", h); + assertTrue("Content-Encoding must be gzip", h.getValue().contains("gzip")); + } + } + + @Test + public void testFile_gzip_bodyDecompressesToOriginalContent() throws Exception { + byte[] content = new byte[LARGE_SIZE]; + Arrays.fill(content, (byte) 'Z'); + File f = writeTempFile("gzip-body.bin", content); + int port = startServer(new HttpStaticResource("/resource", ResourceType.FILE, f.getAbsolutePath(), "application/octet-stream", Collections.emptyMap())); + + try (CloseableHttpClient client = noCompressionClient(); + CloseableHttpResponse resp = client.execute(getGzip(port, "/resource"))) { + byte[] compressed = IOUtils.toByteArray(resp.getEntity().getContent()); + byte[] decompressed; + try (GZIPInputStream gzis = new GZIPInputStream(new ByteArrayInputStream(compressed))) { + decompressed = gzis.readAllBytes(); + } + assertArrayEquals(content, decompressed); + } + } + + // ========================================================================= + // GROUP C — Routing: requests outside context path must return 404 (IRT-831) + // ========================================================================= + + @Test + public void testRequestOutsideResourceContextPathReturns404() throws Exception { + String content = "hello"; + int port = startServer(new HttpStaticResource("/resource", ResourceType.CUSTOM, content, + "text/plain", Collections.emptyMap())); + + try (CloseableHttpClient client = noCompressionClient(); + CloseableHttpResponse resp = client.execute(get(port, "/other"))) { + assertEquals(404, resp.getStatusLine().getStatusCode()); + } + } + + // ========================================================================= + // GROUP D — ResourceType.DIRECTORY fallthrough to channel handler (IRT-831 fix) + // Verifies that StaticResourceHandler does NOT mark the request as handled when + // a requested file is absent, allowing the channel context to process it. + // ========================================================================= + + @Test + public void testDirectory_missingFile_fallsThroughToChannelHandler() throws Exception { + File dir = tempFolder.newFolder("dir-missing"); + int port = startServerWithDirectoryAndChannel("/test/data", dir, "/test"); + + try (CloseableHttpClient client = noCompressionClient(); + CloseableHttpResponse resp = client.execute(get(port, "/test/data/nonexistent.txt"))) { + assertEquals("Missing file under DIRECTORY resource must fall through to channel handler", + 200, resp.getStatusLine().getStatusCode()); + assertEquals("channel", + IOUtils.toString(resp.getEntity().getContent(), StandardCharsets.UTF_8)); + } + } + + @Test + public void testDirectory_existingFile_isServedByStaticHandler() throws Exception { + File dir = tempFolder.newFolder("dir-serve"); + File file = new File(dir, "hello.txt"); + try (FileOutputStream fos = new FileOutputStream(file)) { + fos.write("static-content".getBytes(StandardCharsets.UTF_8)); + } + int port = startServerWithDirectoryAndChannel("/test/data", dir, "/test"); + + try (CloseableHttpClient client = noCompressionClient(); + CloseableHttpResponse resp = client.execute(get(port, "/test/data/hello.txt"))) { + assertEquals(200, resp.getStatusLine().getStatusCode()); + assertEquals("static-content", + IOUtils.toString(resp.getEntity().getContent(), StandardCharsets.UTF_8)); + } + } + + @Test + public void testDirectory_subdirectoryInPath_fallsThroughToChannelHandler() throws Exception { + File dir = tempFolder.newFolder("dir-subpath"); + int port = startServerWithDirectoryAndChannel("/test/data", dir, "/test"); + + try (CloseableHttpClient client = noCompressionClient(); + CloseableHttpResponse resp = client.execute(get(port, "/test/data/sub/file.txt"))) { + assertEquals("Subdirectory path must fall through to channel handler", + 200, resp.getStatusLine().getStatusCode()); + assertEquals("channel", + IOUtils.toString(resp.getEntity().getContent(), StandardCharsets.UTF_8)); + } + } + + // ========================================================================= + // GROUP E — CUSTOM and FILE fallthrough to channel handler (IRT-831, commit a200466b0) + // Before this fix, only DIRECTORY resources were embedded inside the channel + // ContextHandler. CUSTOM and FILE still had their own ContextHandler, so + // CoreContextHandler returned true on any prefix match — blocking fallthrough + // for sub-paths the StaticResourceHandler could not serve. + // ========================================================================= + + @Test + public void testCustom_subPath_fallsThroughToChannelHandler() throws Exception { + String content = "custom-content"; + int port = startServerWithResourceAndChannel( + "/test/data", ResourceType.CUSTOM, content, "/test"); + + try (CloseableHttpClient client = noCompressionClient(); + CloseableHttpResponse resp = client.execute(get(port, "/test/data/wrong"))) { + assertEquals( + "Sub-path miss on CUSTOM resource must fall through to channel handler", + 200, resp.getStatusLine().getStatusCode()); + assertEquals("channel", + IOUtils.toString(resp.getEntity().getContent(), StandardCharsets.UTF_8)); + } + } + + @Test + public void testFile_subPath_fallsThroughToChannelHandler() throws Exception { + byte[] content = "file-content".getBytes(StandardCharsets.UTF_8); + File f = writeTempFile("serve.bin", content); + int port = startServerWithResourceAndChannel( + "/test/data", ResourceType.FILE, f.getAbsolutePath(), "/test"); + + try (CloseableHttpClient client = noCompressionClient(); + CloseableHttpResponse resp = client.execute(get(port, "/test/data/wrong"))) { + assertEquals( + "Sub-path miss on FILE resource must fall through to channel handler", + 200, resp.getStatusLine().getStatusCode()); + assertEquals("channel", + IOUtils.toString(resp.getEntity().getContent(), StandardCharsets.UTF_8)); + } + } + + // ========================================================================= + // Helpers — Jetty server setup + // ========================================================================= + + /** + * Starts a real Jetty 12 server on a random loopback port using the IRT-831 handler chain + * ({@code ContextHandlerCollection} + {@code DefaultHandler} + {@code Handler.Sequence}) + * with {@code StaticResourceHandler} for a single static resource. + * Uses reflection to instantiate the private inner class. + * + * @return the local port the server is listening on + */ + private int startServer(HttpStaticResource resource) throws Exception { + jettyServer = new Server(); + + org.eclipse.jetty.server.HttpConfiguration httpCfg = new org.eclipse.jetty.server.HttpConfiguration(); + httpCfg.setSendServerVersion(false); + serverConnector = new ServerConnector(jettyServer, new HttpConnectionFactory(httpCfg)); + serverConnector.setHost(LOOPBACK); + serverConnector.setPort(0); // random free port + serverConnector.setIdleTimeout(30_000); + jettyServer.addConnector(serverConnector); + + // Reflectively instantiate the private StaticResourceHandler inner class + Class handlerClass = null; + for (Class c : HttpReceiver.class.getDeclaredClasses()) { + if ("StaticResourceHandler".equals(c.getSimpleName())) { + handlerClass = c; + break; + } + } + assertNotNull("StaticResourceHandler inner class not found in HttpReceiver", handlerClass); + Constructor ctor = handlerClass.getDeclaredConstructor(HttpReceiver.class, HttpStaticResource.class); + ctor.setAccessible(true); + AbstractHandler staticHandler = (AbstractHandler) ctor.newInstance(receiver, resource); + + // Replicate the EE8 handler chain from HttpReceiver.onStart() + ContextHandler resourceContext = new ContextHandler(); + resourceContext.setContextPath(resource.getContextPath()); + resourceContext.setAllowNullPathInfo(true); + resourceContext.setHandler(staticHandler); + + org.eclipse.jetty.server.handler.ContextHandlerCollection coreHandlers = + new org.eclipse.jetty.server.handler.ContextHandlerCollection(); + resourceContext.setServer(jettyServer); + coreHandlers.addHandler(resourceContext.getCoreContextHandler()); + + org.eclipse.jetty.server.handler.DefaultHandler defaultHandler = + new org.eclipse.jetty.server.handler.DefaultHandler(); + defaultHandler.setServeFavIcon(false); + + jettyServer.setHandler(new org.eclipse.jetty.server.Handler.Sequence(coreHandlers, defaultHandler)); + + jettyServer.start(); + return serverConnector.getLocalPort(); + } + + /** + * Starts a server with a single static resource handler (any {@link ResourceType}) and a + * {@link ChannelEchoHandler} embedded inside one channel {@link ContextHandler}, replicating + * the architecture from commit a200466b0. Used by GROUP E tests. + */ + private int startServerWithResourceAndChannel( + String resourceContextPath, ResourceType resourceType, + String resourceValue, String channelContextPath) throws Exception { + jettyServer = new Server(); + org.eclipse.jetty.server.HttpConfiguration httpCfg = + new org.eclipse.jetty.server.HttpConfiguration(); + httpCfg.setSendServerVersion(false); + serverConnector = new ServerConnector(jettyServer, new HttpConnectionFactory(httpCfg)); + serverConnector.setHost(LOOPBACK); + serverConnector.setPort(0); + serverConnector.setIdleTimeout(30_000); + jettyServer.addConnector(serverConnector); + + Class handlerClass = null; + for (Class c : HttpReceiver.class.getDeclaredClasses()) { + if ("StaticResourceHandler".equals(c.getSimpleName())) { + handlerClass = c; + break; + } + } + assertNotNull("StaticResourceHandler inner class not found in HttpReceiver", handlerClass); + Constructor ctor = handlerClass.getDeclaredConstructor( + HttpReceiver.class, HttpStaticResource.class); + ctor.setAccessible(true); + HttpStaticResource resource = new HttpStaticResource( + resourceContextPath, resourceType, + resourceValue, "text/plain", Collections.emptyMap()); + AbstractHandler staticHandler = (AbstractHandler) ctor.newInstance(receiver, resource); + + HandlerCollection innerChain = new HandlerCollection() { + @Override + public void handle(String target, Request baseRequest, + HttpServletRequest request, HttpServletResponse response) + throws IOException, ServletException { + for (org.eclipse.jetty.ee8.nested.Handler h : getHandlers()) { + h.handle(target, baseRequest, request, response); + if (baseRequest.isHandled()) { + return; + } + } + } + }; + innerChain.addHandler(staticHandler); + innerChain.addHandler(new ChannelEchoHandler()); + + ContextHandler channelCtx = new ContextHandler(); + channelCtx.setContextPath(channelContextPath); + channelCtx.setAllowNullPathInfo(true); + channelCtx.setHandler(innerChain); + channelCtx.setServer(jettyServer); + + org.eclipse.jetty.server.handler.DefaultHandler defaultHandler = + new org.eclipse.jetty.server.handler.DefaultHandler(); + defaultHandler.setServeFavIcon(false); + + jettyServer.setHandler(new org.eclipse.jetty.server.Handler.Sequence( + channelCtx.getCoreContextHandler(), defaultHandler)); + + jettyServer.start(); + return serverConnector.getLocalPort(); + } + + // ========================================================================= + // Helpers — HTTP client + // ========================================================================= + + /** + * Returns a client with content compression disabled — no automatic {@code Accept-Encoding} + * header is added and gzip responses are NOT auto-decompressed. Required so that tests can + * inspect the raw {@code Content-Length} and {@code Content-Encoding} response headers. + */ + private static CloseableHttpClient noCompressionClient() { + return HttpClients.custom().disableContentCompression().build(); + } + + private static HttpGet get(int port, String path) { + return new HttpGet("http://" + LOOPBACK + ":" + port + path); + } + + private static HttpGet getGzip(int port, String path) { + HttpGet request = new HttpGet("http://" + LOOPBACK + ":" + port + path); + request.setHeader("Accept-Encoding", "gzip"); + return request; + } + + // ========================================================================= + // Helpers — data generation and file I/O + // ========================================================================= + + private static byte[] randomBytes(int size) { + byte[] buf = new byte[size]; + new Random().nextBytes(buf); + return buf; + } + + private File writeTempFile(String name, byte[] content) throws Exception { + File f = tempFolder.newFile(name); + try (FileOutputStream fos = new FileOutputStream(f)) { + fos.write(content); + } + return f; + } + + private static String repeat(char c, int count) { + char[] buf = new char[count]; + Arrays.fill(buf, c); + return new String(buf); + } + + /** + * Starts a server with a DIRECTORY {@link StaticResourceHandler} at {@code resourceContextPath} + * and a simple channel echo handler at {@code channelContextPath}. Used by GROUP D tests to + * verify that unanswered requests fall through from the resource context to the channel. + */ + private int startServerWithDirectoryAndChannel( + String resourceContextPath, File dir, String channelContextPath) throws Exception { + jettyServer = new Server(); + org.eclipse.jetty.server.HttpConfiguration httpCfg = + new org.eclipse.jetty.server.HttpConfiguration(); + httpCfg.setSendServerVersion(false); + serverConnector = new ServerConnector(jettyServer, new HttpConnectionFactory(httpCfg)); + serverConnector.setHost(LOOPBACK); + serverConnector.setPort(0); + serverConnector.setIdleTimeout(30_000); + jettyServer.addConnector(serverConnector); + + Class handlerClass = null; + for (Class c : HttpReceiver.class.getDeclaredClasses()) { + if ("StaticResourceHandler".equals(c.getSimpleName())) { + handlerClass = c; + break; + } + } + assertNotNull("StaticResourceHandler inner class not found in HttpReceiver", handlerClass); + Constructor ctor = handlerClass.getDeclaredConstructor( + HttpReceiver.class, HttpStaticResource.class); + ctor.setAccessible(true); + HttpStaticResource resource = new HttpStaticResource( + resourceContextPath, ResourceType.DIRECTORY, + dir.getAbsolutePath(), "application/octet-stream", Collections.emptyMap()); + AbstractHandler staticHandler = (AbstractHandler) ctor.newInstance(receiver, resource); + + // Both handlers live inside ONE channel ContextHandler with a stop-on-first-handled + // HandlerCollection. Separate contexts don't work: the EE8-to-core bridge + // (CoreContextHandler) returns true once the context path matches, even when the + // inner EE8 handler never sets isHandled(). Subclassing HandlerCollection (rather + // than using an anonymous AbstractHandler) ensures Jetty's lifecycle calls + // (setServer, start) propagate through addHandler() to both inner handlers. + HandlerCollection innerChain = new HandlerCollection() { + @Override + public void handle(String target, Request baseRequest, + HttpServletRequest request, HttpServletResponse response) + throws IOException, ServletException { + for (org.eclipse.jetty.ee8.nested.Handler h : getHandlers()) { + h.handle(target, baseRequest, request, response); + if (baseRequest.isHandled()) { + return; + } + } + } + }; + innerChain.addHandler(staticHandler); + innerChain.addHandler(new ChannelEchoHandler()); + + ContextHandler channelCtx = new ContextHandler(); + channelCtx.setContextPath(channelContextPath); + channelCtx.setAllowNullPathInfo(true); + channelCtx.setHandler(innerChain); + channelCtx.setServer(jettyServer); + + org.eclipse.jetty.server.handler.DefaultHandler defaultHandler = + new org.eclipse.jetty.server.handler.DefaultHandler(); + defaultHandler.setServeFavIcon(false); + + jettyServer.setHandler(new org.eclipse.jetty.server.Handler.Sequence( + channelCtx.getCoreContextHandler(), + defaultHandler)); + + jettyServer.start(); + return serverConnector.getLocalPort(); + } + + private static class ChannelEchoHandler extends AbstractHandler { + @Override + public void handle(String target, Request baseRequest, + HttpServletRequest request, HttpServletResponse response) + throws IOException, ServletException { + baseRequest.setHandled(true); + response.setStatus(HttpServletResponse.SC_OK); + response.getWriter().write("channel"); + } + } +} diff --git a/server/test/com/mirth/connect/connectors/http/HttpReceiverStaticResourceTest.java b/server/test/com/mirth/connect/connectors/http/HttpReceiverStaticResourceTest.java new file mode 100644 index 000000000..54f174466 --- /dev/null +++ b/server/test/com/mirth/connect/connectors/http/HttpReceiverStaticResourceTest.java @@ -0,0 +1,521 @@ +package com.mirth.connect.connectors.http; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.HashMap; +import java.util.Random; +import java.util.zip.GZIPInputStream; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.eclipse.jetty.ee8.nested.Request; +import org.eclipse.jetty.http.HttpURI; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import com.google.inject.AbstractModule; +import com.google.inject.Guice; + +import com.mirth.connect.connectors.http.HttpStaticResource.ResourceType; +import com.mirth.connect.donkey.server.channel.Channel; +import com.mirth.connect.server.controllers.ConfigurationController; +import com.mirth.connect.server.controllers.ControllerFactory; +import com.mirth.connect.server.controllers.EventController; + +public class HttpReceiverStaticResourceTest { + + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + + private HttpReceiver receiver; + private HttpReceiverProperties props; + + @BeforeClass + public static void setupBeforeClass() { + ControllerFactory controllerFactory = mock(ControllerFactory.class); + ConfigurationController configurationController = mock(ConfigurationController.class); + when(controllerFactory.createConfigurationController()).thenReturn(configurationController); + EventController eventController = mock(EventController.class); + when(controllerFactory.createEventController()).thenReturn(eventController); + Guice.createInjector(new AbstractModule() { + @Override + protected void configure() { + requestStaticInjection(ControllerFactory.class); + bind(ControllerFactory.class).toInstance(controllerFactory); + } + }).getInstance(ControllerFactory.class); + } + + @Before + public void setUp() throws Exception { + receiver = new HttpReceiver(); + props = new HttpReceiverProperties(); + props.setCharset("UTF-8"); + + Channel channel = mock(Channel.class); + doReturn("test-channel-id").when(channel).getChannelId(); + receiver.setChannel(channel); + receiver.setConnectorProperties(props); + + // Required to avoid NPE in createRequestMessage's binary content type check + java.lang.reflect.Field arrayField = HttpReceiver.class.getDeclaredField("binaryMimeTypesArray"); + arrayField.setAccessible(true); + arrayField.set(receiver, new String[0]); + java.lang.reflect.Field regexField = HttpReceiver.class.getDeclaredField("binaryMimeTypesRegex"); + regexField.setAccessible(true); + regexField.set(receiver, java.util.regex.Pattern.compile("$^")); + + // configuration is null until onDeploy(); inject a minimal mock + HttpConfiguration httpConfig = mock(HttpConfiguration.class); + when(httpConfig.getRequestInformation(any())).thenReturn(new HashMap<>()); + java.lang.reflect.Field configField = HttpReceiver.class.getDeclaredField("configuration"); + configField.setAccessible(true); + configField.set(receiver, httpConfig); + } + + // ========================================================================= + // Infrastructure helpers + // ========================================================================= + + /** + * Reflectively invokes StaticResourceHandler.handle() without starting a real Jetty server. + * Unwraps InvocationTargetException so callers see the real exception if one escapes. + */ + private void invokeHandler(HttpStaticResource resource, Request baseRequest, + HttpServletResponse response) throws Exception { + Class cls = null; + for (Class c : HttpReceiver.class.getDeclaredClasses()) { + if ("StaticResourceHandler".equals(c.getSimpleName())) { + cls = c; + break; + } + } + assertNotNull("StaticResourceHandler inner class not found via reflection", cls); + + Constructor ctor = cls.getDeclaredConstructor(HttpReceiver.class, HttpStaticResource.class); + ctor.setAccessible(true); + Object handler = ctor.newInstance(receiver, resource); + + Method handle = cls.getMethod("handle", String.class, Request.class, + HttpServletRequest.class, HttpServletResponse.class); + try { + // target and servletRequest are unused inside the handler + handle.invoke(handler, null, baseRequest, null, response); + } catch (InvocationTargetException e) { + Throwable cause = e.getCause(); + if (cause instanceof RuntimeException) throw (RuntimeException) cause; + if (cause instanceof Exception) throw (Exception) cause; + throw new RuntimeException(cause); + } + } + + /** + * Builds a mocked Jetty Request for a GET at the given context path. + * Pass acceptGzip=true to include an Accept-Encoding: gzip header. + */ + private Request createGetRequest(String contextPath, boolean acceptGzip) { + Request req = mock(Request.class); + when(req.getMethod()).thenReturn("GET"); + when(req.getRemoteAddr()).thenReturn("127.0.0.1"); + when(req.getRemotePort()).thenReturn(12345); + when(req.getLocalAddr()).thenReturn("127.0.0.1"); + when(req.getLocalPort()).thenReturn(8080); + when(req.getQueryString()).thenReturn(""); + when(req.getRequestURL()).thenReturn(new StringBuffer("http://localhost" + contextPath)); + when(req.getContentType()).thenReturn(null); + when(req.getCharacterEncoding()).thenReturn("UTF-8"); + when(req.getParameterMap()).thenReturn(new HashMap<>()); + when(req.getProtocol()).thenReturn("HTTP/1.1"); + + HttpURI httpURI = mock(HttpURI.class); + when(httpURI.getPathQuery()).thenReturn(contextPath); + when(req.getHttpURI()).thenReturn(httpURI); + + if (acceptGzip) { + when(req.getHeaderNames()).thenReturn( + Collections.enumeration(Collections.singletonList("Accept-Encoding"))); + when(req.getHeaders("Accept-Encoding")).thenReturn( + Collections.enumeration(Collections.singletonList("gzip"))); + } else { + when(req.getHeaderNames()).thenReturn( + Collections.enumeration(Collections.emptyList())); + } + + return req; + } + + // ========================================================================= + // GROUP A — ResourceType.CUSTOM (inline string value) + // ========================================================================= + + @Test + public void testCustomResource_smallString_setsContentLength() throws Exception { + String value = "Hello, static world!"; + int expectedLen = value.getBytes(StandardCharsets.UTF_8).length; + + HttpStaticResource resource = new HttpStaticResource( + "/resource", ResourceType.CUSTOM, value, "text/plain", Collections.emptyMap()); + ContentLengthCapturingResponse response = new ContentLengthCapturingResponse(); + + invokeHandler(resource, createGetRequest("/resource", false), response); + + assertEquals(expectedLen, response.capturedContentLength); + } + + @Test + public void testCustomResource_largeString_setsContentLength() throws Exception { + // 40 KB — the regression size that triggered ERR_HTTP2_PROTOCOL_ERROR + String value = "A".repeat(40 * 1024); + int expectedLen = value.getBytes(StandardCharsets.UTF_8).length; + + HttpStaticResource resource = new HttpStaticResource( + "/resource", ResourceType.CUSTOM, value, "text/plain", Collections.emptyMap()); + ContentLengthCapturingResponse response = new ContentLengthCapturingResponse(); + + invokeHandler(resource, createGetRequest("/resource", false), response); + + assertEquals(expectedLen, response.capturedContentLength); + } + + @Test + public void testCustomResource_bodyBytesMatchContentLength() throws Exception { + String value = "Body content for length verification"; + HttpStaticResource resource = new HttpStaticResource( + "/resource", ResourceType.CUSTOM, value, "text/plain", Collections.emptyMap()); + ContentLengthCapturingResponse response = new ContentLengthCapturingResponse(); + + invokeHandler(resource, createGetRequest("/resource", false), response); + + assertEquals(response.capturedContentLength, response.outputStream.size()); + } + + @Test + public void testCustomResource_bodyContentIsCorrect() throws Exception { + String value = "Expected body content"; + HttpStaticResource resource = new HttpStaticResource( + "/resource", ResourceType.CUSTOM, value, "text/plain", Collections.emptyMap()); + ContentLengthCapturingResponse response = new ContentLengthCapturingResponse(); + + invokeHandler(resource, createGetRequest("/resource", false), response); + + assertEquals(value, response.outputStream.toString("UTF-8")); + } + + @Test + public void testCustomResource_gzip_contentLengthNotSet() throws Exception { + String value = "Gzipped static content"; + HttpStaticResource resource = new HttpStaticResource( + "/resource", ResourceType.CUSTOM, value, "text/plain", Collections.emptyMap()); + ContentLengthCapturingResponse response = new ContentLengthCapturingResponse(); + + invokeHandler(resource, createGetRequest("/resource", true), response); + + assertEquals(-1, response.capturedContentLength); + } + + @Test + public void testCustomResource_gzip_bodyIsValidGzip() throws Exception { + String value = "Gzipped static content"; + HttpStaticResource resource = new HttpStaticResource( + "/resource", ResourceType.CUSTOM, value, "text/plain", Collections.emptyMap()); + ContentLengthCapturingResponse response = new ContentLengthCapturingResponse(); + + invokeHandler(resource, createGetRequest("/resource", true), response); + + byte[] compressed = response.outputStream.toByteArray(); + String decompressed; + try (GZIPInputStream gzis = new GZIPInputStream(new ByteArrayInputStream(compressed))) { + decompressed = new String(gzis.readAllBytes(), StandardCharsets.UTF_8); + } + assertEquals(value, decompressed); + } + + @Test + public void testCustomResource_nonGetMethod_noBodyWritten() throws Exception { + HttpStaticResource resource = new HttpStaticResource( + "/resource", ResourceType.CUSTOM, "value", "text/plain", Collections.emptyMap()); + ContentLengthCapturingResponse response = new ContentLengthCapturingResponse(); + + Request postRequest = mock(Request.class); + when(postRequest.getMethod()).thenReturn("POST"); + + invokeHandler(resource, postRequest, response); + + assertEquals(0, response.outputStream.size()); + assertEquals(-1, response.capturedContentLength); + } + + @Test + public void testCustomResource_contentTypeIsSet() throws Exception { + HttpStaticResource resource = new HttpStaticResource( + "/resource", ResourceType.CUSTOM, "js content", "application/javascript", Collections.emptyMap()); + ContentLengthCapturingResponse response = new ContentLengthCapturingResponse(); + + invokeHandler(resource, createGetRequest("/resource", false), response); + + assertTrue(response.contentTypeValue.contains("application/javascript")); + } + + @Test + public void testCustomResource_invalidCharset_fallsBackToTextPlain() throws Exception { + // An unrecognized charset causes UnsupportedCharsetException in ContentType.parse(), + // which the handler catches and falls back to text/plain with the connector's default charset. + HttpStaticResource resource = new HttpStaticResource( + "/resource", ResourceType.CUSTOM, "value", + "text/html; charset=TOTALLY-INVALID-CHARSET-XYZ", Collections.emptyMap()); + ContentLengthCapturingResponse response = new ContentLengthCapturingResponse(); + + invokeHandler(resource, createGetRequest("/resource", false), response); + + assertTrue(response.contentTypeValue.contains("text/plain")); + } + + // ========================================================================= + // GROUP B — ResourceType.FILE + // ========================================================================= + + @Test + public void testFileResource_smallFile_setsContentLength() throws Exception { + File f = tempFolder.newFile("small.txt"); + byte[] content = "small file content".getBytes(StandardCharsets.UTF_8); + try (FileOutputStream fos = new FileOutputStream(f)) { fos.write(content); } + + HttpStaticResource resource = new HttpStaticResource( + "/resource", ResourceType.FILE, f.getAbsolutePath(), "text/plain", Collections.emptyMap()); + ContentLengthCapturingResponse response = new ContentLengthCapturingResponse(); + + invokeHandler(resource, createGetRequest("/resource", false), response); + + assertEquals(f.length(), response.capturedContentLength); + } + + @Test + public void testFileResource_largeFile_setsContentLength() throws Exception { + // 40 KB — the regression size + File f = tempFolder.newFile("large.bin"); + byte[] content = new byte[40 * 1024]; + new Random().nextBytes(content); + try (FileOutputStream fos = new FileOutputStream(f)) { fos.write(content); } + + HttpStaticResource resource = new HttpStaticResource( + "/resource", ResourceType.FILE, f.getAbsolutePath(), "application/octet-stream", Collections.emptyMap()); + ContentLengthCapturingResponse response = new ContentLengthCapturingResponse(); + + invokeHandler(resource, createGetRequest("/resource", false), response); + + assertEquals(f.length(), response.capturedContentLength); + } + + @Test + public void testFileResource_bodyMatchesFileContent() throws Exception { + File f = tempFolder.newFile("content.txt"); + byte[] content = "exact file bytes".getBytes(StandardCharsets.UTF_8); + try (FileOutputStream fos = new FileOutputStream(f)) { fos.write(content); } + + HttpStaticResource resource = new HttpStaticResource( + "/resource", ResourceType.FILE, f.getAbsolutePath(), "text/plain", Collections.emptyMap()); + ContentLengthCapturingResponse response = new ContentLengthCapturingResponse(); + + invokeHandler(resource, createGetRequest("/resource", false), response); + + assertArrayEquals(content, response.outputStream.toByteArray()); + } + + @Test + public void testFileResource_gzip_contentLengthNotSet() throws Exception { + File f = tempFolder.newFile("gzip.txt"); + try (FileOutputStream fos = new FileOutputStream(f)) { fos.write("gzip test".getBytes()); } + + HttpStaticResource resource = new HttpStaticResource( + "/resource", ResourceType.FILE, f.getAbsolutePath(), "text/plain", Collections.emptyMap()); + ContentLengthCapturingResponse response = new ContentLengthCapturingResponse(); + + invokeHandler(resource, createGetRequest("/resource", true), response); + + assertEquals(-1, response.capturedContentLength); + } + + @Test + public void testFileResource_gzip_bodyDecompressesToFileContent() throws Exception { + File f = tempFolder.newFile("gzip_content.txt"); + byte[] content = "file content for gzip".getBytes(StandardCharsets.UTF_8); + try (FileOutputStream fos = new FileOutputStream(f)) { fos.write(content); } + + HttpStaticResource resource = new HttpStaticResource( + "/resource", ResourceType.FILE, f.getAbsolutePath(), "text/plain", Collections.emptyMap()); + ContentLengthCapturingResponse response = new ContentLengthCapturingResponse(); + + invokeHandler(resource, createGetRequest("/resource", true), response); + + byte[] compressed = response.outputStream.toByteArray(); + byte[] decompressed; + try (GZIPInputStream gzis = new GZIPInputStream(new ByteArrayInputStream(compressed))) { + decompressed = gzis.readAllBytes(); + } + assertArrayEquals(content, decompressed); + } + + // ========================================================================= + // GROUP C — ResourceType.DIRECTORY + // ========================================================================= + + @Test + public void testDirectoryResource_validFile_setsContentLength() throws Exception { + File dir = tempFolder.newFolder("static"); + File f = new File(dir, "test.js"); + byte[] content = new byte[1024]; + new Random().nextBytes(content); + try (FileOutputStream fos = new FileOutputStream(f)) { fos.write(content); } + + HttpStaticResource resource = new HttpStaticResource( + "/static", ResourceType.DIRECTORY, dir.getAbsolutePath(), "application/javascript", Collections.emptyMap()); + ContentLengthCapturingResponse response = new ContentLengthCapturingResponse(); + + invokeHandler(resource, createGetRequest("/static/test.js", false), response); + + assertEquals(f.length(), response.capturedContentLength); + } + + @Test + public void testDirectoryResource_validFile_bodyMatchesContent() throws Exception { + File dir = tempFolder.newFolder("staticbody"); + File f = new File(dir, "data.txt"); + byte[] content = "directory file content".getBytes(StandardCharsets.UTF_8); + try (FileOutputStream fos = new FileOutputStream(f)) { fos.write(content); } + + HttpStaticResource resource = new HttpStaticResource( + "/staticbody", ResourceType.DIRECTORY, dir.getAbsolutePath(), "text/plain", Collections.emptyMap()); + ContentLengthCapturingResponse response = new ContentLengthCapturingResponse(); + + invokeHandler(resource, createGetRequest("/staticbody/data.txt", false), response); + + assertArrayEquals(content, response.outputStream.toByteArray()); + } + + @Test + public void testDirectoryResource_subdirectoryRequest_resetsResponse() throws Exception { + File dir = tempFolder.newFolder("staticdir"); + new File(dir, "sub").mkdir(); + + HttpStaticResource resource = new HttpStaticResource( + "/staticdir", ResourceType.DIRECTORY, dir.getAbsolutePath(), "text/plain", Collections.emptyMap()); + ResettableResponse response = new ResettableResponse(); + + // childPath "sub/file.txt" contains "/" — triggers the subdirectory guard + invokeHandler(resource, createGetRequest("/staticdir/sub/file.txt", false), response); + + assertTrue(response.resetCalled); + assertEquals(0, response.outputStream.size()); + } + + @Test + public void testDirectoryResource_missingFile_resetsResponse() throws Exception { + File dir = tempFolder.newFolder("staticmissing"); + + HttpStaticResource resource = new HttpStaticResource( + "/staticmissing", ResourceType.DIRECTORY, dir.getAbsolutePath(), "text/plain", Collections.emptyMap()); + ResettableResponse response = new ResettableResponse(); + + invokeHandler(resource, createGetRequest("/staticmissing/nonexistent.js", false), response); + + assertTrue(response.resetCalled); + assertEquals(0, response.outputStream.size()); + } + + @Test + public void testDirectoryResource_directoryRequestedAsFile_resetsResponse() throws Exception { + File dir = tempFolder.newFolder("staticdirfile"); + new File(dir, "subdir").mkdir(); + + HttpStaticResource resource = new HttpStaticResource( + "/staticdirfile", ResourceType.DIRECTORY, dir.getAbsolutePath(), "text/plain", Collections.emptyMap()); + ResettableResponse response = new ResettableResponse(); + + // "subdir" exists but is itself a directory — should reset + invokeHandler(resource, createGetRequest("/staticdirfile/subdir", false), response); + + assertTrue(response.resetCalled); + assertEquals(0, response.outputStream.size()); + } + + // ========================================================================= + // GROUP D — Error path / committed response + // ========================================================================= + + @Test + public void testStaticResource_committedResponse_swallowsIllegalStateException() throws Exception { + // Non-existent file triggers FileNotFoundException in the handler catch block. + // The catch block calls reset() — on a committed response that throws ISE. + // The fix wraps this in try/catch so the ISE is swallowed rather than propagated. + HttpStaticResource resource = new HttpStaticResource( + "/resource", ResourceType.FILE, + "/this/path/does/not/exist/anywhere.txt", "text/plain", Collections.emptyMap()); + CommittedResponse response = new CommittedResponse(); + + invokeHandler(resource, createGetRequest("/resource", false), response); // must not throw + } + + @Test + public void testStaticResource_error_returns500WithStackTrace() throws Exception { + HttpStaticResource resource = new HttpStaticResource( + "/resource", ResourceType.FILE, + "/this/path/does/not/exist/anywhere.txt", "text/plain", Collections.emptyMap()); + ContentLengthCapturingResponse response = new ContentLengthCapturingResponse(); + + invokeHandler(resource, createGetRequest("/resource", false), response); + + assertEquals(500, response.statusCode); + String body = response.outputStream.toString("UTF-8"); + assertTrue("Expected stack trace to mention file not found", + body.contains("FileNotFoundException") || body.contains("NoSuchFileException")); + } + + // ========================================================================= + // Helper response implementations + // ========================================================================= + + /** Captures the value passed to setContentLength / setContentLengthLong. */ + static class ContentLengthCapturingResponse extends HttpReceiverTest.TestHttpServletResponse { + long capturedContentLength = -1; + + @Override public void setContentLength(int len) { capturedContentLength = len; } + @Override public void setContentLengthLong(long len) { capturedContentLength = len; } + } + + /** Tracks whether reset() was called; clears the output stream on reset. */ + static class ResettableResponse extends ContentLengthCapturingResponse { + boolean resetCalled = false; + + @Override + public void reset() { + resetCalled = true; + outputStream.reset(); + } + } + + /** Simulates a Jetty-committed response: reset() throws IllegalStateException. */ + static class CommittedResponse extends ContentLengthCapturingResponse { + @Override public boolean isCommitted() { return true; } + @Override public void reset() { throw new IllegalStateException("Response already committed"); } + } +} diff --git a/server/test/com/mirth/connect/connectors/http/HttpReceiverTest.java b/server/test/com/mirth/connect/connectors/http/HttpReceiverTest.java index 1fa1730b7..eb8ce145b 100644 --- a/server/test/com/mirth/connect/connectors/http/HttpReceiverTest.java +++ b/server/test/com/mirth/connect/connectors/http/HttpReceiverTest.java @@ -1,5 +1,6 @@ package com.mirth.connect.connectors.http; +import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; @@ -987,6 +988,68 @@ public void testSendErrorResponseWithDispatchResult() throws Exception { assertTrue(dr.isAttemptedResponse()); } + // ========== IRT-832: Content-Length tests ========== + + @Test + public void testSendResponseNonGzipSetsContentLength() throws Exception { + TestHttpServletResponse servletResponse = new TestHttpServletResponse(); + Request baseRequest = mock(Request.class); + when(baseRequest.getHeaders("Accept-Encoding")).thenReturn(Collections.enumeration(Collections.emptyList())); + + props.setResponseDataTypeBinary(false); + props.setCharset("UTF-8"); + props.setResponseStatusCode("200"); + + String body = "Hello Content-Length"; + byte[] expectedBytes = body.getBytes(StandardCharsets.UTF_8); + + Response selectedResponse = mock(Response.class); + when(selectedResponse.getMessage()).thenReturn(body); + when(selectedResponse.getStatus()).thenReturn(Status.SENT); + DispatchResult dr = new TestDispatchResult(1L, message, selectedResponse, true, true); + + receiver.sendResponse(baseRequest, servletResponse, dr); + + assertEquals("Content-Length must match body byte length", expectedBytes.length, servletResponse.contentLength); + assertArrayEquals(expectedBytes, servletResponse.outputStream.toByteArray()); + } + + @Test + public void testSendResponseGzipDoesNotSetContentLength() throws Exception { + TestHttpServletResponse servletResponse = new TestHttpServletResponse(); + Request baseRequest = mock(Request.class); + Vector acceptEncodings = new Vector<>(); + acceptEncodings.add("gzip"); + when(baseRequest.getHeaders("Accept-Encoding")).thenReturn(acceptEncodings.elements()); + + props.setResponseDataTypeBinary(false); + props.setCharset("UTF-8"); + props.setResponseStatusCode("200"); + + Response selectedResponse = mock(Response.class); + when(selectedResponse.getMessage()).thenReturn("Gzip body"); + when(selectedResponse.getStatus()).thenReturn(Status.SENT); + DispatchResult dr = new TestDispatchResult(1L, message, selectedResponse, true, true); + + receiver.sendResponse(baseRequest, servletResponse, dr); + + assertEquals("Content-Length must not be set for gzip responses", -1, servletResponse.contentLength); + assertEquals("gzip", servletResponse.headers.get("Content-Encoding")); + } + + @Test + public void testSendErrorResponseSetsContentLength() throws Exception { + TestHttpServletResponse servletResponse = new TestHttpServletResponse(); + Request baseRequest = mock(Request.class); + + receiver.sendErrorResponse(baseRequest, servletResponse, null, new RuntimeException("boom")); + + byte[] errBytes = servletResponse.outputStream.toByteArray(); + assertTrue("Error body must be non-empty", errBytes.length > 0); + assertEquals("Content-Length must match error body byte length", errBytes.length, servletResponse.contentLength); + assertEquals(500, servletResponse.statusCode); + } + // ========== Helper methods ========== /** @@ -1056,6 +1119,7 @@ public void setReadListener(javax.servlet.ReadListener readListener) { static class TestHttpServletResponse implements HttpServletResponse { int statusCode; String contentTypeValue; + int contentLength = -1; ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); Map headers = new HashMap<>(); @@ -1109,8 +1173,8 @@ public void setWriteListener(WriteListener writeListener) {} @Override public String getCharacterEncoding() { return "UTF-8"; } @Override public java.io.PrintWriter getWriter() { return new java.io.PrintWriter(outputStream); } @Override public void setCharacterEncoding(String charset) {} - @Override public void setContentLength(int len) {} - @Override public void setContentLengthLong(long len) {} + @Override public void setContentLength(int len) { this.contentLength = len; } + @Override public void setContentLengthLong(long len) { this.contentLength = (int) len; } @Override public void setBufferSize(int size) {} @Override public int getBufferSize() { return 0; } @Override public void flushBuffer() {} diff --git a/server/test/com/mirth/connect/server/MirthWebServerTest.java b/server/test/com/mirth/connect/server/MirthWebServerTest.java index 45485e527..f50d241af 100644 --- a/server/test/com/mirth/connect/server/MirthWebServerTest.java +++ b/server/test/com/mirth/connect/server/MirthWebServerTest.java @@ -2,21 +2,37 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; +import java.nio.file.Files; +import java.util.Collections; +import java.util.Enumeration; import java.util.HashSet; import java.util.LinkedHashSet; import java.util.Set; +import java.util.Vector; import javax.servlet.FilterChain; +import javax.servlet.ServletOutputStream; +import javax.servlet.WriteListener; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import org.eclipse.jetty.ee8.nested.Request; +import org.eclipse.jetty.http.MimeTypes; + import org.apache.commons.configuration2.PropertiesConfiguration; import org.junit.Test; @@ -474,7 +490,7 @@ public void testContextPathNormalizationHandlesEmpty() { if (contextPath.endsWith("/")) { contextPath = contextPath.substring(0, contextPath.length() - 1); } - assertEquals("/", contextPath); + assertEquals("", contextPath); } @Test @@ -620,7 +636,7 @@ public void testSessionCacheNoneValue() { PropertiesConfiguration mirthProperties = PropertiesConfigurationUtil.create(); mirthProperties.setProperty("server.api.sessioncache", "none"); String sessionCacheProperty = mirthProperties.getString("server.api.sessioncache", "default"); - assertTrue(org.apache.commons.lang3.StringUtils.equalsIgnoreCase(sessionCacheProperty, "none")); + assertTrue(sessionCacheProperty.equalsIgnoreCase("none")); } @Test @@ -631,7 +647,7 @@ public void testSessionCacheNoneOnlyUsedWithSessionStore() { String sessionCacheProperty = mirthProperties.getString("server.api.sessioncache", "default"); boolean sessionStore = Boolean.parseBoolean(mirthProperties.getString("server.api.sessionstore", "false")); - boolean useNullCache = org.apache.commons.lang3.StringUtils.equalsIgnoreCase(sessionCacheProperty, "none") && sessionStore; + boolean useNullCache = sessionCacheProperty.equalsIgnoreCase("none") && sessionStore; assertFalse(useNullCache); // Should not use NullSessionCache when sessionStore is false } @@ -643,7 +659,200 @@ public void testSessionCacheNoneWithSessionStoreEnabled() { String sessionCacheProperty = mirthProperties.getString("server.api.sessioncache", "default"); boolean sessionStore = Boolean.parseBoolean(mirthProperties.getString("server.api.sessionstore", "false")); - boolean useNullCache = org.apache.commons.lang3.StringUtils.equalsIgnoreCase(sessionCacheProperty, "none") && sessionStore; + boolean useNullCache = sessionCacheProperty.equalsIgnoreCase("none") && sessionStore; assertTrue(useNullCache); // Should use NullSessionCache } + + // ===== InstallerFileHandler tests (IRT-833) ===== + + @Test + public void testInstallerFileHandlerSetsContentLengthLongForNonGzip() throws Exception { + File tempFile = File.createTempFile("installer-test-", ".zip"); + tempFile.deleteOnExit(); + Files.write(tempFile.toPath(), new byte[]{1, 2, 3, 4, 5}); + + Object handler = newInstallerHandlerInstance(tempFile); + + Request baseRequest = mock(Request.class); + when(baseRequest.getMethod()).thenReturn("GET"); + + HttpServletRequest request = mock(HttpServletRequest.class); + Enumeration emptyEnum = Collections.enumeration(Collections.emptyList()); + when(request.getHeaders("Accept-Encoding")).thenReturn(emptyEnum); + + HttpServletResponse response = mock(HttpServletResponse.class); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + when(response.getOutputStream()).thenReturn(capturingStream(baos)); + + invokeInstallerHandler(handler, baseRequest, request, response); + + verify(response).setContentLengthLong(tempFile.length()); + } + + @Test + public void testInstallerFileHandlerDoesNotSetContentLengthLongForGzip() throws Exception { + File tempFile = File.createTempFile("installer-test-gzip-", ".zip"); + tempFile.deleteOnExit(); + Files.write(tempFile.toPath(), new byte[]{1, 2, 3, 4, 5}); + + Object handler = newInstallerHandlerInstance(tempFile); + + Request baseRequest = mock(Request.class); + when(baseRequest.getMethod()).thenReturn("GET"); + + HttpServletRequest request = mock(HttpServletRequest.class); + Vector encodings = new Vector<>(); + encodings.add("gzip"); + when(request.getHeaders("Accept-Encoding")).thenReturn(encodings.elements()); + + HttpServletResponse response = mock(HttpServletResponse.class); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + when(response.getOutputStream()).thenReturn(capturingStream(baos)); + + invokeInstallerHandler(handler, baseRequest, request, response); + + verify(response, never()).setContentLengthLong(anyLong()); + } + + @Test + public void testInstallerFileHandlerSwallowsIllegalStateExceptionOnReset() throws Exception { + File tempFile = File.createTempFile("installer-test-err-", ".zip"); + tempFile.deleteOnExit(); + Files.write(tempFile.toPath(), new byte[]{1, 2, 3}); + + Object handler = newInstallerHandlerInstance(tempFile); + + Request baseRequest = mock(Request.class); + when(baseRequest.getMethod()).thenReturn("GET"); + + HttpServletRequest request = mock(HttpServletRequest.class); + Enumeration emptyEnum = Collections.enumeration(Collections.emptyList()); + when(request.getHeaders("Accept-Encoding")).thenReturn(emptyEnum); + + HttpServletResponse response = mock(HttpServletResponse.class); + when(response.getOutputStream()).thenThrow(new IOException("simulated write failure")); + + // handle() should complete normally — error caught internally, 500 status set + invokeInstallerHandler(handler, baseRequest, request, response); + + verify(response).reset(); + verify(response).setStatus(org.apache.commons.httpclient.HttpStatus.SC_INTERNAL_SERVER_ERROR); + } + + private Object newInstallerHandlerInstance(File file) throws Exception { + java.lang.reflect.Field unsafeField = sun.misc.Unsafe.class.getDeclaredField("theUnsafe"); + unsafeField.setAccessible(true); + sun.misc.Unsafe unsafe = (sun.misc.Unsafe) unsafeField.get(null); + MirthWebServer outer = (MirthWebServer) unsafe.allocateInstance(MirthWebServer.class); + + Class handlerClass = null; + for (Class c : MirthWebServer.class.getDeclaredClasses()) { + if ("InstallerFileHandler".equals(c.getSimpleName())) { + handlerClass = c; + break; + } + } + assertNotNull("InstallerFileHandler inner class not found", handlerClass); + + Constructor ctor = handlerClass.getDeclaredConstructor(MirthWebServer.class, File.class, MimeTypes.class); + ctor.setAccessible(true); + return ctor.newInstance(outer, file, new MimeTypes()); + } + + private void invokeInstallerHandler(Object handler, Request baseRequest, + HttpServletRequest request, HttpServletResponse response) throws Exception { + Method handle = handler.getClass().getMethod("handle", String.class, Request.class, + HttpServletRequest.class, HttpServletResponse.class); + try { + handle.invoke(handler, "/test", baseRequest, request, response); + } catch (InvocationTargetException e) { + Throwable cause = e.getCause(); + if (cause instanceof RuntimeException) throw (RuntimeException) cause; + if (cause instanceof Exception) throw (Exception) cause; + throw new RuntimeException(cause); + } + } + + private static ServletOutputStream capturingStream(ByteArrayOutputStream baos) { + return new ServletOutputStream() { + @Override public void write(int b) throws IOException { baos.write(b); } + @Override public void write(byte[] b) throws IOException { baos.write(b); } + @Override public void write(byte[] b, int off, int len) throws IOException { baos.write(b, off, len); } + @Override public boolean isReady() { return true; } + @Override public void setWriteListener(WriteListener wl) {} + }; + } + + // ===== SwaggerUiFilter tests (IRT-834) ===== + + @Test + public void testSwaggerUiFilterServesIndexHtmlWithContentLength() throws Exception { + File tmpDir = createTempDir(); + File indexFile = new File(tmpDir, "index.html"); + byte[] content = "swagger".getBytes(); + Files.write(indexFile.toPath(), content); + + MirthWebServer.SwaggerUiFilter filter = new MirthWebServer.SwaggerUiFilter(tmpDir.getAbsolutePath()); + + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getPathInfo()).thenReturn(null); + HttpServletResponse response = mock(HttpServletResponse.class); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + when(response.getOutputStream()).thenReturn(capturingStream(baos)); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(request, response, chain); + + verify(response).setContentLengthLong(content.length); + verify(response).setContentType("text/html; charset=UTF-8"); + verify(chain, never()).doFilter(request, response); + } + + @Test + public void testSwaggerUiFilterServesStaticCssWithContentLength() throws Exception { + File tmpDir = createTempDir(); + File cssFile = new File(tmpDir, "swagger-ui.css"); + byte[] content = "body { margin: 0; }".getBytes(); + Files.write(cssFile.toPath(), content); + + MirthWebServer.SwaggerUiFilter filter = new MirthWebServer.SwaggerUiFilter(tmpDir.getAbsolutePath()); + + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getPathInfo()).thenReturn("/swagger-ui.css"); + HttpServletResponse response = mock(HttpServletResponse.class); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + when(response.getOutputStream()).thenReturn(capturingStream(baos)); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(request, response, chain); + + verify(response).setContentLengthLong(content.length); + verify(response).setContentType("text/css"); + verify(chain, never()).doFilter(request, response); + } + + @Test + public void testSwaggerUiFilterPassesThroughNonStaticApiPath() throws Exception { + File tmpDir = createTempDir(); + + MirthWebServer.SwaggerUiFilter filter = new MirthWebServer.SwaggerUiFilter(tmpDir.getAbsolutePath()); + + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getPathInfo()).thenReturn("/channels"); + HttpServletResponse response = mock(HttpServletResponse.class); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(request, response, chain); + + verify(chain).doFilter(request, response); + verify(response, never()).setContentLengthLong(anyLong()); + } + + private File createTempDir() throws IOException { + File dir = File.createTempFile("swagger-test-", ""); + dir.delete(); + dir.mkdirs(); + dir.deleteOnExit(); + return dir; + } } diff --git a/server/test/com/mirth/connect/server/controllers/DefaultConfigurationControllerTests.java b/server/test/com/mirth/connect/server/controllers/DefaultConfigurationControllerTests.java index 16b976f89..4e8934c74 100644 --- a/server/test/com/mirth/connect/server/controllers/DefaultConfigurationControllerTests.java +++ b/server/test/com/mirth/connect/server/controllers/DefaultConfigurationControllerTests.java @@ -24,9 +24,13 @@ import static org.mockito.Mockito.when; import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; import java.io.StringReader; import java.io.StringWriter; import java.io.Writer; +import java.lang.reflect.Field; +import java.security.KeyStore; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; @@ -52,6 +56,7 @@ import com.google.inject.Injector; import com.mirth.connect.client.core.ControllerException; import com.mirth.connect.server.controllers.ConfigurationController; +import com.mirth.connect.util.KeystoreRegenerationResponse; import com.mirth.connect.util.ConnectionTestResponse; import com.mirth.connect.model.DriverInfo; import com.mirth.connect.util.ConfigurationProperty; @@ -68,6 +73,7 @@ public static void setup() throws Exception { ConfigurationController configController = mock(ConfigurationController.class); when(configController.getHttpsClientProtocols()).thenReturn(new String[0]); when(configController.getHttpsCipherSuites()).thenReturn(new String[0]); + when(configController.getConfigurationDir()).thenReturn(System.getProperty("java.io.tmpdir")); when(controllerFactory.createConfigurationController()).thenReturn(configController); Injector injector = Guice.createInjector(new AbstractModule() { @@ -567,6 +573,154 @@ public void testSendTestEmail_OAuthEmptyTokenUrl_ThrowsException() throws Except } } + // ----------------------------------------------------------------------- + // regenerateKeystorePassword + // ----------------------------------------------------------------------- + + private static final String DEFAULT_STOREPASS_A = "81uWxplDtB"; + private static final String DEFAULT_KEYPASS_A = "81uWxplDtB"; + private static final String DEFAULT_STOREPASS_B = "nfHbaNFacIhQ"; + private static final String DEFAULT_KEYPASS_B = "tdW6edezNbmd"; + + @Test + public void regenerateKeystorePassword_NonDefaultPasswords_ReturnsAlreadySecure() throws Exception { + String prevStorepass = DefaultConfigurationController.mirthConfig.getString("keystore.storepass"); + String prevKeypass = DefaultConfigurationController.mirthConfig.getString("keystore.keypass"); + try { + DefaultConfigurationController.mirthConfig.setProperty("keystore.storepass", "uniqueNonDefault1"); + DefaultConfigurationController.mirthConfig.setProperty("keystore.keypass", "uniqueNonDefault2"); + + KeystoreRegenerationResponse response = new DefaultConfigurationController().regenerateKeystorePassword(); + + assertEquals(KeystoreRegenerationResponse.Type.ALREADY_SECURE, response.getType()); + assertEquals("uniqueNonDefault1", DefaultConfigurationController.mirthConfig.getString("keystore.storepass")); + } finally { + restoreMirthConfigProperty("keystore.storepass", prevStorepass); + restoreMirthConfigProperty("keystore.keypass", prevKeypass); + } + } + + @Test + public void regenerateKeystorePassword_NullPasswords_ReturnsAlreadySecure() throws Exception { + String prevStorepass = DefaultConfigurationController.mirthConfig.getString("keystore.storepass"); + String prevKeypass = DefaultConfigurationController.mirthConfig.getString("keystore.keypass"); + try { + DefaultConfigurationController.mirthConfig.clearProperty("keystore.storepass"); + DefaultConfigurationController.mirthConfig.clearProperty("keystore.keypass"); + + KeystoreRegenerationResponse response = new DefaultConfigurationController().regenerateKeystorePassword(); + + assertEquals(KeystoreRegenerationResponse.Type.ALREADY_SECURE, response.getType()); + } finally { + restoreMirthConfigProperty("keystore.storepass", prevStorepass); + restoreMirthConfigProperty("keystore.keypass", prevKeypass); + } + } + + @Test + public void regenerateKeystorePassword_DefaultSetA_ReturnsRegenerated() throws Exception { + String prevStorepass = DefaultConfigurationController.mirthConfig.getString("keystore.storepass"); + String prevKeypass = DefaultConfigurationController.mirthConfig.getString("keystore.keypass"); + String prevPath = DefaultConfigurationController.mirthConfig.getString("keystore.path"); + String prevType = DefaultConfigurationController.mirthConfig.getString("keystore.type"); + File tempKs = createEmptyJceksKeystore(DEFAULT_STOREPASS_A); + try { + DefaultConfigurationController.mirthConfig.setProperty("keystore.storepass", DEFAULT_STOREPASS_A); + DefaultConfigurationController.mirthConfig.setProperty("keystore.keypass", DEFAULT_KEYPASS_A); + DefaultConfigurationController.mirthConfig.setProperty("keystore.path", tempKs.getAbsolutePath()); + DefaultConfigurationController.mirthConfig.setProperty("keystore.type", "JCEKS"); + + DefaultConfigurationController controller = new DefaultConfigurationController(); + KeystoreRegenerationResponse response = controller.regenerateKeystorePassword(); + + assertEquals(KeystoreRegenerationResponse.Type.REGENERATED, response.getType()); + assertFalse("storepass must change from Set-A default", + DEFAULT_STOREPASS_A.equals(DefaultConfigurationController.mirthConfig.getString("keystore.storepass"))); + assertFalse("keypass must change from Set-A default", + DEFAULT_KEYPASS_A.equals(DefaultConfigurationController.mirthConfig.getString("keystore.keypass"))); + Field flag = DefaultConfigurationController.class.getDeclaredField("usingDefaultKeystorePassword"); + flag.setAccessible(true); + assertFalse((Boolean) flag.get(controller)); + } finally { + restoreMirthConfigProperty("keystore.storepass", prevStorepass); + restoreMirthConfigProperty("keystore.keypass", prevKeypass); + restoreMirthConfigProperty("keystore.path", prevPath); + restoreMirthConfigProperty("keystore.type", prevType); + tempKs.delete(); + } + } + + @Test + public void regenerateKeystorePassword_DefaultSetB_ReturnsRegenerated() throws Exception { + String prevStorepass = DefaultConfigurationController.mirthConfig.getString("keystore.storepass"); + String prevKeypass = DefaultConfigurationController.mirthConfig.getString("keystore.keypass"); + String prevPath = DefaultConfigurationController.mirthConfig.getString("keystore.path"); + String prevType = DefaultConfigurationController.mirthConfig.getString("keystore.type"); + File tempKs = createEmptyJceksKeystore(DEFAULT_STOREPASS_B); + try { + DefaultConfigurationController.mirthConfig.setProperty("keystore.storepass", DEFAULT_STOREPASS_B); + DefaultConfigurationController.mirthConfig.setProperty("keystore.keypass", DEFAULT_KEYPASS_B); + DefaultConfigurationController.mirthConfig.setProperty("keystore.path", tempKs.getAbsolutePath()); + DefaultConfigurationController.mirthConfig.setProperty("keystore.type", "JCEKS"); + + KeystoreRegenerationResponse response = new DefaultConfigurationController().regenerateKeystorePassword(); + + assertEquals(KeystoreRegenerationResponse.Type.REGENERATED, response.getType()); + assertFalse("storepass must change from Set-B default", + DEFAULT_STOREPASS_B.equals(DefaultConfigurationController.mirthConfig.getString("keystore.storepass"))); + assertFalse("keypass must change from Set-B default", + DEFAULT_KEYPASS_B.equals(DefaultConfigurationController.mirthConfig.getString("keystore.keypass"))); + } finally { + restoreMirthConfigProperty("keystore.storepass", prevStorepass); + restoreMirthConfigProperty("keystore.keypass", prevKeypass); + restoreMirthConfigProperty("keystore.path", prevPath); + restoreMirthConfigProperty("keystore.type", prevType); + tempKs.delete(); + } + } + + @Test + public void regenerateKeystorePassword_MissingKeystoreFile_ThrowsException() throws Exception { + String prevStorepass = DefaultConfigurationController.mirthConfig.getString("keystore.storepass"); + String prevKeypass = DefaultConfigurationController.mirthConfig.getString("keystore.keypass"); + String prevPath = DefaultConfigurationController.mirthConfig.getString("keystore.path"); + try { + DefaultConfigurationController.mirthConfig.setProperty("keystore.storepass", DEFAULT_STOREPASS_A); + DefaultConfigurationController.mirthConfig.setProperty("keystore.keypass", DEFAULT_KEYPASS_A); + DefaultConfigurationController.mirthConfig.setProperty("keystore.path", "/no/such/file.jks"); + + try { + new DefaultConfigurationController().regenerateKeystorePassword(); + fail("Expected FileNotFoundException for missing keystore"); + } catch (FileNotFoundException e) { + // expected + } + } finally { + restoreMirthConfigProperty("keystore.storepass", prevStorepass); + restoreMirthConfigProperty("keystore.keypass", prevKeypass); + restoreMirthConfigProperty("keystore.path", prevPath); + } + } + + private static File createEmptyJceksKeystore(String storepass) throws Exception { + KeyStore ks = KeyStore.getInstance("JCEKS"); + ks.load(null, null); + File f = File.createTempFile("test-ks-", ".jks"); + f.deleteOnExit(); + try (FileOutputStream fos = new FileOutputStream(f)) { + ks.store(fos, storepass.toCharArray()); + } + return f; + } + + private static void restoreMirthConfigProperty(String key, String value) { + if (value == null) { + DefaultConfigurationController.mirthConfig.clearProperty(key); + } else { + DefaultConfigurationController.mirthConfig.setProperty(key, value); + } + } + private void assertDefaultDrivers(List drivers, boolean includeODBC) { assertEquals(includeODBC ? 7 : 6, drivers.size()); int i = 0; diff --git a/server/test/com/mirth/connect/server/controllers/KeystorePasswordRegenerationTest.java b/server/test/com/mirth/connect/server/controllers/KeystorePasswordRegenerationTest.java new file mode 100644 index 000000000..3008e8ba2 --- /dev/null +++ b/server/test/com/mirth/connect/server/controllers/KeystorePasswordRegenerationTest.java @@ -0,0 +1,210 @@ +/* + * Copyright (c) Innovar Healthcare. All rights reserved. + */ + +package com.mirth.connect.server.controllers; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.security.KeyStore; +import java.security.SecureRandom; +import java.util.Enumeration; + +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; + +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import com.mirth.commons.encryption.KeyEncryptor; +import com.mirth.commons.encryption.Output; + +/** + * Verifies that keystore password regeneration (IRT-577) does NOT affect the + * AES data-encryption key — messages encrypted with encryptData=true and + * database passwords stored via encrypt.properties=true remain fully + * decryptable after the keystore is re-keyed. + * + * The proof: after re-keying the keystore under new random passwords the same + * raw key bytes come back out, so any ciphertext produced before regeneration + * can still be decrypted. + */ +public class KeystorePasswordRegenerationTest { + + private static final String KEYSTORE_TYPE = "JCEKS"; + private static final String SECRET_KEY_ALIAS = "encryption"; + private static final String DEFAULT_STOREPASS = "81uWxplDtB"; + private static final String DEFAULT_KEYPASS = "81uWxplDtB"; + private static final String ENCRYPTION_ALGO = "AES/CBC/PKCS5Padding"; + private static final int KEY_LENGTH = 128; + + private File keystoreFile; + private SecretKey originalAesKey; + private String newStorepass; + private String newKeypass; + + @Before + public void setUp() throws Exception { + keystoreFile = File.createTempFile("test-keystore", ".jceks"); + keystoreFile.deleteOnExit(); + + BouncyCastleProvider provider = new BouncyCastleProvider(); + KeyGenerator keyGen = KeyGenerator.getInstance("AES", provider); + keyGen.init(KEY_LENGTH); + originalAesKey = keyGen.generateKey(); + + // Build a JCEKS keystore that mimics a fresh BridgeLink install: + // AES secret key stored under "encryption" alias with default passwords. + KeyStore ks = KeyStore.getInstance(KEYSTORE_TYPE); + ks.load(null, DEFAULT_STOREPASS.toCharArray()); + ks.setEntry(SECRET_KEY_ALIAS, new KeyStore.SecretKeyEntry(originalAesKey), + new KeyStore.PasswordProtection(DEFAULT_KEYPASS.toCharArray())); + try (FileOutputStream fos = new FileOutputStream(keystoreFile)) { + ks.store(fos, DEFAULT_STOREPASS.toCharArray()); + } + + // Run the same re-keying logic as DefaultConfigurationController.regenerateKeystorePassword() + newStorepass = generateNewPassword(); + newKeypass = generateNewPassword(); + reKeystore(DEFAULT_STOREPASS, DEFAULT_KEYPASS, newStorepass, newKeypass); + } + + @After + public void tearDown() { + if (keystoreFile != null) { + keystoreFile.delete(); + } + } + + /** + * Claim: encryptData messages remain readable after keystore password regeneration. + * + * The AES key bytes are unchanged — only the password wrapping them in the + * keystore changes. A message encrypted before regeneration must still decrypt + * correctly after loading the re-keyed keystore. + */ + @Test + public void encryptDataMessagesRemainsReadableAfterKeystoreRekey() throws Exception { + BouncyCastleProvider provider = new BouncyCastleProvider(); + + // Encrypt a message using the ORIGINAL key (simulates a stored encryptData message) + KeyEncryptor encryptorBefore = buildEncryptor(provider, originalAesKey); + String plaintext = "HL7 patient message - channel encryptData=true"; + String ciphertext = encryptorBefore.encrypt(plaintext); + + // After regeneration: reload keystore with NEW passwords and extract the key + SecretKey keyAfterRegen = loadKeyFromKeystore(newStorepass, newKeypass); + + // The raw key bytes must be identical + assertArrayEquals( + "AES key bytes must be unchanged after keystore re-key", + originalAesKey.getEncoded(), + keyAfterRegen.getEncoded()); + + // A KeyEncryptor built from the post-regen key must decrypt the pre-regen ciphertext + KeyEncryptor encryptorAfter = buildEncryptor(provider, keyAfterRegen); + String decrypted = encryptorAfter.decrypt(ciphertext); + + assertEquals( + "encryptData messages must remain readable after keystore password regeneration", + plaintext, decrypted); + } + + /** + * Claim: encrypt.properties database passwords remain readable after keystore password + * regeneration. + * + * Same AES key is used to encrypt mirth.properties values when encrypt.properties=true. + * After re-keying the keystore the password can still be decrypted. + */ + @Test + public void encryptPropertiesDbPasswordRemainsReadableAfterKeystoreRekey() throws Exception { + BouncyCastleProvider provider = new BouncyCastleProvider(); + + // Simulate encrypting the database password at startup (encrypt.properties=true) + KeyEncryptor encryptorBefore = buildEncryptor(provider, originalAesKey); + String dbPassword = "s3cr3tDbP@ssword!"; + String storedValue = "{enc}" + encryptorBefore.encrypt(dbPassword); + + assertTrue("Stored value must start with {enc} prefix", storedValue.startsWith("{enc}")); + + // After regeneration: reload keystore with NEW passwords + SecretKey keyAfterRegen = loadKeyFromKeystore(newStorepass, newKeypass); + + assertArrayEquals( + "AES key bytes must be unchanged after keystore re-key", + originalAesKey.getEncoded(), + keyAfterRegen.getEncoded()); + + // Strip the {enc} prefix and decrypt — same as what the server does at startup + KeyEncryptor encryptorAfter = buildEncryptor(provider, keyAfterRegen); + String encryptedPart = storedValue.substring("{enc}".length()); + String decryptedPassword = encryptorAfter.decrypt(encryptedPart); + + assertEquals( + "Encrypted database.password from encrypt.properties must remain readable after keystore password regeneration", + dbPassword, decryptedPassword); + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private void reKeystore(String oldStorepass, String oldKeypass, + String newStorepass, String newKeypass) throws Exception { + KeyStore ks = KeyStore.getInstance(KEYSTORE_TYPE); + try (FileInputStream fis = new FileInputStream(keystoreFile)) { + ks.load(fis, oldStorepass.toCharArray()); + } + + Enumeration aliases = ks.aliases(); + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + if (ks.isKeyEntry(alias)) { + java.security.Key key = ks.getKey(alias, oldKeypass.toCharArray()); + java.security.cert.Certificate[] chain = ks.getCertificateChain(alias); + ks.setKeyEntry(alias, key, newKeypass.toCharArray(), chain); + } + } + + try (FileOutputStream fos = new FileOutputStream(keystoreFile)) { + ks.store(fos, newStorepass.toCharArray()); + } + } + + private SecretKey loadKeyFromKeystore(String storepass, String keypass) throws Exception { + KeyStore ks = KeyStore.getInstance(KEYSTORE_TYPE); + try (FileInputStream fis = new FileInputStream(keystoreFile)) { + ks.load(fis, storepass.toCharArray()); + } + return (SecretKey) ks.getKey(SECRET_KEY_ALIAS, keypass.toCharArray()); + } + + private KeyEncryptor buildEncryptor(BouncyCastleProvider provider, SecretKey key) throws Exception { + KeyEncryptor encryptor = new KeyEncryptor(); + encryptor.setProvider(provider); + encryptor.setKey(key); + encryptor.setAlgorithm(ENCRYPTION_ALGO); + encryptor.setFormat(Output.BASE64); + encryptor.initialize(); + return encryptor; + } + + private String generateNewPassword() { + SecureRandom random = new SecureRandom(); + byte[] bytes = new byte[16]; + random.nextBytes(bytes); + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } +} diff --git a/server/test/com/mirth/connect/server/servlets/WebStartServletTest.java b/server/test/com/mirth/connect/server/servlets/WebStartServletTest.java index 8ab82bfb6..fe7a119bf 100644 --- a/server/test/com/mirth/connect/server/servlets/WebStartServletTest.java +++ b/server/test/com/mirth/connect/server/servlets/WebStartServletTest.java @@ -25,10 +25,12 @@ import static org.mockito.Mockito.when; import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.PrintWriter; import java.io.StringWriter; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -38,6 +40,7 @@ import java.util.Map; import javax.servlet.ServletOutputStream; +import javax.servlet.WriteListener; import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @@ -132,6 +135,22 @@ public String answer(InvocationOnMock invocation) throws Throwable { assertEquals("attachment; filename = \"webstart.jnlp\"", response.getHeader("Content-Disposition")); } + @Test + public void testDoGetCoreSetsContentLength() throws Exception { + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getRequestURI()).thenReturn("/webstart"); + when(request.getServletPath()).thenReturn("/webstart"); + when(request.getParameterNames()).thenReturn(Collections.emptyEnumeration()); + + TestHttpServletResponse response = new TestHttpServletResponse(); + webStartServlet.doGet(request, response); + + String body = response.getResponseString(); + assertTrue("Content-Length should be positive", response.getContentLength() > 0); + assertEquals("Content-Length must match actual body bytes", + body.getBytes(StandardCharsets.UTF_8).length, response.getContentLength()); + } + @Test public void testDoGetCoreQueryParamsInvalidValues() throws Exception { // Test "maxHeapSize" invalid value @@ -485,12 +504,9 @@ public void testDoGetNonMatchingPath() throws Exception { TestHttpServletResponse response = new TestHttpServletResponse(); - try { - webStartServlet.doGet(request, response); - } catch (Exception e) { - // May throw ServletException wrapping NPE for null jnlpDocument - } + webStartServlet.doGet(request, response); + assertEquals(404, response.getStatus()); assertEquals("", response.getContentType()); assertNull(response.getHeader("Content-Disposition")); } @@ -504,12 +520,9 @@ public void testDoGetWebstartWithTrailingSlash() throws Exception { TestHttpServletResponse response = new TestHttpServletResponse(); - try { - webStartServlet.doGet(request, response); - } catch (Exception e) { - // URI /webstart/ doesn't match /webstart exactly - } + webStartServlet.doGet(request, response); + assertEquals(404, response.getStatus()); assertEquals("", response.getContentType()); assertNull(response.getHeader("Content-Disposition")); } @@ -598,12 +611,9 @@ public void testDoGetCoreWithContextPathMismatch() throws Exception { TestHttpServletResponse response = new TestHttpServletResponse(); - try { - contextServlet.doGet(request, response); - } catch (Exception e) { - // May throw for null jnlpDocument - } + contextServlet.doGet(request, response); + assertEquals(404, response.getStatus()); assertEquals("", response.getContentType()); assertNull(response.getHeader("Content-Disposition")); } @@ -615,6 +625,9 @@ private static class TestHttpServletResponse implements HttpServletResponse { private StringWriter stringWriter; private PrintWriter printWriter; private Map> headers; + private final ByteArrayOutputStream bodyBytes = new ByteArrayOutputStream(); + private int status = 200; + private int contentLength = -1; public TestHttpServletResponse() { contentType = ""; @@ -625,9 +638,14 @@ public TestHttpServletResponse() { } public String getResponseString() { + if (bodyBytes.size() > 0) { + return new String(bodyBytes.toByteArray(), StandardCharsets.UTF_8); + } return stringWriter.toString(); } + public int getContentLength() { return contentLength; } + @Override public void flushBuffer() throws IOException { @@ -655,7 +673,11 @@ public Locale getLocale() { @Override public ServletOutputStream getOutputStream() throws IOException { - return null; + return new ServletOutputStream() { + @Override public void write(int b) { bodyBytes.write(b); } + @Override public boolean isReady() { return true; } + @Override public void setWriteListener(WriteListener l) {} + }; } @Override @@ -690,7 +712,7 @@ public void setCharacterEncoding(String arg0) { @Override public void setContentLength(int arg0) { - + this.contentLength = arg0; } @Override @@ -774,12 +796,12 @@ public Collection getHeaders(String key) { @Override public int getStatus() { - return 0; + return status; } @Override public void sendError(int arg0) throws IOException { - + this.status = arg0; } @Override @@ -814,7 +836,7 @@ public void setIntHeader(String arg0, int arg1) { @Override public void setStatus(int arg0) { - + this.status = arg0; } @Override