Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
* CARLOS has no affiliation with OSCAR or McMaster University.
*/


package io.github.carlos_emr.carlos.commn.web;

import java.util.ArrayList;
Expand All @@ -40,7 +39,7 @@

import com.fasterxml.jackson.databind.ObjectMapper;

import org.apache.commons.text.StringEscapeUtils;
import org.owasp.encoder.Encode;
import org.apache.commons.lang3.StringUtils;
import io.github.carlos_emr.carlos.commn.dao.DemographicCustDao;
import io.github.carlos_emr.carlos.commn.dao.DemographicDao;
Expand All @@ -50,6 +49,7 @@
import io.github.carlos_emr.carlos.utility.LoggedInInfo;
import io.github.carlos_emr.carlos.utility.MiscUtils;
import io.github.carlos_emr.carlos.utility.SpringUtils;
import io.github.carlos_emr.carlos.managers.SecurityInfoManager;

import io.github.carlos_emr.OscarProperties;
import io.github.carlos_emr.carlos.prescript.data.RxProviderData;
Expand All @@ -61,109 +61,133 @@
import com.opensymphony.xwork2.ActionSupport;
import org.apache.struts2.ServletActionContext;

@SuppressWarnings("deprecation")
public class SearchDemographicAutoComplete2Action extends ActionSupport {
HttpServletRequest request = ServletActionContext.getRequest();
HttpServletResponse response = ServletActionContext.getResponse();
private static final ObjectMapper objectMapper = new ObjectMapper();
private SecurityInfoManager securityInfoManager = SpringUtils.getBean(SecurityInfoManager.class);

public String execute() throws Exception {
LoggedInInfo loggedInInfo = LoggedInInfo.getLoggedInInfoFromSession(request);

if (!securityInfoManager.hasPrivilege(loggedInInfo, "_demographic", SecurityInfoManager.READ, null)) {
throw new RuntimeException("missing required security object (_demographic)");
}

public String execute() throws Exception {
String providerNo = LoggedInInfo.getLoggedInInfoFromSession(request).getLoggedInProviderNo();
String providerNo = loggedInInfo.getLoggedInProviderNo();

boolean outOfDomain = false;
if (request.getParameter("outofdomain") != null && request.getParameter("outofdomain").equals("true")) {
outOfDomain = true;
}

DemographicDao demographicDao = (DemographicDao) SpringUtils.getBean(DemographicDao.class);
String searchStr = request.getParameter("demographicKeyword");

if (searchStr == null) {
searchStr = request.getParameter("query");
}

if (searchStr == null) {
searchStr = request.getParameter("name");
}

if (searchStr == null) {
searchStr = request.getParameter("term");
}

boolean activeOnly = false;
activeOnly = request.getParameter("activeOnly") != null && request.getParameter("activeOnly").equalsIgnoreCase("true");
boolean jqueryJSON = request.getParameter("jqueryJSON") != null && request.getParameter("jqueryJSON").equalsIgnoreCase("true");
activeOnly = request.getParameter("activeOnly") != null
&& request.getParameter("activeOnly").equalsIgnoreCase("true");
boolean jqueryJSON = request.getParameter("jqueryJSON") != null
&& request.getParameter("jqueryJSON").equalsIgnoreCase("true");
RxProviderData rx = new RxProviderData();


List<Demographic> list = null;

// Handle null or empty search string
if (searchStr == null || searchStr.trim().isEmpty()) {
response.setContentType("text/x-json");
response.getWriter().write("{\"results\":[]}");
return null;
}

if (searchStr.length() == 8 && searchStr.matches("([0-9]*)")) {
list = demographicDao.searchDemographicByDOB(searchStr.substring(0, 4) + "-" + searchStr.substring(4, 6) + "-" + searchStr.substring(6, 8), 100, 0, providerNo, outOfDomain);
list = demographicDao.searchDemographicByDOB(
searchStr.substring(0, 4) + "-" + searchStr.substring(4, 6) + "-" + searchStr.substring(6, 8), 100,
0, providerNo, outOfDomain);
Comment on lines 116 to +119

Choose a reason for hiding this comment

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

Action required

2. searchstr null dereference 📘 Rule violation ⛯ Reliability

searchStr is read from multiple request parameters but can still remain null if none are
  present.
• The code then calls searchStr.length() without a null guard, which can throw a
  NullPointerException and return a 500 error.
• This violates the edge-case handling requirement for null/empty inputs.
Agent Prompt
## Issue description
`searchStr` can be `null` if none of the expected request parameters are provided, but the code calls `searchStr.length()`.

## Issue Context
This is an edge-case bug that can cause NPE/500 responses and violates robust error handling requirements.

## Fix Focus Areas
- src/main/java/io/github/carlos_emr/carlos/commn/web/SearchDemographicAutoComplete2Action.java[76-116]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

} else if (activeOnly) {
OscarProperties props = OscarProperties.getInstance();
String pstatus = props.getProperty("inactive_statuses", "IN, DE, IC, ID, MO, FI");
pstatus = pstatus.replaceAll("'", "").replaceAll("\\s", "");
List<String> stati = Arrays.asList(pstatus.split(","));

list = demographicDao.searchDemographicByNameAndNotStatus(searchStr, stati, 100, 0, providerNo, outOfDomain);
list = demographicDao.searchDemographicByNameAndNotStatus(searchStr, stati, 100, 0, providerNo,
outOfDomain);
if (list.size() == 100) {
MiscUtils.getLogger().warn("More results exists than returned");
}
} else {
list = demographicDao.searchDemographicByName(searchStr, 100, 0, providerNo, outOfDomain);
if (list.size() == 100) {
MiscUtils.getLogger().warn("More results exists than returned");
}
}


List<HashMap<String, String>> secondList = new ArrayList<HashMap<String, String>>();
for (Demographic demo : list) {
HashMap<String, String> h = new HashMap<String, String>();
h.put("fomattedDob", demo.getFormattedDob());
h.put("formattedName", StringEscapeUtils.escapeJava(demo.getFormattedName().replaceAll("\"", "\\\"")));
String encodedDob = Encode.forHtml(demo.getFormattedDob());
h.put("formattedDob", encodedDob);
h.put("fomattedDob", encodedDob); // Legacy key for backward compatibility
h.put("formattedName", Encode.forHtml(demo.getFormattedName()));
h.put("demographicNo", String.valueOf(demo.getDemographicNo()));
h.put("status", demo.getPatientStatus());

h.put("status", Encode.forHtml(demo.getPatientStatus()));

Provider p = rx.getProvider(demo.getProviderNo());
if (demo.getProviderNo() != null) {
h.put("providerNo", demo.getProviderNo());
}
if (p.getSurname() != null && p.getFirstName() != null) {
h.put("providerName", p.getSurname() + ", " + p.getFirstName());
if (p != null && p.getSurname() != null && p.getFirstName() != null) {
h.put("providerName", Encode.forHtml(p.getSurname() + ", " + p.getFirstName()));
}

if (OscarProperties.getInstance().isPropertyActive("workflow_enhance")) {
h.put("nextAppointment", AppointmentUtil.getNextAppointment(demo.getDemographicNo() + ""));
DemographicCustDao demographicCustDao = (DemographicCustDao) SpringUtils.getBean(DemographicCustDao.class);
String nextAppt = AppointmentUtil.getNextAppointment(demo.getDemographicNo() + "");
h.put("nextAppointment", nextAppt != null ? Encode.forHtml(nextAppt) : "");
DemographicCustDao demographicCustDao = (DemographicCustDao) SpringUtils
.getBean(DemographicCustDao.class);
DemographicCust demographicCust = demographicCustDao.find(demo.getDemographicNo());

if (demographicCust != null) {
String cust1 = StringUtils.trimToNull(demographicCust.getNurse());
String cust2 = StringUtils.trimToNull(demographicCust.getResident());
String cust4 = StringUtils.trimToNull(demographicCust.getMidwife());
if (cust1 != null) {
h.put("cust1", cust1);
p = rx.getProvider(cust1);
h.put("cust1Name", p.getSurname() + ", " + p.getFirstName());
if (p != null && p.getSurname() != null && p.getFirstName() != null) {
h.put("cust1Name", Encode.forHtml(p.getSurname() + ", " + p.getFirstName()));
}
}
if (cust2 != null) {
h.put("cust2", cust2);
p = rx.getProvider(cust2);
h.put("cust2Name", p.getSurname() + ", " + p.getFirstName());
if (p != null && p.getSurname() != null && p.getFirstName() != null) {
h.put("cust2Name", Encode.forHtml(p.getSurname() + ", " + p.getFirstName()));
}
}
if (cust4 != null) {
h.put("cust4", cust4);
p = rx.getProvider(cust4);
h.put("cust4Name", p.getSurname() + ", " + p.getFirstName());
if (p != null && p.getSurname() != null && p.getFirstName() != null) {
h.put("cust4Name", Encode.forHtml(p.getSurname() + ", " + p.getFirstName()));
}
}
}

Check notice on line 188 in src/main/java/io/github/carlos_emr/carlos/commn/web/SearchDemographicAutoComplete2Action.java

View check run for this annotation

codefactor.io / CodeFactor

src/main/java/io/github/carlos_emr/carlos/commn/web/SearchDemographicAutoComplete2Action.java#L68-L188

Complex Method
}


secondList.add(h);
}

Expand All @@ -181,24 +205,25 @@

}

private String formatJSON(List<HashMap<String, String>> info) {
StringBuilder json = new StringBuilder("[");

HashMap<String, String> record;
int size = info.size();
for (int idx = 0; idx < size; ++idx) {
record = info.get(idx);
json.append("{\"label\":\"" + record.get("formattedName") + " " + record.get("fomattedDob") + " (" + record.get("status") + ")\",\"value\":\"" + record.get("demographicNo") + "\"");
json.append(",\"providerNo\":\"" + record.get("providerNo") + "\",\"provider\":\"" + record.get("providerName") + "\",\"nextAppt\":\"" + record.get("nextAppointment") + "\",");
json.append("\"formattedName\":\"" + record.get("formattedName") + "\"}");

if (idx < size - 1) {
json.append(",");
}
private String formatJSON(List<HashMap<String, String>> info)
throws com.fasterxml.jackson.core.JsonProcessingException {
List<java.util.LinkedHashMap<String, String>> results = new ArrayList<>();
for (HashMap<String, String> record : info) {
java.util.LinkedHashMap<String, String> node = new java.util.LinkedHashMap<>();
String formattedName = record.get("formattedName") != null ? record.get("formattedName") : "";
String formattedDob = record.get("formattedDob") != null ? record.get("formattedDob") : "";
String status = record.get("status") != null ? record.get("status") : "";
Comment on lines +213 to +215
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

These null checks can be simplified by using Map.getOrDefault(), which is available since Java 8. This would make the code more concise and readable.

Suggested change
String formattedName = record.get("formattedName") != null ? record.get("formattedName") : "";
String formattedDob = record.get("formattedDob") != null ? record.get("formattedDob") : "";
String status = record.get("status") != null ? record.get("status") : "";
String formattedName = record.getOrDefault("formattedName", "");
String formattedDob = record.getOrDefault("formattedDob", "");
String status = record.getOrDefault("status", "");

node.put("label", formattedName + " " + formattedDob + " (" + status + ")");
node.put("value", record.get("demographicNo"));
node.put("providerNo", record.get("providerNo"));
node.put("provider", record.get("providerName"));
node.put("nextAppt", record.get("nextAppointment"));
node.put("formattedName", formattedName);
node.put("formattedDob", formattedDob);
node.put("fomattedDob", formattedDob); // Legacy key for backward compatibility
results.add(node);
}
json.append("]");

return json.toString();
return objectMapper.writeValueAsString(results);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,8 @@
* CARLOS has no affiliation with OSCAR or McMaster University.
*/


package io.github.carlos_emr.carlos.commn.web;

import java.util.Hashtable;
import java.util.List;

import javax.servlet.http.HttpServletRequest;
Expand All @@ -40,8 +38,10 @@
import com.fasterxml.jackson.databind.node.ObjectNode;

import io.github.carlos_emr.carlos.utility.SpringUtils;
import io.github.carlos_emr.carlos.utility.LoggedInInfo;
import io.github.carlos_emr.carlos.managers.SecurityInfoManager;

import io.github.carlos_emr.carlos.providers.data.ProviderData;
import org.owasp.encoder.Encode;
import io.github.carlos_emr.carlos.commn.dao.ProviderDataDao;

/**
Expand All @@ -50,14 +50,22 @@
import com.opensymphony.xwork2.ActionSupport;
import org.apache.struts2.ServletActionContext;

@SuppressWarnings("deprecation")
public class SearchProviderAutoComplete2Action extends ActionSupport {
HttpServletRequest request = ServletActionContext.getRequest();
HttpServletResponse response = ServletActionContext.getResponse();


private static final ObjectMapper objectMapper = new ObjectMapper();
private SecurityInfoManager securityInfoManager = SpringUtils.getBean(SecurityInfoManager.class);
private ProviderDataDao providerDataDao = SpringUtils.getBean(ProviderDataDao.class);

public String execute() throws Exception {
LoggedInInfo loggedInInfo = LoggedInInfo.getLoggedInInfoFromSession(request);

if (!securityInfoManager.hasPrivilege(loggedInInfo, "_provider", SecurityInfoManager.READ, null)) {
throw new RuntimeException("missing required security object (_provider)");
}

if ("labSearch".equals(request.getParameter("method"))) {
return labSearch();
Comment on lines 62 to 70
Copy link
Contributor

@coderabbitai coderabbitai bot Feb 6, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check Struts2 DMI configuration
echo "=== Checking for Struts2 DMI configuration ==="
rg -n 'struts.enable.DynamicMethodInvocation' --type=properties --type=xml --type=conf -C 2

echo -e "\n=== Checking for struts2 XML config files ==="
fd -e "xml" | grep -i struts | head -20

echo -e "\n=== Looking for SearchProviderAutoComplete configuration ==="
rg -n 'SearchProviderAutoComplete' --type=xml -C 5

Repository: carlos-emr/carlos

Length of output: 6541


🏁 Script executed:

#!/bin/bash
# Read the full SearchProviderAutoComplete2Action.java file
echo "=== Full SearchProviderAutoComplete2Action.java content ==="
fd -name "SearchProviderAutoComplete2Action.java" -type f

Repository: carlos-emr/carlos

Length of output: 368


🏁 Script executed:

#!/bin/bash
# Find the file and read it
find . -name "SearchProviderAutoComplete2Action.java" -type f 2>/dev/null | head -1 | xargs wc -l

Repository: carlos-emr/carlos

Length of output: 157


🏁 Script executed:

#!/bin/bash
# Read the full SearchProviderAutoComplete2Action.java file
cat -n ./src/main/java/io/github/carlos_emr/carlos/commn/web/SearchProviderAutoComplete2Action.java

Repository: carlos-emr/carlos

Length of output: 6796


🏁 Script executed:

#!/bin/bash
# Check for DMI configuration in properties files and constants
echo "=== Checking for DMI in struts configuration ==="
rg -n 'DynamicMethodInvocation' --type=java --type=xml
echo ""
echo "=== Checking struts.properties or application.properties ==="
fd "\.properties$" | xargs rg -n 'struts.enable' 2>/dev/null || echo "No struts.enable properties found"
echo ""
echo "=== Checking for default struts configuration ==="
rg -n 'struts\.' src/main/resources/ 2>/dev/null | head -20 || echo "No src/main/resources found"

Repository: carlos-emr/carlos

Length of output: 344


🏁 Script executed:

#!/bin/bash
# Check if there's a struts properties file or Spring XML configuration
find . -name "*.properties" -o -name "*.xml" | grep -E "(struts|spring)" | grep -v "test" | grep -v "TrackingRegexp"

Repository: carlos-emr/carlos

Length of output: 303


Make labSearch() private to prevent direct invocation bypassing the security check.

The labSearch() method is public but lacks the required SecurityInfoManager.hasPrivilege() check. Since it's only called from within execute() (line 70), it should be private to ensure all requests go through the security gate.

-    public String labSearch() throws Exception {
+    private String labSearch() throws Exception {
🤖 Prompt for AI Agents
In
`@src/main/java/io/github/carlos_emr/carlos/commn/web/SearchProviderAutoComplete2Action.java`
around lines 62 - 70, The labSearch method is public and can be invoked
directly, bypassing the hasPrivilege check in execute; change the visibility of
labSearch to private inside the SearchProviderAutoComplete2Action class so it
can only be called from execute(), and verify there are no external callers to
labSearch (update or refactor any callers to use execute() instead if found).

Copy link
Collaborator

Choose a reason for hiding this comment

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

@claude, this still requires authentication checks and the nature of this platform does largely treat authentication as circle of care, but this is something to look at further in general as the code base matures. Does this allow for bypass of authentication check? I don't think so? Please make a issue ticket for this for follow up.

Copy link
Contributor

Choose a reason for hiding this comment

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

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

Choose a reason for hiding this comment

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

Ask

@claude, this still requires authentication checks and the nature of this platform does largely treat authentication as circle of care, but this is something to look at further in general as the code base matures. Does this allow for bypass of authentication check? I don't think so? Please make a issue ticket for this for follow up.

Answer:

Does this allow a bypass of the authentication/authorization check?
Potentially yes, depending on Struts2 configuration—specifically whether Dynamic Method Invocation (DMI) is enabled.

Why it could be bypassable

In SearchProviderAutoComplete2Action, the privilege check was added in execute():

if (!securityInfoManager.hasPrivilege(loggedInInfo, "_provider", SecurityInfoManager.READ, null)) {
    throw new RuntimeException("missing required security object (_provider)");
}

However, labSearch() remains public and contains no privilege check. In Struts2, if DMI is enabled, a request can invoke non-execute() public methods directly via the actionName!methodName pattern (e.g., SearchProviderAutoComplete2Action!labSearch). In that scenario, the request would reach labSearch() without running execute(), and thus without running the _provider privilege gate.

If DMI is disabled (common in hardened setups), then this direct invocation path generally won’t work, and you’re right that it likely does not bypass authz in practice. But because this is security-sensitive and configuration can drift over time (or differ across deployments), it’s still worth addressing as defense-in-depth.

Recommended follow-up (low-risk hardening)

Any one of these would close the potential gap:

  1. Make labSearch() private (or at least protected) if it’s not intended as a Struts action method.
  2. Add the same hasPrivilege check inside labSearch() (belt-and-suspenders).
  3. Confirm/ensure DMI is disabled globally in Struts config, and document it as a security requirement.

Option (1) is simplest and aligns with CodeRabbit’s suggestion, provided there are no external callers (and since Struts uses reflection, “external caller” here includes the framework via DMI).


Follow-up issue ticket to create

Title: security: prevent potential Struts2 DMI bypass for public action methods (labSearch)
Description:

  • SearchProviderAutoComplete2Action enforces _provider READ privilege in execute(), but labSearch() is public and has no privilege check.
  • If Struts2 Dynamic Method Invocation (DMI) is enabled, attackers could invoke !labSearch directly and bypass the execute() authorization gate.
    Acceptance criteria:
  • Make labSearch() non-public or add an explicit privilege check within labSearch().
  • Verify Struts2 DMI setting across deployments (ensure disabled) and document expected config.
  • Add a regression/security test or documentation note to prevent reintroducing public methods without authz checks.

}
Expand All @@ -69,8 +77,41 @@ public String execute() throws Exception {
searchStr = request.getParameter("name");
}

List provList = ProviderData.searchProvider(searchStr, true);
Hashtable d = new Hashtable();
// Handle null or empty search string
if (searchStr == null || searchStr.trim().isEmpty()) {
response.setContentType("text/x-json");
response.getWriter().write("{\"results\":[]}");
return null;
}

// Parse search string for firstName and lastName (replaces deprecated
// ProviderData.searchProvider)
String firstName = null;
String lastName;
if (searchStr.indexOf(",") != -1) {
String[] array = searchStr.split(",", -1);
lastName = array[0].trim();
firstName = array.length > 1 ? array[1].trim() : "";
} else {
lastName = searchStr.trim();
}

// Use the DAO directly with the newer model class
List<io.github.carlos_emr.carlos.commn.model.ProviderData> providers = providerDataDao.findByName(firstName,
lastName, true);

// Convert to the expected map format for JSON response
List<java.util.Map<String, String>> provList = new java.util.ArrayList<>();
for (io.github.carlos_emr.carlos.commn.model.ProviderData p : providers) {
java.util.Map<String, String> result = new java.util.HashMap<>();
result.put("providerNo", p.getId());
result.put("firstName", p.getFirstName() != null ? Encode.forHtml(p.getFirstName()) : "");
result.put("lastName", p.getLastName() != null ? Encode.forHtml(p.getLastName()) : "");
result.put("ohipNo", p.getOhipNo() != null ? Encode.forHtml(p.getOhipNo()) : "");
provList.add(result);
}

java.util.HashMap<String, Object> d = new java.util.HashMap<>();
d.put("results", provList);

response.setContentType("text/x-json");
Expand All @@ -83,12 +124,22 @@ public String execute() throws Exception {
public String labSearch() throws Exception {

String searchStr = request.getParameter("term");

// Handle null or empty search string
if (searchStr == null || searchStr.trim().isEmpty()) {
response.setContentType("text/x-json");
response.getWriter().write("[]");
return null;
}

String firstName, lastName;

if (searchStr.indexOf(",") != -1) {
String[] searchParams = searchStr.split(",", -1);
//note - the -1 is added because split discards a last empty string by default, so "smith,".split(",") returns ["smith"], not ["smith",""].
//adding the -1 causes split to return the 2 element array in this situation, to avoid an index out of bounds error when setting the firstName
// note - the -1 is added because split discards a last empty string by default,
// so "smith,".split(",") returns ["smith"], not ["smith",""].
// adding the -1 causes split to return the 2 element array in this situation,
// to avoid an index out of bounds error when setting the firstName
lastName = searchParams[0].trim();
firstName = searchParams[1].trim();
} else {
Expand All @@ -97,21 +148,21 @@ public String labSearch() throws Exception {
}

ProviderDataDao providerDataDao = SpringUtils.getBean(ProviderDataDao.class);
List<io.github.carlos_emr.carlos.commn.model.ProviderData> provList = providerDataDao.findByName(firstName, lastName, true);
StringBuilder searchResults = new StringBuilder("[");
int idx = 0;
List<io.github.carlos_emr.carlos.commn.model.ProviderData> provList = providerDataDao.findByName(firstName,
lastName, true);
List<java.util.LinkedHashMap<String, String>> searchResults = new java.util.ArrayList<>();

for (io.github.carlos_emr.carlos.commn.model.ProviderData provData : provList) {
searchResults.append("{\"label\":\"" + provData.getLastName() + ", " + provData.getFirstName() + "\",\"value\":\"" + provData.getId() + "\"}");
if (idx < provList.size() - 1) {
searchResults.append(",");
}
++idx;
java.util.LinkedHashMap<String, String> node = new java.util.LinkedHashMap<>();
String provLastName = provData.getLastName() != null ? Encode.forHtml(provData.getLastName()) : "";
String provFirstName = provData.getFirstName() != null ? Encode.forHtml(provData.getFirstName()) : "";
node.put("label", provLastName + ", " + provFirstName);
node.put("value", provData.getId());
searchResults.add(node);
}

searchResults.append("]");
response.setContentType("text/x-json");
response.getWriter().write(searchResults.toString());
response.getWriter().write(objectMapper.writeValueAsString(searchResults));

return null;
}
Expand Down