Skip to content

(feat) O3-5186: Add security safeguards for logo path configuration#5

Merged
dkayiwa merged 25 commits intoopenmrs:mainfrom
jnsereko:O3-5186
Nov 19, 2025
Merged

(feat) O3-5186: Add security safeguards for logo path configuration#5
dkayiwa merged 25 commits intoopenmrs:mainfrom
jnsereko:O3-5186

Conversation

@jnsereko
Copy link
Contributor

@jnsereko jnsereko commented Nov 5, 2025

Description

This PR implements security protections for the logo path configuration feature to prevent path traversal attacks.
The changes restrict logo file access to only the OpenMRS application data directory, addressing security concerns that were blocking O3 inclusion.

Screenshot

Screenshot 2025-11-05 at 16 01 16 Screenshot 2025-11-05 at 16 04 09 Screenshot 2025-11-05 at 16 57 24

Related issue

https://openmrs.atlassian.net/browse/O3-5186

@jnsereko jnsereko marked this pull request as ready for review November 5, 2025 14:33
@jnsereko jnsereko requested review from dkayiwa and ibacher November 5, 2025 14:34
Copy link
Member

@ibacher ibacher left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @jnsereko. While most of this is nitpicking, it's as well to keep our documentation tightly focused. Parts of this look LLM-generated, which is perfectly fine, but it's always important to be reviewing the LLM's work and making sure it conforms to our standards, that it doesn't over-document just for the specific task that it's working on (so that our documentation remains clean and readable and primarily aimed at the concerns of people calling the API).

Finally, some actual tests would be very valuable here.

*
* Logo resolution priority:
* 1. Custom logo from path resolved under {@code OPENMRS_APPLICATION_DATA_DIRECTORY}
* 2. Default OpenMRS logo as base64 data URI
Copy link
Member

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?

Copy link
Contributor Author

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.

Copy link
Member

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:

We try to resolve the logoUrlPath to a path relative to the Application Data Directory. If this path cannot be resolved to a file, we fallback to using the bytes passed into the defaultLogoBytes parameter as the logo.

The caller can supply a byte[] representing a default document, but not a base64 data URI.

Copy link
Member

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, for configureLogo() to load the default logo from the classpath if the logoUrlPath doesn't resolve properly.

@jnsereko jnsereko requested a review from ibacher November 11, 2025 16:29
@jnsereko
Copy link
Contributor Author

Thank you @ibacher @dkayiwa
I have cleaned LLM generated documentation, removed hard coded regex and added tests.
Kindly review when you get some time.

@denniskigen
Copy link
Member

Ping

