-
Notifications
You must be signed in to change notification settings - Fork 10
(feat) O3-5186: Add security safeguards for logo path configuration #5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 10 commits
fb99653
9466efd
3a69ead
3393fcc
c4ff685
ef44fbd
5e05736
665a16c
9e50b01
02a5c48
eb27ff7
0eae47e
b8a3258
5a388fc
ee8dd5d
a51180a
e170a55
c2df6d9
ccf8592
174733a
afe7272
6653ea0
5bd3ed8
b490420
c48e493
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,6 +16,8 @@ | |
| import java.io.File; | ||
| import java.io.IOException; | ||
| import java.io.OutputStream; | ||
| import java.nio.file.Path; | ||
| import java.nio.file.Paths; | ||
| import java.util.Arrays; | ||
| import java.util.Base64; | ||
| import java.util.HashMap; | ||
|
|
@@ -35,6 +37,7 @@ | |
| import javax.xml.transform.stream.StreamResult; | ||
|
|
||
| import org.openmrs.annotation.Handler; | ||
| import org.openmrs.api.APIException; | ||
| import org.openmrs.api.context.Context; | ||
| import org.openmrs.messagesource.MessageSourceService; | ||
| import org.openmrs.module.initializer.api.InitializerService; | ||
|
|
@@ -49,6 +52,8 @@ | |
| import org.openmrs.module.reporting.report.renderer.ReportDesignRenderer; | ||
| import org.openmrs.module.reporting.report.renderer.ReportRenderer; | ||
| import org.openmrs.util.OpenmrsUtil; | ||
| import org.slf4j.Logger; | ||
| import org.slf4j.LoggerFactory; | ||
| import org.springframework.stereotype.Component; | ||
| import org.w3c.dom.Document; | ||
| import org.w3c.dom.Element; | ||
|
|
@@ -63,6 +68,8 @@ | |
| @Localized("patientdocuments.patientIdStickerXmlReportRenderer") | ||
| public class PatientIdStickerXmlReportRenderer extends ReportDesignRenderer { | ||
|
|
||
| private static final Logger log = LoggerFactory.getLogger(PatientIdStickerXmlReportRenderer.class); | ||
|
|
||
| private MessageSourceService mss; | ||
|
|
||
| private InitializerService initializerService; | ||
|
|
@@ -243,51 +250,112 @@ private void configureHeader(Document doc, Element templatePIDElement, byte[] de | |
| templatePIDElement.appendChild(i18nStrings); | ||
| } | ||
|
|
||
| /** | ||
| * Configures the logo for the sticker document. | ||
| * | ||
| * Logo resolution priority: | ||
| * 1. Custom logo from absolute filesystem path resolved under {@code OPENMRS_APPLICATION_DATA_DIRECTORY} | ||
| * 2. Default OpenMRS logo as base64 data URI | ||
| * | ||
| * @param doc The XML document | ||
| * @param header The header element to append the logo to | ||
| * @param logoUrlPath User-configured logo path (can be null, absolute, or relative) | ||
| * @throws RenderingException if no valid logo can be found | ||
| */ | ||
| /** | ||
| * Configures the logo for the sticker document. | ||
| * | ||
| * Logo resolution priority: | ||
| * 1. Custom logo from path resolved under {@code OPENMRS_APPLICATION_DATA_DIRECTORY} | ||
| * 2. Default OpenMRS logo as base64 data URI | ||
| * | ||
| * @param doc The XML document | ||
| * @param header The header element to append the logo to | ||
| * @param logoUrlPath User-configured logo path (must be relative to app data dir) | ||
| * @param defaultLogoBytes Default logo bytes to use as fallback | ||
| * @throws RenderingException if path traversal is detected or other security violation occurs | ||
| */ | ||
| private void configureLogo(Document doc, Element header, String logoUrlPath, byte[] defaultLogoBytes) { | ||
| String logoPath = ""; | ||
| String logoContent = null; | ||
|
|
||
| try { | ||
| // 1. Try custom logo | ||
jnsereko marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| if (isNotBlank(logoUrlPath)) { | ||
| File logoFile = new File(logoUrlPath); | ||
| if (!logoFile.isAbsolute()) { | ||
| File appDataDir = OpenmrsUtil.getDirectoryInApplicationDataDirectory(""); | ||
| logoFile = new File(appDataDir, logoUrlPath); | ||
| } | ||
| if (logoFile.exists() && logoFile.canRead()) { | ||
| logoPath = logoFile.getAbsolutePath(); | ||
| File logoFile = resolveSecureLogoPath(logoUrlPath); | ||
| if (logoFile != null && logoFile.exists() && logoFile.canRead() && logoFile.isFile()) { | ||
| logoContent = logoFile.getAbsolutePath(); | ||
| } else { | ||
| log.warn("Logo file not found, unreadable, or not a regular file: {}", logoUrlPath); | ||
jnsereko marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
| } | ||
|
|
||
| // 2. Fall back to default logo | ||
| if (isBlank(logoPath) && defaultLogoBytes != null && defaultLogoBytes.length > 0) { | ||
| String base64Image = Base64.getEncoder().encodeToString(defaultLogoBytes); | ||
| logoPath = "data:image/png;base64," + base64Image; | ||
| } | ||
| } catch (Exception e) { | ||
| throw new RenderingException("Failed to configure logo", e); | ||
| } catch (APIException e) { | ||
| log.error("Security violation when resolving logo path: {}", logoUrlPath, e); | ||
jnsereko marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| // Create and append logo elements if valid | ||
| if (isNotBlank(logoPath)) { | ||
|
|
||
| if (isBlank(logoContent) && defaultLogoBytes != null && defaultLogoBytes.length > 0) { | ||
| String base64Image = Base64.getEncoder().encodeToString(defaultLogoBytes); | ||
| logoContent = "data:image/png;base64," + base64Image; | ||
| } | ||
|
|
||
| if (isNotBlank(logoContent)) { | ||
| Element branding = doc.createElement("branding"); | ||
| Element image = doc.createElement("logo"); | ||
| image.setTextContent(logoPath); | ||
| image.setTextContent(logoContent); | ||
| branding.appendChild(image); | ||
| header.appendChild(branding); | ||
| } | ||
| else if (isNotBlank(logoUrlPath)) { | ||
| // If a path was provided but we could not resolve or fall back, surface an error | ||
| throw new RenderingException("Failed to configure logo: unresolved path and no default provided"); | ||
jnsereko marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Securely resolves a logo path, ensuring it stays within the application data directory. | ||
jnsereko marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| * | ||
| * @param logoUrlPath The user-provided logo path (must be relative) | ||
jnsereko marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| * @return A File object if the path is valid and secure, null otherwise | ||
jnsereko marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| * @throws APIException if path traversal or other security violation is detected | ||
| */ | ||
| private File resolveSecureLogoPath(String logoUrlPath) throws APIException { | ||
| if (isBlank(logoUrlPath)) { | ||
| return null; | ||
| } | ||
|
|
||
| final File appDataDir = OpenmrsUtil.getApplicationDataDirectoryAsFile(); | ||
| if (appDataDir == null) { | ||
jnsereko marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| log.error("Application data directory is not configured"); | ||
jnsereko marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| return null; | ||
| } | ||
|
|
||
| try { | ||
| final Path appDataPath = appDataDir.toPath().toRealPath(); | ||
| final Path logoPath = Paths.get(logoUrlPath); | ||
|
|
||
| // For absolute paths, verify they're within app data directory | ||
|
||
| if (logoPath.isAbsolute()) { | ||
| final Path logoRealPath = logoPath.toRealPath(); | ||
| if (!isPathWithinAppDataDirectory(logoRealPath, appDataPath)) { | ||
| throw new APIException("Absolute path must be within application data directory: " + logoUrlPath); | ||
|
||
| } | ||
| return logoRealPath.toFile(); | ||
| } | ||
jnsereko marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| // For relative paths, detect path traversal by comparing absolute and normalized paths | ||
| final Path logoAbsolutePath = logoPath.toAbsolutePath(); | ||
| final Path logoNormalizedPath = logoAbsolutePath.normalize(); | ||
|
|
||
| if (!logoAbsolutePath.equals(logoNormalizedPath)) { | ||
| throw new APIException("Path traversal detected in logo path: " + logoUrlPath); | ||
| } | ||
|
|
||
| // Resolve against application data directory and validate real location | ||
| final Path resolvedLogoPath = appDataPath.resolve(logoUrlPath).normalize(); | ||
| final Path resolvedLogoRealPath = resolvedLogoPath.toRealPath(); | ||
|
|
||
| if (!isPathWithinAppDataDirectory(resolvedLogoRealPath, appDataPath)) { | ||
| throw new APIException("Logo path escapes application data directory: " + logoUrlPath); | ||
jnsereko marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| return resolvedLogoRealPath.toFile(); | ||
| } catch (IllegalArgumentException e) { | ||
| throw new APIException("Invalid characters in logo path: " + logoUrlPath, e); | ||
|
||
| } catch (IOException e) { | ||
| log.error("Failed to resolve logo path: {}", logoUrlPath, e); | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| private boolean isPathWithinAppDataDirectory(Path path, Path appDataPath) { | ||
| return path.startsWith(appDataPath); | ||
| } | ||
|
|
||
| private Map<String, String> createConfigKeyMap() { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,23 +12,78 @@ | |
| import org.junit.jupiter.api.Assertions; | ||
| import org.junit.jupiter.api.BeforeEach; | ||
| import org.junit.jupiter.api.Test; | ||
| import org.junit.jupiter.api.AfterEach; | ||
| import org.openmrs.Patient; | ||
| import org.openmrs.api.APIException; | ||
| import org.openmrs.module.patientdocuments.reports.PatientIdStickerPdfReport; | ||
| import org.openmrs.test.jupiter.BaseModuleContextSensitiveTest; | ||
| import org.springframework.beans.factory.annotation.Autowired; | ||
| import org.openmrs.module.reporting.report.manager.ReportManagerUtil; | ||
| import org.openmrs.module.patientdocuments.reports.PatientIdStickerReportManager; | ||
|
|
||
| import java.io.File; | ||
| import java.lang.reflect.Method; | ||
| import java.lang.reflect.InvocationTargetException; | ||
| import java.nio.file.Files; | ||
| import java.nio.file.Path; | ||
| import java.nio.file.Paths; | ||
|
|
||
| public class PatientIdStickerXmlReportRendererTest extends BaseModuleContextSensitiveTest { | ||
|
|
||
| @Autowired | ||
| private PatientIdStickerPdfReport pdfReport; | ||
|
|
||
| private Path temporaryApplicationDataDirectory; | ||
|
|
||
| private String originalApplicationDataDirectoryProperty; | ||
|
|
||
| @BeforeEach | ||
| public void setup() throws Exception { | ||
| executeDataSet("org/openmrs/module/patientdocuments/include/patientIdStickerManagerTestDataset.xml"); | ||
| ReportManagerUtil.setupReport(new PatientIdStickerReportManager()); | ||
| } | ||
|
|
||
| @BeforeEach | ||
| public void setupApplicationDataDirectory() throws Exception { | ||
|
||
| originalApplicationDataDirectoryProperty = System.getProperty("OPENMRS_APPLICATION_DATA_DIRECTORY"); | ||
| temporaryApplicationDataDirectory = Files.createTempDirectory("openmrs-appdata-"); | ||
| System.setProperty("OPENMRS_APPLICATION_DATA_DIRECTORY", temporaryApplicationDataDirectory.toFile().getAbsolutePath()); | ||
| } | ||
|
|
||
| @AfterEach | ||
| public void tearDownApplicationDataDirectory() throws Exception { | ||
| if (originalApplicationDataDirectoryProperty != null) { | ||
| System.setProperty("OPENMRS_APPLICATION_DATA_DIRECTORY", originalApplicationDataDirectoryProperty); | ||
| } else { | ||
| System.clearProperty("OPENMRS_APPLICATION_DATA_DIRECTORY"); | ||
| } | ||
| if (temporaryApplicationDataDirectory != null) { | ||
| try { | ||
| Files.walk(temporaryApplicationDataDirectory) | ||
| .sorted((pathA, pathB) -> pathB.compareTo(pathA)) | ||
| .forEach(pathToDelete -> { | ||
| try { | ||
| Files.deleteIfExists(pathToDelete); | ||
| } catch (Exception ignored) { } | ||
| }); | ||
| } catch (Exception ignored) { } | ||
| } | ||
| } | ||
|
|
||
| private File invokeResolveSecureLogoPath(String logoPath) throws Exception { | ||
| PatientIdStickerXmlReportRenderer renderer = new PatientIdStickerXmlReportRenderer(); | ||
| Method resolveMethod = PatientIdStickerXmlReportRenderer.class.getDeclaredMethod("resolveSecureLogoPath", String.class); | ||
|
||
| resolveMethod.setAccessible(true); | ||
| try { | ||
| return (File) resolveMethod.invoke(renderer, logoPath); | ||
| } catch (InvocationTargetException invocationException) { | ||
| Throwable actualCause = invocationException.getCause(); | ||
| if (actualCause instanceof APIException) { | ||
| throw (APIException) actualCause; | ||
| } | ||
| throw invocationException; | ||
| } | ||
| } | ||
|
|
||
| @Test | ||
| public void generatePdf_shouldThrowWhenPatientIsMissing() throws Exception { | ||
|
|
@@ -38,4 +93,53 @@ public void generatePdf_shouldThrowWhenPatientIsMissing() throws Exception { | |
| }); | ||
| } | ||
|
|
||
| @Test | ||
| public void resolveSecureLogoPath_shouldAllowAbsolutePathWithinAppDataDir() throws Exception { | ||
| Path logoPathInsideAppData = temporaryApplicationDataDirectory.resolve("logos").resolve("logo.png"); | ||
| Files.createDirectories(logoPathInsideAppData.getParent()); | ||
| Files.createFile(logoPathInsideAppData); | ||
|
|
||
| File resolvedLogoFile = invokeResolveSecureLogoPath(logoPathInsideAppData.toFile().getAbsolutePath()); | ||
| Assertions.assertEquals(logoPathInsideAppData.toFile().getCanonicalPath(), resolvedLogoFile.getCanonicalPath()); | ||
| } | ||
|
|
||
| @Test | ||
| public void resolveSecureLogoPath_shouldRejectAbsolutePathOutsideAppDataDir() { | ||
| Assertions.assertThrows(APIException.class, () -> { | ||
| Path externalTempDirectory = Files.createTempDirectory("outside-appdata-"); | ||
| try { | ||
| Path logoPathOutsideAppData = externalTempDirectory.resolve("logo.png"); | ||
| Files.createFile(logoPathOutsideAppData); | ||
| invokeResolveSecureLogoPath(logoPathOutsideAppData.toFile().getAbsolutePath()); | ||
| } finally { | ||
| try { | ||
| Files.walk(externalTempDirectory) | ||
| .sorted((pathA, pathB) -> pathB.compareTo(pathA)) | ||
| .forEach(pathToDelete -> { | ||
| try { | ||
| Files.deleteIfExists(pathToDelete); | ||
| } catch (Exception ignored) { } | ||
| }); | ||
| } catch (Exception ignored) { } | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| @Test | ||
| public void resolveSecureLogoPath_shouldResolveRelativePathWithinAppDataDir() throws Exception { | ||
| String relativeLogoPath = "images/logo.png"; | ||
| Path expectedLogoPath = temporaryApplicationDataDirectory.resolve(Paths.get(relativeLogoPath)); | ||
| Files.createDirectories(expectedLogoPath.getParent()); | ||
| Files.createFile(expectedLogoPath); | ||
|
|
||
| File resolvedLogoFile = invokeResolveSecureLogoPath(relativeLogoPath); | ||
| Assertions.assertEquals(expectedLogoPath.toFile().getCanonicalPath(), resolvedLogoFile.getCanonicalPath()); | ||
| } | ||
|
|
||
| @Test | ||
| public void resolveSecureLogoPath_shouldRejectRelativePathTraversal() { | ||
| Assertions.assertThrows(APIException.class, () -> { | ||
| invokeResolveSecureLogoPath("../logo.png"); | ||
| }); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -164,9 +164,9 @@ The optional header section can contain: | |
| - An organizational logo on the left (from a file path under `OPENMRS_APPLICATION_DATA_DIRECTORY`) | ||
| - Custom header text on the right | ||
| - Logo handling behavior: | ||
| - Relative paths are resolved under `OPENMRS_APPLICATION_DATA_DIRECTORY` | ||
| - Logo path is resolved relative to `OPENMRS_APPLICATION_DATA_DIRECTORY`. | ||
| - Absolute paths are rejected; path traversal sequences are blocked; non-regular files are ignored. | ||
|
||
| - If none configured or missing, the default OpenMRS logo is loaded from the servlet context. | ||
| - Supported formats: absolute filesystem path (custom logo) or base64 data URI (default logo). The XSL-FO processor can handle both. | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The only image format we support is a PNG; that seems important to document. |
||
|
|
||
| ### Internationalization Section | ||
|
|
||
|
|
@@ -208,7 +208,7 @@ The stylesheet includes several responsive design elements: | |
| - **Demographic Grouping**: In MSF layout, groups Gender, DOB, and Age fields in a single row | ||
| - **Secondary Identifier**: Special handling for secondary patient identifiers | ||
| - **Internationalization**: Support for translated field labels and messages | ||
| - **Logo Handling**: Pulled from the `OPENMRS_APPLICATION_DATA_DIRECTORY` or servlet context for the default OpenMRS logo. | ||
| - **Logo Handling**: Pulled from `OPENMRS_APPLICATION_DATA_DIRECTORY` via a relative path, or from the servlet context for the default OpenMRS logo. | ||
jnsereko marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| ## Technical Requirements | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this code support this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes @ibacher. The code supports the above comments as the logic added with #3. It just changed as i used LLM to improve documentation for me.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What I mean is we encode the data as a base64 data URL, but that's not something that the caller can supply in
logoUrlPath, right? The point of Javadoc comments is to explain to the caller how to use a method.Here our logo resolution algortihm now looks like:
The caller can supply a
byte[]representing a default document, but not a base64 data URI.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm also wondering now why we're passing around this
byte[]for the default logo? It's passed as an argument here, in the caller (configureHeader()) and it's caller (render()). The issue here is that images are relatively large (a little over 8kB in this case) and arguments to functions are persisted on the Java stack, so we're swallowing ~25kB of memory to pass around data that might be used. It makes more sense, I think, forconfigureLogo()to load the default logo from the classpath if thelogoUrlPathdoesn't resolve properly.