diff --git a/api/src/main/java/org/openmrs/module/patientdocuments/common/Helper.java b/api/src/main/java/org/openmrs/module/patientdocuments/common/Helper.java index a94a12d..eeb4a42 100644 --- a/api/src/main/java/org/openmrs/module/patientdocuments/common/Helper.java +++ b/api/src/main/java/org/openmrs/module/patientdocuments/common/Helper.java @@ -10,11 +10,16 @@ package org.openmrs.module.patientdocuments.common; import org.apache.commons.io.IOUtils; +import org.apache.commons.lang3.StringUtils; import org.openmrs.util.OpenmrsClassLoader; +import org.openmrs.util.OpenmrsUtil; +import java.io.File; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.nio.file.Paths; public class Helper { @@ -37,4 +42,36 @@ public static String getStringFromResource(String resourceName) { throw new IllegalArgumentException("Unable to load resource: " + resourceName, e); } } + + public static File getFileFromAppDataDir(String relativePath) { + if (StringUtils.isBlank(relativePath)) { + return null; + } + + final File appDataDir = OpenmrsUtil.getApplicationDataDirectoryAsFile(); + try { + final Path appDataPath = appDataDir.toPath().toRealPath(); + final Path inputPath = Paths.get(relativePath); + + if (inputPath.isAbsolute()) { + return null; + } + + final Path inputAbsolute = inputPath.toAbsolutePath(); + if (!inputAbsolute.equals(inputAbsolute.normalize())) { + return null; + } + + final Path resolved = appDataPath.resolve(relativePath).normalize(); + final Path resolvedReal = resolved.toRealPath(); + + if (!resolvedReal.startsWith(appDataPath)) { + return null; + } + + return resolvedReal.toFile(); + } catch (IllegalArgumentException | IOException e) { + return null; + } + } } diff --git a/api/src/main/java/org/openmrs/module/patientdocuments/common/PatientDocumentsConstants.java b/api/src/main/java/org/openmrs/module/patientdocuments/common/PatientDocumentsConstants.java index fcd36cc..c8dd87a 100644 --- a/api/src/main/java/org/openmrs/module/patientdocuments/common/PatientDocumentsConstants.java +++ b/api/src/main/java/org/openmrs/module/patientdocuments/common/PatientDocumentsConstants.java @@ -38,7 +38,7 @@ public class PatientDocumentsConstants { public static final String ENCOUNTER_PRINTING_FOOTER_PREFIX = "report.encounterPrinting.footer."; - public static final String ENCOUNTER_PRINTING_STYLESHEET_KEY = "report.encounterPrinting.stylesheet"; + public static final String ENCOUNTER_PRINTING_STYLESHEET_PATH_KEY = "report.encounterPrinting.stylesheetPath"; public static final String ENCOUNTER_PRINTING_LOGO_PATH_KEY = "report.encounterPrinting.logopath"; diff --git a/api/src/main/java/org/openmrs/module/patientdocuments/renderer/EncounterPdfReportRenderer.java b/api/src/main/java/org/openmrs/module/patientdocuments/renderer/EncounterPdfReportRenderer.java index 42b9be6..3fb17dc 100644 --- a/api/src/main/java/org/openmrs/module/patientdocuments/renderer/EncounterPdfReportRenderer.java +++ b/api/src/main/java/org/openmrs/module/patientdocuments/renderer/EncounterPdfReportRenderer.java @@ -26,6 +26,8 @@ import org.openmrs.module.reporting.report.ReportRequest; import org.openmrs.module.reporting.report.renderer.RenderingException; import org.openmrs.module.reporting.report.renderer.ReportDesignRenderer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import javax.xml.XMLConstants; @@ -35,11 +37,14 @@ import javax.xml.transform.TransformerFactory; import javax.xml.transform.sax.SAXResult; import javax.xml.transform.stream.StreamSource; +import java.io.File; import java.io.FileNotFoundException; +import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.StringReader; import java.net.URI; +import java.nio.file.Files; import java.util.ArrayList; import java.util.List; import java.util.Locale; @@ -48,6 +53,8 @@ @Handler public class EncounterPdfReportRenderer extends ReportDesignRenderer { + private static final Logger log = LoggerFactory.getLogger(EncounterPdfReportRenderer.class); + @Override public String getFilename(ReportRequest request) { return "EncountersReport.pdf"; @@ -99,10 +106,11 @@ private void transformXmlToPdf(String xmlData, OutputStream outStream) throws Ex FOUserAgent foUserAgent = fopFactory.newFOUserAgent(); Fop fop = fopFactory.newFop(MimeConstants.MIME_PDF, foUserAgent, outStream); - String stylesheetName = getStylesheetName(); - try (InputStream xslStream = Helper.getInputStreamByResource(stylesheetName)) { + try (InputStream xslStream = getStylesheetStream()) { if (xslStream == null) { - throw new FileNotFoundException("Stylesheet not found at " + stylesheetName); + throw new FileNotFoundException( + "Default stylesheet not found on classpath: " + + PatientDocumentsConstants.DEFAULT_ENCOUNTER_FORM_XSL_PATH); } TransformerFactory factory = TransformerFactory.newInstance(); @@ -115,11 +123,26 @@ private void transformXmlToPdf(String xmlData, OutputStream outStream) throws Ex } } - private String getStylesheetName() { - String stylesheetName = Context.getService(InitializerService.class).getValueFromKey(PatientDocumentsConstants.ENCOUNTER_PRINTING_STYLESHEET_KEY); - if (StringUtils.isBlank(stylesheetName)) { - stylesheetName = PatientDocumentsConstants.DEFAULT_ENCOUNTER_FORM_XSL_PATH; + InputStream getStylesheetStream() throws IOException { + return getStylesheetStream(getConfiguredStylesheetPath()); + } + + InputStream getStylesheetStream(String path) throws IOException { + if (StringUtils.isNotBlank(path)) { + File stylesheetFile = Helper.getFileFromAppDataDir(path); + if (stylesheetFile != null && stylesheetFile.isFile() && stylesheetFile.canRead()) { + return Files.newInputStream(stylesheetFile.toPath()); + } + log.warn("Configured encounter printing stylesheet '{}' was not found in the OpenMRS " + + "application data directory; falling back to the default bundled stylesheet.", + path); } - return stylesheetName; + + return Helper.getInputStreamByResource(PatientDocumentsConstants.DEFAULT_ENCOUNTER_FORM_XSL_PATH); + } + + private String getConfiguredStylesheetPath() { + return Context.getService(InitializerService.class) + .getValueFromKey(PatientDocumentsConstants.ENCOUNTER_PRINTING_STYLESHEET_PATH_KEY); } } diff --git a/api/src/main/java/org/openmrs/module/patientdocuments/renderer/EncounterXmlBuilder.java b/api/src/main/java/org/openmrs/module/patientdocuments/renderer/EncounterXmlBuilder.java index d7cd63f..54c61bc 100644 --- a/api/src/main/java/org/openmrs/module/patientdocuments/renderer/EncounterXmlBuilder.java +++ b/api/src/main/java/org/openmrs/module/patientdocuments/renderer/EncounterXmlBuilder.java @@ -26,6 +26,7 @@ import org.openmrs.messagesource.MessageSourceService; import org.openmrs.module.initializer.api.InitializerService; import org.openmrs.module.o3forms.api.O3FormsService; +import org.openmrs.module.patientdocuments.common.Helper; import org.openmrs.module.patientdocuments.common.PatientDocumentsConstants; import org.openmrs.module.webservices.rest.SimpleObject; import org.openmrs.util.OpenmrsUtil; @@ -34,11 +35,10 @@ import java.io.File; import java.io.IOException; -import java.nio.file.Path; -import java.nio.file.Paths; import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; +import java.util.Collections; import java.util.Date; import java.util.HashMap; import java.util.List; @@ -53,6 +53,10 @@ public class EncounterXmlBuilder { private static final String QUESTIONS_SECTION = "questions"; + private static final String ANSWERS_FIELD = "answers"; + + private static final String CONCEPT_FIELD = "concept"; + private static final String LABEL_FIELD = "label"; private InitializerService initializerService; @@ -72,8 +76,8 @@ private String getLogoContent() { return null; } - File logoFile = resolveSecureLogoPath(logoPath); - if (logoFile == null || !logoFile.exists() || !logoFile.canRead() || !logoFile.isFile()) { + File logoFile = Helper.getFileFromAppDataDir(logoPath); + if (logoFile == null || !logoFile.isFile() || !logoFile.canRead()) { return null; } @@ -89,40 +93,6 @@ private String getLogoContent() { return null; } - private File resolveSecureLogoPath(String logoUrlPath) { - if (StringUtils.isBlank(logoUrlPath)) { - return null; - } - - final File appDataDir = OpenmrsUtil.getApplicationDataDirectoryAsFile(); - try { - final Path appDataPath = appDataDir.toPath().toRealPath(); - final Path logoPath = Paths.get(logoUrlPath); - - if (logoPath.isAbsolute()) { - return null; - } - - final Path logoAbsolutePath = logoPath.toAbsolutePath(); - final Path logoNormalizedPath = logoAbsolutePath.normalize(); - - if (!logoAbsolutePath.equals(logoNormalizedPath)) { - return null; - } - - final Path resolvedLogoPath = appDataPath.resolve(logoUrlPath).normalize(); - final Path resolvedLogoRealPath = resolvedLogoPath.toRealPath(); - - if (!resolvedLogoRealPath.startsWith(appDataPath)) { - return null; - } - - return resolvedLogoRealPath.toFile(); - } catch (IllegalArgumentException | IOException e) { - return null; - } - } - public String build(EncounterPrintingContext printingContext) { StringBuilder xml = new StringBuilder(); xml.append(""); @@ -399,7 +369,19 @@ private String extractConceptRef(Map question) { return null; } Map options = (Map) question.get(QUESTION_OPTIONS_SECTION); - return (String) options.get("concept"); + return (String) options.get(CONCEPT_FIELD); + } + + private List> extractAnswers(Map question) { + Object optionsObj = question.get(QUESTION_OPTIONS_SECTION); + if (!(optionsObj instanceof Map)) { + return Collections.emptyList(); + } + Object answersObj = ((Map) optionsObj).get(ANSWERS_FIELD); + if (answersObj instanceof List) { + return (List>) answersObj; + } + return Collections.emptyList(); } private String formatValueAsText(Object value) { @@ -426,7 +408,7 @@ private String findObsValue(Map question, Map> } return observations.stream() - .map(obs -> getLocalizedObsValue(obs, locale)) + .map(obs -> getLocalizedObsValue(question, obs, locale)) .collect(Collectors.joining(", ")); } @@ -434,8 +416,13 @@ private String escape(String input) { return StringEscapeUtils.escapeXml10(StringUtils.defaultString(input)); } - private String getLocalizedObsValue(Obs obs, Locale locale) { + private String getLocalizedObsValue(Map question, Obs obs, Locale locale) { if (obs.getValueCoded() != null) { + String answerLabel = findAnswerLabel(question, obs.getValueCoded().getUuid(), locale); + if (StringUtils.isNotBlank(answerLabel)) { + return answerLabel; + } + ConceptName localizedName = obs.getValueCoded().getName(locale); if (localizedName != null) { return localizedName.getName(); @@ -446,17 +433,29 @@ private String getLocalizedObsValue(Obs obs, Locale locale) { return obs.getValueAsString(locale); } - private String getLocalizedLabel(String defaultLabel, String conceptRef, Locale locale) { - if (StringUtils.isNotBlank(conceptRef)) { - Concept concept = getConceptService().getConceptByReference(conceptRef); - if (concept != null) { - ConceptName localizedName = concept.getName(locale); - if (localizedName != null) { - return localizedName.getName(); - } + private String findAnswerLabel(Map question, String obsConceptUuid, Locale locale) { + for (Map answer : extractAnswers(question)) { + String answerConceptRef = (String) answer.get(CONCEPT_FIELD); + if (StringUtils.isBlank(answerConceptRef)) { + continue; + } + if (answerMatchesConcept(answerConceptRef, obsConceptUuid)) { + String answerLabel = (String) answer.get(LABEL_FIELD); + return StringUtils.isNotBlank(answerLabel) ? getLocalizedLabel(answerLabel, null, locale) : null; } } + return null; + } + private boolean answerMatchesConcept(String answerConceptRef, String obsConceptUuid) { + if (answerConceptRef.equals(obsConceptUuid)) { + return true; + } + Concept answerConcept = getConceptService().getConceptByReference(answerConceptRef); + return answerConcept != null && answerConcept.getUuid().equals(obsConceptUuid); + } + + private String getLocalizedLabel(String defaultLabel, String conceptRef, Locale locale) { if (StringUtils.isNotBlank(defaultLabel)) { try { String translated = getMessageSourceService().getMessage(defaultLabel, null, locale); @@ -465,9 +464,20 @@ private String getLocalizedLabel(String defaultLabel, String conceptRef, Locale } } catch (Exception ignored) { } + return defaultLabel; + } + + if (StringUtils.isNotBlank(conceptRef)) { + Concept concept = getConceptService().getConceptByReference(conceptRef); + if (concept != null) { + ConceptName localizedName = concept.getName(locale); + if (localizedName != null) { + return localizedName.getName(); + } + } } - return defaultLabel; + return ""; } private MessageSourceService getMessageSourceService() { diff --git a/api/src/main/resources/defaultEncounterFormFopStylesheet.xsl b/api/src/main/resources/defaultEncounterFormFopStylesheet.xsl index 6c40f64..8903604 100644 --- a/api/src/main/resources/defaultEncounterFormFopStylesheet.xsl +++ b/api/src/main/resources/defaultEncounterFormFopStylesheet.xsl @@ -202,20 +202,23 @@ - + - + - + + - + diff --git a/api/src/test/java/org/openmrs/module/patientdocuments/common/HelperTest.java b/api/src/test/java/org/openmrs/module/patientdocuments/common/HelperTest.java new file mode 100644 index 0000000..0870869 --- /dev/null +++ b/api/src/test/java/org/openmrs/module/patientdocuments/common/HelperTest.java @@ -0,0 +1,63 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.patientdocuments.common; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.openmrs.test.jupiter.BaseModuleContextSensitiveTest; +import org.openmrs.util.OpenmrsUtil; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; + +public class HelperTest extends BaseModuleContextSensitiveTest { + + @Test + public void getFileFromAppDataDir_shouldResolveFileInsideAppDataDir() throws Exception { + Path dir = Files.createDirectories( + OpenmrsUtil.getApplicationDataDirectoryAsFile().toPath().resolve("printing")); + Path stylesheet = dir.resolve("custom.xsl"); + Files.write(stylesheet, "".getBytes()); + + File resolved = Helper.getFileFromAppDataDir("printing/custom.xsl"); + + Assertions.assertNotNull(resolved); + Assertions.assertEquals(stylesheet.toRealPath(), resolved.toPath().toRealPath()); + } + + @Test + public void getFileFromAppDataDir_shouldReturnNullForBlankInput() { + Assertions.assertNull(Helper.getFileFromAppDataDir(null)); + Assertions.assertNull(Helper.getFileFromAppDataDir("")); + Assertions.assertNull(Helper.getFileFromAppDataDir(" ")); + } + + @Test + public void getFileFromAppDataDir_shouldRejectPathTraversal() { + Assertions.assertNull(Helper.getFileFromAppDataDir("../escape.xsl")); + Assertions.assertNull(Helper.getFileFromAppDataDir("printing/../../escape.xsl")); + } + + @Test + public void getFileFromAppDataDir_shouldRejectAbsolutePaths() throws Exception { + Path outside = Files.createTempFile("absolute-path-test", ".xsl"); + try { + Assertions.assertNull(Helper.getFileFromAppDataDir(outside.toString())); + } finally { + Files.deleteIfExists(outside); + } + } + + @Test + public void getFileFromAppDataDir_shouldReturnNullWhenFileMissing() { + Assertions.assertNull(Helper.getFileFromAppDataDir("nonexistent/missing.xsl")); + } +} diff --git a/api/src/test/java/org/openmrs/module/patientdocuments/renderer/EncounterPdfReportRendererStylesheetTest.java b/api/src/test/java/org/openmrs/module/patientdocuments/renderer/EncounterPdfReportRendererStylesheetTest.java new file mode 100644 index 0000000..e16d04d --- /dev/null +++ b/api/src/test/java/org/openmrs/module/patientdocuments/renderer/EncounterPdfReportRendererStylesheetTest.java @@ -0,0 +1,72 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.patientdocuments.renderer; + +import org.apache.commons.io.IOUtils; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.openmrs.test.jupiter.BaseModuleContextSensitiveTest; +import org.openmrs.util.OpenmrsUtil; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +public class EncounterPdfReportRendererStylesheetTest extends BaseModuleContextSensitiveTest { + + private final EncounterPdfReportRenderer renderer = new EncounterPdfReportRenderer(); + + @Test + public void getStylesheetStream_shouldFallBackToDefaultWhenNothingConfigured() throws Exception { + try (InputStream s = renderer.getStylesheetStream(null)) { + Assertions.assertNotNull(s, "default classpath stylesheet must be readable"); + String body = IOUtils.toString(s, StandardCharsets.UTF_8); + Assertions.assertTrue(body.contains("xsl:stylesheet")); + } + } + + @Test + public void getStylesheetStream_shouldFallBackToDefaultWhenConfiguredPathIsBlank() throws Exception { + try (InputStream s = renderer.getStylesheetStream(" ")) { + Assertions.assertNotNull(s); + Assertions.assertTrue(IOUtils.toString(s, StandardCharsets.UTF_8).contains("xsl:stylesheet")); + } + } + + @Test + public void getStylesheetStream_shouldLoadConfiguredFileFromAppDataDir() throws Exception { + Path dir = Files.createDirectories( + OpenmrsUtil.getApplicationDataDirectoryAsFile().toPath().resolve("printing")); + Path stylesheet = dir.resolve("custom.xsl"); + String marker = ""; + Files.write(stylesheet, marker.getBytes(StandardCharsets.UTF_8)); + + try (InputStream s = renderer.getStylesheetStream("printing/custom.xsl")) { + Assertions.assertEquals(marker, IOUtils.toString(s, StandardCharsets.UTF_8)); + } + } + + @Test + public void getStylesheetStream_shouldFallBackWhenConfiguredFileMissing() throws Exception { + try (InputStream s = renderer.getStylesheetStream("printing/missing.xsl")) { + Assertions.assertNotNull(s, "must fall back to the default classpath stylesheet"); + Assertions.assertTrue(IOUtils.toString(s, StandardCharsets.UTF_8).contains("xsl:stylesheet")); + } + } + + @Test + public void getStylesheetStream_shouldFallBackOnPathTraversal() throws Exception { + try (InputStream s = renderer.getStylesheetStream("../etc/passwd")) { + Assertions.assertNotNull(s); + Assertions.assertTrue(IOUtils.toString(s, StandardCharsets.UTF_8).contains("xsl:stylesheet")); + } + } +} diff --git a/readme/EncounterPrinting.md b/readme/EncounterPrinting.md index e17f8c0..561b952 100644 --- a/readme/EncounterPrinting.md +++ b/readme/EncounterPrinting.md @@ -57,7 +57,9 @@ Configuration for the XSL stylesheet used to render the PDF. | Key | Description | |-----|-------------| -| `report.encounterPrinting.stylesheet` | XSL stylesheet filename to use for rendering (e.g., `defaultEncounterFormFopStylesheet.xsl`) | +| `report.encounterPrinting.stylesheetPath` | Path to a custom XSL stylesheet, relative to `OPENMRS_APPLICATION_DATA_DIRECTORY`. Drop the `.xsl` file anywhere inside the OpenMRS data folder and point this config at it - no module rebuild required. If the configured file is missing, unreadable, or escapes the data directory (path traversal), the module falls back to the bundled `defaultEncounterFormFopStylesheet.xsl`. | + +**Note:** Path traversal sequences (`..`) and absolute paths are rejected. ### Logo Configuration for logo element in the PDF. @@ -86,7 +88,7 @@ Configuration for logo element in the PDF. "report.encounterPrinting.header.visitAttributes": "true", "report.encounterPrinting.header.visitAttributeTypes": "Payment Method", "report.encounterPrinting.footer.customText": "Organization Name", - "report.encounterPrinting.stylesheet": "defaultEncounterFormFopStylesheet.xsl", + "report.encounterPrinting.stylesheetPath": "printing/customEncounterFormFopStylesheet.xsl", "report.encounterPrinting.logopath": "branding/logo.png" } ```