if (logoPath.isAbsolute()) {
final Path logoRealPath = logoPath.toRealPath();
if (!isPathWithinAppDataDirectory(logoRealPath, appDataPath)) {
throw new APIException("Absolute path must be within application data directory: " + logoUrlPath);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How can an absolute path be within the application data directory?

Copy link
Contributor Author

@jnsereko jnsereko Nov 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Absolute paths can be within the application data directory eg

  1. Right use

    • App Data Directory: /openmrs/data/
    • User provides: /openmrs/data/logos/company-logo.png
    • This is an absolute path that IS within our app directory ✅
  2. Violation

    • App Data Directory: /openmrs/data/
    • User provides: /etc/passwd
    • This is an absolute path that IS NOT within our app directory ❌

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that just makes you write more code than you should. Just accept only relative paths within the application data directory. Wouldn't you end up with the same functionality but with less code? 😊

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that just makes you write more code than you should. Just accept only relative paths within the application data directory. Wouldn't you end up with the same functionality but with less code? 😊

cc @ibacher


return resolvedLogoRealPath.toFile();
} catch (IllegalArgumentException e) {
throw new APIException("Invalid characters in logo path: " + logoUrlPath, e);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a possibility of the IllegalArgumentException being thrown while the problem is not that of invalid characters?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. Thats very true. I have generalized it to Invalid logo path: as I think having multiple checks might not be an overkill.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if the log path is valid but the problem is something else?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should i change the last catch to catch another error? In this case, null will be returned and the default OpenMRS Logo will be displayed. Something like below.

} catch (Exception e) {
   log.error("Failed to resolve logo path: {}", logoUrlPath, e);
   return null;
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the problem is not with the path itself, then the error message can be misleading. In that case something as generic as i gave above would be better.

Copy link
Contributor Author

@jnsereko jnsereko Nov 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think something like Failed to access logo file: {} on catching IOException should be better.

@jnsereko jnsereko requested a review from dkayiwa November 14, 2025 11:21
}
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 '" + logoUrlPath + "' and no default provided");
Copy link
Member

@dkayiwa dkayiwa Nov 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is having a logo a requirement for printing these documents? In other words, is your plan to abort the printing if the only missing part is the logo?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logically, each field should be optional so i think we should just log errors instead of throwing them and also basing on the fact that we have a fallback logo.


return resolvedLogoRealPath.toFile();
} catch (IllegalArgumentException e) {
throw new APIException("Invalid logo path: " + logoUrlPath, e);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What criteria do you use to determine whether to throw an exception or log an error?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the logo is an option, like many other sticker configurations, i have just logged the caught errors.

@jnsereko jnsereko requested a review from dkayiwa November 14, 2025 14:34
Copy link
Member

@ibacher ibacher left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a bunch of comments here, but the real blocker is that the tests (it's great that we have them!) must pass on CI.

*
* Logo resolution priority:
* 1. Custom logo from path resolved under {@code OPENMRS_APPLICATION_DATA_DIRECTORY}
* 2. Default OpenMRS logo as base64 data URI
Copy link
Member

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:

We try to resolve the logoUrlPath to a path relative to the Application Data Directory. If this path cannot be resolved to a file, we fallback to using the bytes passed into the defaultLogoBytes parameter as the logo.

The caller can supply a byte[] representing a default document, but not a base64 data URI.

*
* Logo resolution priority:
* 1. Custom logo from path resolved under {@code OPENMRS_APPLICATION_DATA_DIRECTORY}
* 2. Default OpenMRS logo as base64 data URI
Copy link
Member

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, for configureLogo() to load the default logo from the classpath if the logoUrlPath doesn't resolve properly.

if (isNotBlank(logoUrlPath)) {
File logoFile = resolveSecureLogoPath(logoUrlPath);
if (logoFile != null && logoFile.exists() && logoFile.canRead() && logoFile.isFile()) {
logoContent = logoFile.getAbsolutePath();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't it be better to load the image here and convert it into a data URI, as we do for the default logo, rather than passing it as a URL and assuming it will be readable when rendered?

@jnsereko jnsereko requested a review from ibacher November 18, 2025 18:31
@jnsereko
Copy link
Contributor Author

Thank you @dkayiwa and @ibacher for looking at this. I have fixed tests, improved documentation and loaded the OpenMRS from the classpath instead of Servlet context.
Kindly re-review.


private static final Logger log = LoggerFactory.getLogger(PatientIdStickerXmlReportRenderer.class);

private static final String DEFAULT_LOGO_CLASSPATH = "web/module/resources/openmrs_logo_white_large.png";
Copy link
Member

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?

Copy link
Member

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.

Copy link
Contributor Author

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.

@ibacher 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, for configureLogo() to load the default logo from the classpath if the logoUrlPath doesn't resolve properly.

We had two options

  • Get OpenMRS logo from servlet context (only be one in controllers)

    • pass OpenMRS logo data from controllers to images (not recommended~ increasing memory in method calls) ❌
    • save as a temporary or permanent file (not recommended ~ duplicate file on disc) ❌
  • Using classpath

    • get OpenMRS logo from legacy UI resources OpenmrsClassLoader.getInstance().getResourceAsStream() (not working) ❌
    • duplicate OpenMRS logo in this module and access it using classpath (only option left) ✅

final Path appDataPath = appDataDir.toPath().toRealPath();
final Path logoPath = Paths.get(logoUrlPath);

// For absolute paths, verify they're within app data directory
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Didn't we agree to remove this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ibacher suggested that we can allow absolute paths so long as they are with in the application data directory.
something like /openmrs/data/custom_logo.png

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm happy to leave this off, but we've had requests for supporting this for similar features where we've tried to lock-down where files are loaded from.

…be null))

Removed null check for application data directory.
@jnsereko jnsereko requested a review from dkayiwa November 19, 2025 09:02
@jnsereko
Copy link
Contributor Author

hey @dkayiwa and @ibacher,
Could we handle logo retrieval performance and optimization in a separate Ticket that i have created here?
That case, we can keep this logic only focused on ensuring that the way we access the custom logo is secure?

@dkayiwa
Copy link
Member

dkayiwa commented Nov 19, 2025

@jnsereko is there a reason why you are still accepting absolute paths for the logo? I thought we agreed to only allow relative paths.

@jnsereko
Copy link
Contributor Author

@jnsereko is there a reason why you are still accepting absolute paths for the logo? I thought we agreed to only allow relative paths.

@ibacher had suggested that we can allow absolute paths as long as they are within the App data directory but let me remove that

@jnsereko
Copy link
Contributor Author

Thank you @dkayiwa
I have rejected absolute paths from the logo configuration.

final Path resolvedLogoRealPath = resolvedLogoPath.toRealPath();

if (!isPathWithinAppDataDirectory(resolvedLogoRealPath, appDataPath)) {
log.error("Logo path escapes application data directory: {}", logoUrlPath);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does escapes mean?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have changed the log to Logo path must be within the application data directory

@jnsereko jnsereko requested a review from dkayiwa November 19, 2025 14:10
@dkayiwa dkayiwa merged commit adcb9b0 into openmrs:main Nov 19, 2025
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants