diff --git a/assets/refactor-first-report.mustache b/assets/refactor-first-report.mustache
index d30f4b8..0b9c568 100644
--- a/assets/refactor-first-report.mustache
+++ b/assets/refactor-first-report.mustache
@@ -469,7 +469,7 @@
{{#classRelationshipsToRemove.relationships}}
- | {{renderedLabel}} |
+ {{{safeRenderedLabel}}} |
{{priority}} |
{{cycleCount}} |
{{effortRank}} |
@@ -579,7 +579,7 @@
{{#packageRelationshipsToRemove.relationships}}
- | {{{renderedLabel}}} |
+ {{{safeRenderedLabel}}} |
{{priority}} |
{{cycleCount}} |
{{effortRank}} |
diff --git a/lib/renderer.js b/lib/renderer.js
index 60f0ca9..5227ff7 100644
--- a/lib/renderer.js
+++ b/lib/renderer.js
@@ -50,6 +50,27 @@ function registerLinkSafetyHook() {
linkSafetyHookRegistered = true;
}
+/**
+ * Copies a relationship with label markup limited to source links and markers.
+ * The report-wide sanitizer permits template styles, so labels need their own
+ * allow-list before interpolation. Never trust a precomputed value from JSON.
+ *
+ * @param {object} relationship - Raw relationship data.
+ * @returns {object} Relationship with a restricted HTML label.
+ */
+function prepareRelationship(relationship) {
+ registerLinkSafetyHook();
+ return {
+ ...relationship,
+ safeRenderedLabel: DOMPurify.sanitize(relationship.renderedLabel ?? '', {
+ ALLOWED_TAGS: ['a', 'strong'],
+ ALLOWED_ATTR: ['href', 'target', 'rel'],
+ ALLOW_DATA_ATTR: false,
+ ALLOW_ARIA_ATTR: false
+ })
+ };
+}
+
// ---------------------------------------------------------------------------
// Enhanced tables: filter -> sort -> paginate pipeline feeding the template
// (plan Phase 3). prepareReportData returns a shallow-copied report object
@@ -170,7 +191,7 @@ export function prepareReportData(data, tableStates = {}, config = TABLE_CONFIG)
descriptor, data.classRelationshipsToRemove.relationships, stateOf(descriptor.id), config);
result.classRelationshipsToRemove = {
...data.classRelationshipsToRemove,
- relationships: rows,
+ relationships: rows.map(prepareRelationship),
tableUi: ui
};
}
@@ -181,7 +202,7 @@ export function prepareReportData(data, tableStates = {}, config = TABLE_CONFIG)
descriptor, data.packageRelationshipsToRemove.relationships, stateOf(descriptor.id), config);
result.packageRelationshipsToRemove = {
...data.packageRelationshipsToRemove,
- relationships: rows,
+ relationships: rows.map(prepareRelationship),
tableUi: ui
};
}
diff --git a/lib/report-view.js b/lib/report-view.js
index 3976aaa..fd13c59 100644
--- a/lib/report-view.js
+++ b/lib/report-view.js
@@ -508,6 +508,39 @@ export function bindThemeChartRedraw() {
}
}
+/**
+ * Validates an untrusted URL from the report payload before it reaches
+ * window.open: only absolute http:/https: URLs pass, so injected schemes
+ * (javascript:, data:, file:, …) and malformed values are rejected.
+ *
+ * @param {*} value - Candidate URL from the fetched report data.
+ * @returns {boolean} True when value parses to an http: or https: URL.
+ */
+export function isHttpUrl(value) {
+ if (typeof value !== 'string') return false;
+ let parsed;
+ try {
+ parsed = new URL(value);
+ } catch {
+ return false;
+ }
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:';
+}
+
+/**
+ * Creates a disharmony bubble chart, replacing any chart on the canvas and
+ * registering it for theme redraws. Clicking a bubble with a URL opens its
+ * source in a new tab with noopener. Warns and returns if Chart.js is unavailable.
+ *
+ * @param {HTMLCanvasElement} canvas - Canvas on which to render the chart.
+ * @param {string} title - Dataset label displayed in the legend.
+ * @param {object} chartData - Report chart data with optional bubbles and axis labels.
+ * @param {Array