From 21d83478022d008973ffbc577b5966c257de1911 Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Wed, 22 Jul 2026 10:18:45 -0700 Subject: [PATCH 1/2] Fix stored XSS in billing notification charge summary report (#1159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Rationale BillingNotification.createChargeSummaryReport built its per-financial-analyst tables by concatenating editor-entered database values (investigator, project, debitedAccount, projectNumber, category) and their derived URLs directly into HTML with no escaping. That HTML is rendered verbatim into the LDK RunNotificationAction admin preview via HtmlString.unsafe and is also sent as the HTML email body, so a stored payload in any of those project/alias fields executed in the browser of any user who previewed the notification or received the email — a stored XSS with privilege-escalation potential toward admins. The adjacent Charge Summary category table already escaped its values with PageFlowUtil.filter; this loop was simply never given the same treatment. ## Related Pull Requests None. ## Changes - Wrap every editor-entered value (investigator, project, account, project number, category) in PageFlowUtil.filter before rendering it into the charge summary tables. - Filter the derived href URLs (project, account, and per-field-descriptor links), which embed those same tainted values. --- .../notification/BillingNotification.java | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/ehr_billing/src/org/labkey/ehr_billing/notification/BillingNotification.java b/ehr_billing/src/org/labkey/ehr_billing/notification/BillingNotification.java index fdde62318..feb5edd73 100644 --- a/ehr_billing/src/org/labkey/ehr_billing/notification/BillingNotification.java +++ b/ehr_billing/src/org/labkey/ehr_billing/notification/BillingNotification.java @@ -464,8 +464,8 @@ protected void createChargeSummaryReport(final StringBuilder msg, Date lastInvoi String baseUrl = createURL(containerMap.get(category), centerSpecificBillingSchema, categoryToQuery.get(category), null) + "&query.param.StartDate=" + getDateFormat(c).format(start.getTime()) + "&query.param.EndDate=" + getDateFormat(c).format(endDate.getTime()); String projUrl = baseUrl + ("None".equals(tokens[1]) ? "&query.project~isblank" : "&query.project~eq=" + tokens[1]); - msg.append("" + financialAnalyst + ""); //the FA - msg.append("" + tokens[1] + ""); + msg.append("" + PageFlowUtil.filter(financialAnalyst) + ""); //the FA + msg.append("" + PageFlowUtil.filter(tokens[1]) + ""); String accountUrl = null; Container financeContainer = EHR_BillingManager.get().getBillingContainer(containerMap.get(category)); @@ -476,22 +476,22 @@ protected void createChargeSummaryReport(final StringBuilder msg, Date lastInvoi if (accountUrl != null) { - msg.append("" + tokens[2] + ""); + msg.append("" + PageFlowUtil.filter(tokens[2]) + ""); } else { - msg.append("" + (tokens[2]) + ""); + msg.append("" + PageFlowUtil.filter(tokens[2]) + ""); } - msg.append("" + (tokens[3]) + ""); - msg.append("" + category + ""); + msg.append("" + PageFlowUtil.filter(tokens[3]) + ""); + msg.append("" + PageFlowUtil.filter(category) + ""); for (FieldDescriptor fd : foundCols) { if (totals.containsKey(fd.getFieldName())) { String url = projUrl + fd.getFilter(); - msg.append("" + totals.get(fd.getFieldName()) + ""); + msg.append("" + totals.get(fd.getFieldName()) + ""); } else { From 03c87a593bf53741738f14b15ff847ce514daab4 Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Thu, 23 Jul 2026 12:51:28 -0700 Subject: [PATCH 2/2] Only wrap ehr_lookups tables whose pseudo-PK differs from the real PK (#1168) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Rationale This PR fixes ehr_lookups tables whose user-facing key column was hidden and non-insertable in the UI (`clinpath_status`, `project_types`, `full_snomed`, `flag_values`). The generic fallback in `EHRLookupsUserSchema.createTable()` wrapped any hard table with a single non-rowid key field and a container column in a `ContainerScopedTable`, but for tables whose user-facing key is the true DB PK the wrapper is unnecessary — the PK constraint already enforces uniqueness — and its init demoted the key column while the insert path still required a value for it. ## Related Pull Requests None. ## Changes - Updated the fallback wrapping heuristic to compare the promoted key field against the real PK from JDBC metadata, so only tables where they differ get `ContainerScopedTable`; tables whose key is the true PK now get `CustomPermissionsTable`. - Tables with composite or missing real PKs are no longer container-scoped, since `ContainerScopedTable` only supports a single-column real PK. --- .../labkey/ehr/query/EHRLookupsUserSchema.java | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/ehr/src/org/labkey/ehr/query/EHRLookupsUserSchema.java b/ehr/src/org/labkey/ehr/query/EHRLookupsUserSchema.java index 415ec8c1f..1ee147f51 100644 --- a/ehr/src/org/labkey/ehr/query/EHRLookupsUserSchema.java +++ b/ehr/src/org/labkey/ehr/query/EHRLookupsUserSchema.java @@ -212,10 +212,20 @@ else if (EHRSchema.TABLE_LOOKUP_SETS.equalsIgnoreCase(name)) // By default, any hard tables in the ehr_lookups schema not accounted for above will fall into one of the // two categories below. Both of these will add a check that makes sure the user has EHRDataAdminPermission - // in order to insert/update/delete on the table. The ContainerScopedTable case is for those tables that - // have a true DB PK or rowid but a User pseudoPK that should be accounted for at the container level. + // in order to insert/update/delete on the table. The ContainerScopedTable case is for tables whose + // user-facing key (promoted via isKeyField in ehr_lookups.xml) differs from the true DB PK: the PK + // constraint does not enforce uniqueness of the pseudo-PK, so the wrapper enforces it per container and + // resolves the pseudo-PK to the real PK on update. Tables whose user-facing key is the true PK + // (e.g. project_types) get CustomPermissionsTable instead: the PK constraint already enforces uniqueness, + // and container-scoping such a table made its key column non-insertable in the UI. String pkColName = getPkColName(ti); - if (pkColName != null && !"rowid".equalsIgnoreCase(pkColName) && ti.getColumn("container") != null) + List realPk = ti instanceof FilteredTable ft + ? ft.getRealTable().getPkColumnNames() + : _dbSchema.getTable(name).getPkColumnNames(); + boolean singleColumnPks = pkColName != null && realPk.size() == 1; + boolean realPkMatchesPseudoPk = singleColumnPks && realPk.get(0).equalsIgnoreCase(pkColName); + + if (singleColumnPks && !realPkMatchesPseudoPk && ti.getColumn("container") != null) return getContainerScopedTable(name, cf, pkColName, EHRDataAdminPermission.class); else return getCustomPermissionTable(createSourceTable(name), cf, EHRDataAdminPermission.class);