-
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 22 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,9 @@ | |
| import java.io.File; | ||
| import java.io.IOException; | ||
| import java.io.OutputStream; | ||
| import java.io.InputStream; | ||
| import java.nio.file.Path; | ||
| import java.nio.file.Paths; | ||
| import java.util.Arrays; | ||
| import java.util.Base64; | ||
| import java.util.HashMap; | ||
|
|
@@ -48,12 +51,16 @@ | |
| import org.openmrs.module.reporting.report.renderer.RenderingException; | ||
| import org.openmrs.module.reporting.report.renderer.ReportDesignRenderer; | ||
| import org.openmrs.module.reporting.report.renderer.ReportRenderer; | ||
| import org.openmrs.util.OpenmrsClassLoader; | ||
| 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; | ||
|
|
||
| import com.fasterxml.jackson.databind.ObjectMapper; | ||
| import org.apache.commons.io.IOUtils; | ||
|
|
||
| /** | ||
| * ReportRenderer that renders to a default XML format | ||
|
|
@@ -63,6 +70,10 @@ | |
| @Localized("patientdocuments.patientIdStickerXmlReportRenderer") | ||
| public class PatientIdStickerXmlReportRenderer extends ReportDesignRenderer { | ||
|
|
||
| private static final Logger log = LoggerFactory.getLogger(PatientIdStickerXmlReportRenderer.class); | ||
|
|
||
| private static final String DEFAULT_LOGO_CLASSPATH = "web/module/resources/openmrs_logo_white_large.png"; | ||
|
|
||
| private MessageSourceService mss; | ||
|
|
||
| private InitializerService initializerService; | ||
|
|
@@ -116,10 +127,6 @@ protected String getStringValue(DataSetRow row, DataSetColumn column) { | |
|
|
||
| @Override | ||
| public void render(ReportData results, String argument, OutputStream out) throws IOException, RenderingException { | ||
| render(results, argument, out, null); | ||
| } | ||
|
|
||
| public void render(ReportData results, String argument, OutputStream out, byte[] defaultLogoBytes) throws IOException, RenderingException { | ||
| DocumentBuilderFactory docFactory = DocumentBuilderFactory.newInstance(); | ||
| DocumentBuilder docBuilder; | ||
| try { | ||
|
|
@@ -144,7 +151,7 @@ public void render(ReportData results, String argument, OutputStream out, byte[] | |
| Element templatePIDElement = createStickerTemplate(doc); | ||
|
|
||
| // Handle header configuration | ||
| configureHeader(doc, templatePIDElement, defaultLogoBytes); | ||
| configureHeader(doc, templatePIDElement); | ||
|
|
||
| // Process data set fields | ||
| processDataSetFields(results, doc, templatePIDElement); | ||
|
|
@@ -217,11 +224,11 @@ private Element createStickerTemplate(Document doc) { | |
| return templatePIDElement; | ||
| } | ||
|
|
||
| private void configureHeader(Document doc, Element templatePIDElement, byte[] defaultLogoBytes) { | ||
| private void configureHeader(Document doc, Element templatePIDElement) { | ||
| Element header = doc.createElement("header"); | ||
| // Handle logo if configured | ||
| // Handle logo if configured | ||
| String logoUrlPath = getInitializerService().getValueFromKey("report.patientIdSticker.logourl"); | ||
| configureLogo(doc, header, logoUrlPath, defaultLogoBytes); | ||
| configureLogo(doc, header, logoUrlPath); | ||
|
|
||
| boolean useHeader = Boolean.TRUE.equals(getInitializerService().getBooleanFromKey("report.patientIdSticker.header")); | ||
| if (useHeader) { | ||
|
|
@@ -243,51 +250,131 @@ 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 | ||
| */ | ||
| private void configureLogo(Document doc, Element header, String logoUrlPath, byte[] defaultLogoBytes) { | ||
| String logoPath = ""; | ||
| /** | ||
| * Configures the logo for the sticker document. | ||
| * | ||
| * Loads a custom logo from {@code logoUrlPath} (absolute or relative to the {@code OPENMRS_APPLICATION_DATA_DIRECTORY}. | ||
| * If not found, falls back to the OpenMRS logo from the classpath. | ||
| * | ||
| * @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) | ||
| */ | ||
| private void configureLogo(Document doc, Element header, String logoUrlPath) { | ||
| 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(); | ||
| // 1. Try custom logo | ||
| if (isNotBlank(logoUrlPath)) { | ||
| File logoFile = resolveSecureLogoPath(logoUrlPath); | ||
| if (logoFile != null && logoFile.exists() && logoFile.canRead() && logoFile.isFile()) { | ||
| try { | ||
| byte[] customLogoBytes = OpenmrsUtil.getFileAsBytes(logoFile); | ||
| if (customLogoBytes != null && customLogoBytes.length > 0) { | ||
| String base64Image = Base64.getEncoder().encodeToString(customLogoBytes); | ||
| logoContent = "data:image/png;base64," + base64Image; | ||
| } | ||
| } catch (IOException e) { | ||
| log.error("Failed to load custom logo from file: {}", logoFile.getAbsolutePath(), e); | ||
| } | ||
| } | ||
|
|
||
| // 2. Fall back to default logo | ||
| if (isBlank(logoPath) && defaultLogoBytes != null && defaultLogoBytes.length > 0) { | ||
| } | ||
|
|
||
| if (isBlank(logoContent)) { | ||
| byte[] defaultLogoBytes = loadDefaultLogoFromClasspath(); | ||
| if (defaultLogoBytes != null && defaultLogoBytes.length > 0) { | ||
| String base64Image = Base64.getEncoder().encodeToString(defaultLogoBytes); | ||
| logoPath = "data:image/png;base64," + base64Image; | ||
| logoContent = "data:image/png;base64," + base64Image; | ||
| } | ||
| } catch (Exception e) { | ||
| throw new RenderingException("Failed to configure logo", e); | ||
| } | ||
|
|
||
| // Create and append logo elements if valid | ||
| if (isNotBlank(logoPath)) { | ||
|
|
||
| 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 | ||
| log.error("Failed to configure logo: unresolved path '{}' and no default provided", logoUrlPath); | ||
| } | ||
| } | ||
|
|
||
| private byte[] loadDefaultLogoFromClasspath() { | ||
| try (InputStream logoStream = OpenmrsClassLoader.getInstance().getResourceAsStream(DEFAULT_LOGO_CLASSPATH)) { | ||
| if (logoStream == null) { | ||
| log.warn("Default logo not found on classpath at: {}", DEFAULT_LOGO_CLASSPATH); | ||
| return null; | ||
| } | ||
| return IOUtils.toByteArray(logoStream); | ||
| } | ||
| catch (IOException e) { | ||
| log.error("Failed to load default logo from classpath at: {}", DEFAULT_LOGO_CLASSPATH, e); | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Ensure that the supplied {@code logoUrlPath} refers to a file in the application data directory | ||
| * | ||
| * @param logoUrlPath The user-provided logo path | ||
| * @return A File object pointing to the logo if the path is valid, otherwise {@code null} | ||
| */ | ||
| protected File resolveSecureLogoPath(String logoUrlPath) { | ||
| 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 could not be found"); | ||
| 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)) { | ||
| log.error("Absolute path must be within application data directory: {}", logoUrlPath); | ||
| return null; | ||
| } | ||
| 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)) { | ||
| log.error("Path traversal detected in logo path: {}", logoUrlPath); | ||
| return null; | ||
| } | ||
jnsereko marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| // 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)) { | ||
| log.error("Logo path escapes application data directory: {}", logoUrlPath); | ||
|
||
| return null; | ||
| } | ||
|
|
||
| return resolvedLogoRealPath.toFile(); | ||
| } catch (IllegalArgumentException e) { | ||
| log.error("Invalid logo path: " + logoUrlPath, e); | ||
| return null; | ||
| } catch (IOException e) { | ||
| log.error("Failed to access logo file: {}", logoUrlPath, e); | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| private boolean isPathWithinAppDataDirectory(Path path, Path appDataPath) { | ||
| return path.startsWith(appDataPath); | ||
| } | ||
|
|
||
| private Map<String, String> createConfigKeyMap() { | ||
|
|
||
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.
Are you still loading the logo from the module?
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 thought that we had agreed not to duplicate this logo in the module.
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. We definitely had agreed that but i am out of options.
We had two options
Get OpenMRS logo from servlet context (only be one in controllers)
Using classpath
OpenmrsClassLoader.getInstance().getResourceAsStream()(not working) ❌