Skip to content

Commit cb125ef

Browse files
committed
refactor: sanitize dynamic labels and error messages using htmltools::htmlEscape to prevent HTML injection and rendering issues
1 parent eb98645 commit cb125ef

13 files changed

Lines changed: 216 additions & 116 deletions

.claude/scheduled_tasks.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"sessionId":"0e06490a-d52b-46bc-8fc8-5fdda66e78b5","pid":73129,"procStart":"Wed Jun 3 08:10:59 2026","acquiredAt":1780479487954}

R/qualityoflife.b.R

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,18 @@ qualityoflifeClass <- R6::R6Class(
4747
},
4848

4949
.run = function() {
50+
51+
# Reset cached state so the L65-67 early-return (no items selected)
52+
# doesn't leave stale Tables from a previous valid run.
53+
private$.domain_data <- NULL
54+
private$.domain_scores <- NULL
55+
private$.summary_scores <- NULL
56+
private$.health_utilities <- NULL
57+
private$.quality_results <- NULL
58+
private$.clinical_interpretation <- NULL
59+
private$.group_results <- NULL
60+
private$.longitudinal_results <- NULL
61+
5062
# Check if we have domain items
5163
domain_items <- c(
5264
self$options$physical_function_items,
@@ -136,11 +148,17 @@ qualityoflifeClass <- R6::R6Class(
136148

137149
for (pkg in required_packages) {
138150
if (!requireNamespace(pkg, quietly = TRUE)) {
139-
tryCatch({
140-
install.packages(pkg, repos = "https://cran.rstudio.com/")
141-
}, error = function(e) {
142-
self$results$qol_overview$setNote("error", paste("Failed to install package:", pkg))
143-
})
151+
# In jamovi the bundle declares its dependencies via
152+
# DESCRIPTION, so a missing package here means the user's
153+
# R installation is incomplete OR they invoked this code
154+
# outside jamovi. Surface a structured error instead of
155+
# trying to install.packages() from the analysis worker
156+
# (which would silently fail / hang). Mirror of the
157+
# patientreported fix earlier this session.
158+
jmvcore::reject(
159+
"The '{}' package is required but not installed. Please install it from R: install.packages('{}')",
160+
pkg, pkg
161+
)
144162
}
145163
}
146164
},

R/raftgee.b.R

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -213,11 +213,18 @@ raftgeeClass <- if (requireNamespace('jmvcore', quietly=TRUE)) R6::R6Class(
213213
tolerance <- self$options$convergence_tolerance
214214
robustVar <- self$options$robust_variance
215215

216-
# Build formula
216+
# Build formula — Defense 1: composeTerms backtick-escapes user
217+
# column names safely. Defense 2: .asSurvivalFormula (project
218+
# wrapper around jmvcore::asFormula with survival helpers pre-
219+
# allow-listed) validates against jamovi 2.7.27+'s hardened
220+
# as.formula and blocks C1 RCE via crafted column names.
217221
if (is.null(covariates) || length(covariates) == 0) {
218-
formula <- as.formula("Surv(time, status) ~ 1")
222+
formula <- .asSurvivalFormula("Surv(time, status) ~ 1")
219223
} else {
220-
formula <- as.formula(paste("Surv(time, status) ~", paste(covariates, collapse = " + ")))
224+
formula <- .asSurvivalFormula(paste0(
225+
"Surv(time, status) ~ ",
226+
jmvcore::composeTerms(as.list(covariates))
227+
))
221228
}
222229

223230
# Map rank method
@@ -261,7 +268,11 @@ raftgeeClass <- if (requireNamespace('jmvcore', quietly=TRUE)) R6::R6Class(
261268
return(list(model = aftModel, cox_model = coxModel, error = NULL))
262269

263270
}, error = function(e) {
264-
error_msg <- paste("AFT-GEE model fitting failed:", e$message)
271+
# Escape e$message — it can echo user column names from
272+
# underlying aftgee/coxph error messages. Defense-in-depth
273+
# since setNote renderer HTML behavior is jamovi-version-dep.
274+
error_msg <- paste("AFT-GEE model fitting failed:",
275+
htmltools::htmlEscape(conditionMessage(e)))
265276
return(list(error = error_msg))
266277
})
267278
},

R/raincloud.b.R

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ raincloudClass <- if (requireNamespace("jmvcore")) R6::R6Class("raincloudClass",
108108

109109
# Validate dataset
110110
if (nrow(self$data) == 0) {
111-
stop(.("Error: The provided dataset contains no complete rows. Please check your data and try again."))
111+
jmvcore::reject(.("Error: The provided dataset contains no complete rows. Please check your data and try again."))
112112
}
113113

114114
# Safely require ggdist
@@ -143,7 +143,7 @@ raincloudClass <- if (requireNamespace("jmvcore")) R6::R6Class("raincloudClass",
143143
analysis_data <- analysis_data[complete.cases(analysis_data), ]
144144

145145
if (nrow(analysis_data) == 0) {
146-
stop(.("Error: No complete cases found for the selected variables."))
146+
jmvcore::reject(.("Error: No complete cases found for the selected variables."))
147147
}
148148

149149
# Convert variables to appropriate types
@@ -166,15 +166,15 @@ raincloudClass <- if (requireNamespace("jmvcore")) R6::R6Class("raincloudClass",
166166
missing_counts <- sapply(required_vars, function(v) sum(!complete.cases(dataset[, required_vars])) + sum(!complete.cases(dataset[, v, drop=FALSE])))
167167
missing_msg <- paste0(
168168
"<ul style='margin:6px 0;'>",
169-
paste0("<li>", required_vars, ": ", missing_counts, " missing</li>", collapse = ""),
169+
paste0("<li>", htmltools::htmlEscape(required_vars), ": ", missing_counts, " missing</li>", collapse = ""),
170170
"</ul>"
171171
)
172172
summary_msg <- paste0(
173173
"<div style='background-color:#f8f9fa;padding:12px;border-radius:8px;margin-bottom:12px;'>",
174174
"<strong>Data summary:</strong> ", nrow(analysis_data), " complete rows (removed ",
175175
nrow(dataset) - nrow(analysis_data), " with missing values). ",
176176
length(group_counts), " groups: ",
177-
paste(paste0(names(group_counts), " (n=", group_counts, ")"), collapse = ", "),
177+
paste(paste0(htmltools::htmlEscape(names(group_counts)), " (n=", group_counts, ")"), collapse = ", "),
178178
".", imbalance_note,
179179
"<br><strong>Missing by variable:</strong>", missing_msg,
180180
if (min_group < 10) " <span style='color:#d9534f'>(some groups have n < 10; avoid inferential tests)</span>" else "",
@@ -555,7 +555,7 @@ raincloudClass <- if (requireNamespace("jmvcore")) R6::R6Class("raincloudClass",
555555
stats_html <- paste0(
556556
stats_html,
557557
"<tr style='background-color: ", row_bg, ";'>",
558-
"<td><strong>", stats_summary[[group_var]][i], "</strong></td>",
558+
"<td><strong>", htmltools::htmlEscape(as.character(stats_summary[[group_var]][i])), "</strong></td>",
559559
"<td>", stats_summary$n[i], "</td>",
560560
"<td>", stats_summary$mean[i], "</td>",
561561
"<td>", stats_summary$median[i], "</td>",
@@ -617,7 +617,7 @@ raincloudClass <- if (requireNamespace("jmvcore")) R6::R6Class("raincloudClass",
617617
count <- outliers_list[[group]]
618618
total_outliers <- total_outliers + count
619619
outlier_html <- paste0(outlier_html,
620-
"<li><strong>", group, ":</strong> ", count, " outliers detected</li>"
620+
"<li><strong>", htmltools::htmlEscape(group), ":</strong> ", count, " outliers detected</li>"
621621
)
622622
}
623623

@@ -671,7 +671,7 @@ raincloudClass <- if (requireNamespace("jmvcore")) R6::R6Class("raincloudClass",
671671
normality_html <- paste0(
672672
normality_html,
673673
"<tr>",
674-
"<td>", group, "</td>",
674+
"<td>", htmltools::htmlEscape(group), "</td>",
675675
"<td>", w_stat, "</td>",
676676
"<td>", p_val, "</td>",
677677
"<td>", interpretation, "</td>",
@@ -836,8 +836,8 @@ raincloudClass <- if (requireNamespace("jmvcore")) R6::R6Class("raincloudClass",
836836

837837
"<h4 style='color: #2e7d32;'>Plot Summary:</h4>",
838838
"<ul>",
839-
"<li><strong>Variable:</strong> ", dep_var, " (distribution analysis)</li>",
840-
"<li><strong>Groups:</strong> ", n_groups, " groups defined by ", group_var, "</li>",
839+
"<li><strong>Variable:</strong> ", htmltools::htmlEscape(dep_var), " (distribution analysis)</li>",
840+
"<li><strong>Groups:</strong> ", n_groups, " groups defined by ", htmltools::htmlEscape(group_var), "</li>",
841841
"<li><strong>Observations:</strong> ", n_total, " data points</li>",
842842
"<li><strong>Visualization:</strong> ",
843843
paste(c(
@@ -881,10 +881,15 @@ raincloudClass <- if (requireNamespace("jmvcore")) R6::R6Class("raincloudClass",
881881
},
882882

883883
.generate_report_sentence = function(test_method, p_value, groups) {
884-
significance <- if (p_value < 0.001) "highly significant" else
884+
significance <- if (p_value < 0.001) "highly significant" else
885885
if (p_value < 0.01) "very significant" else
886886
if (p_value < 0.05) "significant" else "not significant"
887-
887+
888+
# `groups` is `levels(data[[group_var]])` (user factor labels) at the
889+
# caller — escape once here since they reach HTML <p> via report_text
890+
# below, then concatenated into comparison_html → setContent.
891+
groups <- htmltools::htmlEscape(groups)
892+
888893
report_text <- sprintf(
889894
"The %s test comparing %s showed %s differences (p = %.3f). %s",
890895
test_method,

R/recist.b.R

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,10 @@ recistClass <- R6::R6Class(
104104
},
105105

106106
# ---- Helper: Escape Variable Names ----
107+
# TODO (cleanup): .escapeVar is dead code — defined here but never called anywhere
108+
# in recist.b.R. Remove it. If a future formula ever needs safe variable names, use
109+
# jmvcore::composeTerm()/toB64() rather than make.names() mangling (which can collide
110+
# distinct non-syntactic column names onto the same name).
107111
.escapeVar = function(x) {
108112
# Make syntactically valid R names for variables with spaces/special chars
109113
gsub("[^A-Za-z0-9_]+", "_", make.names(x))
@@ -119,6 +123,12 @@ recistClass <- R6::R6Class(
119123
return()
120124
}
121125

126+
# TODO (data hygiene): private$.summaryStatus$warnings and $exclusions are R6
127+
# fields initialised once at class generation; jamovi reuses the instance across
128+
# .run() calls, and .addWarning()/.selectTargetLesions() only ever append (c(...)).
129+
# They are never reset, so warnings/exclusions accumulate and duplicate across
130+
# successive runs (the scalar counts are overwritten each run, so only these two
131+
# leak). Reset both to character() here at the top of .run() before any append.
122132
tryCatch({
123133
private$.prepareData()
124134
private$.selectTargetLesions()
@@ -167,7 +177,7 @@ recistClass <- R6::R6Class(
167177
self$results$instructions$setContent(
168178
paste0("<div class='error'>",
169179
"<h4>Error in Analysis</h4>",
170-
"<p>", e$message, "</p>",
180+
"<p>", htmltools::htmlEscape(e$message), "</p>",
171181
"<p><b>Common issues:</b></p>",
172182
"<ul>",
173183
"<li>Ensure data is in long format (one row per lesion per assessment)</li>",
@@ -176,7 +186,7 @@ recistClass <- R6::R6Class(
176186
"<li>Ensure each patient has baseline assessment (earliest time)</li>",
177187
"</ul></div>")
178188
)
179-
stop(e)
189+
jmvcore::reject(e$message)
180190
})
181191
},
182192

@@ -185,14 +195,21 @@ recistClass <- R6::R6Class(
185195

186196
mydata <- self$data
187197

198+
# TODO (correctness): composeTerm() backtick-quotes non-syntactic names
199+
# (e.g. "Patient ID" -> `Patient ID`), so mydata[[composeTerm(...)]] returns NULL
200+
# for any column whose name contains spaces/special chars. composeTerm is for
201+
# FORMULA terms, NOT data-frame indexing — index with the raw self$options$X.
202+
# Affects every composeTerm()+mydata[[...]] pair below in .prepareData()
203+
# (patientId, assessmentTime, lesionId, lesionType, lesionDiameter, organ,
204+
# nonTargetStatus) and the groupVar/patientId merge in .populateStratifiedTable().
188205
patientId <- jmvcore::composeTerm(self$options$patientId)
189206
assessmentTime <- jmvcore::composeTerm(self$options$assessmentTime)
190207
lesionId <- jmvcore::composeTerm(self$options$lesionId)
191208
lesionType <- jmvcore::composeTerm(self$options$lesionType)
192209

193210
data <- data.frame(
194211
patientId = mydata[[patientId]],
195-
assessmentTime = as.numeric(mydata[[assessmentTime]]),
212+
assessmentTime = jmvcore::toNumeric(mydata[[assessmentTime]]),
196213
lesionId = as.character(mydata[[lesionId]]),
197214
lesionType = tolower(as.character(mydata[[lesionType]])),
198215
stringsAsFactors = FALSE
@@ -201,7 +218,7 @@ recistClass <- R6::R6Class(
201218
# Add diameter if provided
202219
if (!is.null(self$options$lesionDiameter)) {
203220
lesionDiameter <- jmvcore::composeTerm(self$options$lesionDiameter)
204-
data$diameter <- as.numeric(mydata[[lesionDiameter]])
221+
data$diameter <- jmvcore::toNumeric(mydata[[lesionDiameter]])
205222
} else {
206223
data$diameter <- NA
207224
}
@@ -628,6 +645,10 @@ recistClass <- R6::R6Class(
628645
}
629646
},
630647

648+
# TODO (correctness): for an all-NA patient (no measurable target sum),
649+
# min(..., na.rm=TRUE) emits a warning and returns Inf/-Inf, which then
650+
# renders as Inf in nadirSum / bestPercentChange. Guard with an all-NA
651+
# check (return NA_real_ when all values are NA).
631652
nadirSum = min(targetSum, na.rm=TRUE),
632653
bestPercentChange = min(changeFromBaseline, na.rm=TRUE),
633654

@@ -928,15 +949,15 @@ recistClass <- R6::R6Class(
928949
if (length(status$exclusions) > 0) {
929950
html <- paste0(html, "<h5 style='color: orange;'>Excluded Lesions (Rules)</h5><ul>")
930951
for(m in status$exclusions) {
931-
html <- paste0(html, "<li>", m, "</li>")
952+
html <- paste0(html, "<li>", htmltools::htmlEscape(m), "</li>")
932953
}
933954
html <- paste0(html, "</ul>")
934955
}
935956

936957
if (length(status$warnings) > 0) {
937958
html <- paste0(html, "<h5 style='color: red;'>Warnings</h5><ul>")
938959
for(w in status$warnings) {
939-
html <- paste0(html, "<li>", w, "</li>")
960+
html <- paste0(html, "<li>", htmltools::htmlEscape(w), "</li>")
940961
}
941962
html <- paste0(html, "</ul>")
942963
}
@@ -949,6 +970,11 @@ recistClass <- R6::R6Class(
949970
.exportData = function() {
950971
data <- private$.lesionData
951972
if (!is.null(data) && nrow(data) > 0) {
973+
# TODO (security): writes recist_lesion_data.csv to getwd() as a .run() side
974+
# effect. On multi-tenant cloud jamovi the cwd is shared/process-level and the
975+
# user never chose it — a silent, unconfirmed write. Not RCE/exfiltration
976+
# (fixed filename, user's own data), but route exports through a jamovi
977+
# Output/path option (or tempdir()) instead of getwd().
952978
filepath <- file.path(getwd(), "recist_lesion_data.csv")
953979
tryCatch({
954980
write.csv(data, filepath, row.names = FALSE)
@@ -957,7 +983,7 @@ recistClass <- R6::R6Class(
957983
if (is.null(currentHtml)) currentHtml <- ""
958984
newHtml <- paste0(currentHtml,
959985
"<p style='color: green;'><b> Data Exported:</b> ",
960-
filepath, "</p>")
986+
htmltools::htmlEscape(filepath), "</p>")
961987
self$results$runSummary$setContent(newHtml)
962988
}, error = function(e) {
963989
private$.addWarning(paste0("Failed to export data: ", e$message))

0 commit comments

Comments
 (0)