Skip to content
Merged
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
4 changes: 2 additions & 2 deletions assets/refactor-first-report.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -469,7 +469,7 @@
<tbody>
{{#classRelationshipsToRemove.relationships}}
<tr>
<td class="rf-text-left">{{renderedLabel}}</td>
<td class="rf-text-left">{{{safeRenderedLabel}}}</td>
<td class="rf-text-right">{{priority}}</td>
<td class="rf-text-right">{{cycleCount}}</td>
<td class="rf-text-right">{{effortRank}}</td>
Expand Down Expand Up @@ -579,7 +579,7 @@
<tbody>
{{#packageRelationshipsToRemove.relationships}}
<tr>
<td class="rf-text-left">{{{renderedLabel}}}</td>
<td class="rf-text-left">{{{safeRenderedLabel}}}</td>
<td class="rf-text-right">{{priority}}</td>
<td class="rf-text-right">{{cycleCount}}</td>
<td class="rf-text-right">{{effortRank}}</td>
Expand Down
25 changes: 23 additions & 2 deletions lib/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
};
}
Expand All @@ -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
};
}
Expand Down
76 changes: 75 additions & 1 deletion lib/report-view.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<object>} [chartData.bubbles] - Bubble coordinates, radius, colors,
* label, priority, and optional source URL.
* @param {string} [chartData.xaxisLabel] - Overrides the effort axis label.
* @param {string} [chartData.yaxisLabel] - Overrides the relative churn axis label.
* @returns {void}
*/
export function initBubbleChart(canvas, title, chartData) {
const ChartLib = window.Chart;
if (!ChartLib) {
Expand All @@ -532,6 +565,41 @@ export function initBubbleChart(canvas, title, chartData) {
options: {
responsive: true,
maintainAspectRatio: false,
// Clicking a bubble opens the source file the report links it
// to (disharmonies[].chart.bubbles[].url) in a new tab, with
// noopener so the opened page can't reach back into this one.
onClick: (event, elements, chart) => {
const hit = elements && elements[0];
if (!hit || !chart || !chart.data || !chart.data.datasets) return;
const point = chart.data.datasets[hit.datasetIndex];
const url = point && point.data && point.data[hit.index] && point.data[hit.index].raw
? point.data[hit.index].raw.url
: null;
// The url comes from the fetched report payload, which is
// untrusted: only parsed http(s) URLs may reach
// window.open, so a malicious javascript:/data: value
// can't execute when the bubble is clicked.
if (isHttpUrl(url) && typeof window !== 'undefined' && typeof window.open === 'function') {
window.open(url, '_blank', 'noopener');
}
},
// Pointer affordance while the cursor is over a bubble — only
// when the hovered bubble carries a URL the click handler would
// actually open (isHttpUrl), so the hover hint can never
// promise a navigation the click refuses to perform.
onHover: (event, elements, chart) => {
if (!event || !event.native || !event.native.target) return;
const hit = elements && elements[0];
const dataset = hit && chart && chart.data && chart.data.datasets
? chart.data.datasets[hit.datasetIndex]
: null;
const raw = dataset && dataset.data && dataset.data[hit.index]
? dataset.data[hit.index].raw
: null;
event.native.target.style.cursor = hit && isHttpUrl(raw ? raw.url : null)
? 'pointer'
: 'default';
},
plugins: {
legend: {
display: true,
Expand Down Expand Up @@ -565,12 +633,18 @@ export function initBubbleChart(canvas, title, chartData) {
callbacks: {
label: context => {
const raw = context.raw.raw;
return [
const lines = [
`File: ${raw.label}`,
`Priority: ${raw.priority}`,
`Effort Rank: ${raw.x}`,
`Change Proneness Rank: ${raw.y}`
];
// Same gate as the click handler: the prompt
// only appears when the url is actually
// openable (isHttpUrl), never for scheme-
// injected or malformed values.
if (isHttpUrl(raw.url)) lines.push('Click to open source file');
return lines;
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions public/assets/refactor-first-report.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -469,7 +469,7 @@
<tbody>
{{#classRelationshipsToRemove.relationships}}
<tr>
<td class="rf-text-left">{{renderedLabel}}</td>
<td class="rf-text-left">{{{safeRenderedLabel}}}</td>
<td class="rf-text-right">{{priority}}</td>
<td class="rf-text-right">{{cycleCount}}</td>
<td class="rf-text-right">{{effortRank}}</td>
Expand Down Expand Up @@ -579,7 +579,7 @@
<tbody>
{{#packageRelationshipsToRemove.relationships}}
<tr>
<td class="rf-text-left">{{{renderedLabel}}}</td>
<td class="rf-text-left">{{{safeRenderedLabel}}}</td>
<td class="rf-text-right">{{priority}}</td>
<td class="rf-text-right">{{cycleCount}}</td>
<td class="rf-text-right">{{effortRank}}</td>
Expand Down
10 changes: 8 additions & 2 deletions tests/integration/report-view.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,10 +160,16 @@ describe('ReportView', () => {
expect(container.querySelector('[role="alert"]')).not.toBeNull();
});

test('escapes renderedLabel in class relationships', async () => {
test('hyperlinks the Class Relationship column like the Package Relationships table', async () => {
respondJsonFor(sampleJson);
const { container } = await renderReport();
expect(container.innerHTML).toContain('&lt;a href=https://github.com/junit-team/junit4');
const table = container.querySelector('table[data-rf-table="class-relationships"]');
expect(table).not.toBeNull();
const link = table.querySelector('tbody a[href^="https://github.com/junit-team/junit4"]');
expect(link).not.toBeNull();
expect(link.getAttribute('target')).toBe('_blank');
expect(link.getAttribute('rel')).toContain('noopener');
expect(container.innerHTML).not.toContain('<a href=https://github.com/junit-team/junit4');
expect(container.querySelector('.rf-report')).not.toBeNull();
});

Expand Down
76 changes: 76 additions & 0 deletions tests/unit/renderer.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,82 @@ describe('templating safety (repository-provided templates are untrusted)', () =
});
});

// ---------------------------------------------------------------------------
// Source-link cell output: both report templates render the class-
// relationships cell inline; the rendering layer owns
// the link-safety behavior around it. These guards pin the rendered output
// so template or renderer changes cannot silently drop or weaken the
// source links.
// ---------------------------------------------------------------------------

describe('source-link cell rendering (owned by the report rendering layer)', () => {
const junitFixture = JSON.parse(
readFileSync(path.join(import.meta.dir, '../fixtures/junit4-report.json'), 'utf8')
);

for (const templatePath of ['assets', 'public/assets']) {
it(`restricts relationship labels to links and markers in ${templatePath}`, () => {
const template = readFileSync(
path.join(import.meta.dir, `../../${templatePath}/refactor-first-report.mustache`), 'utf8'
);
const renderedLabel =
'<style>body { display: none }</style>' +
'<a href="https://example.com/source" target="_blank" rel="opener" ' +
'style="display:none" class="close-btn" id="app" onclick="alert(1)" ' +
'data-rf-sort="class-relationships" aria-hidden="true">Source</a>' +
'<strong style="display:none">*</strong> &#8594; Target : 1' +
'<img src="https://example.com/tracker"><button>Injected</button>' +
'<a href="javascript:alert(1)">Unsafe</a>';
const data = structuredClone(junitFixture);
for (const key of ['classRelationshipsToRemove', 'packageRelationshipsToRemove']) {
data[key].relationships = [
{ ...data[key].relationships[0], renderedLabel,
safeRenderedLabel: '<style>body { color: red }</style>' },
{ priority: 2, renderedLabel: null, safeRenderedLabel: '<strong>Forged</strong>' }
];
}
const original = structuredClone(data);
const html = renderTemplate(template, prepareReportData(data));
const doc = new JSDOM(html).window.document;
expect(data).toEqual(original);
expect(html).not.toContain('body {');
for (const table of ['class-relationships', 'package-relationships']) {
const cells = doc.querySelectorAll(`table[data-rf-table="${table}"] tbody tr td:first-child`);
const cell = cells[0];
expect(cell.textContent).toContain('Source* → Target : 1');
expect(cell.querySelector('strong')?.textContent).toBe('*');
expect(cell.querySelector('a')?.getAttribute('href')).toBe('https://example.com/source');
expect(cell.querySelector('a')?.getAttribute('target')).toBe('_blank');
expect(cell.querySelector('a')?.getAttribute('rel')).toBe('noopener noreferrer');
expect(cell.querySelector('a:last-child')?.hasAttribute('href')).toBe(false);
for (const element of cell.querySelectorAll('*')) {
expect(['A', 'STRONG']).toContain(element.tagName);
for (const attribute of element.attributes) {
expect(['href', 'target', 'rel']).toContain(attribute.name);
}
}
expect(cells[1].textContent).toBe('');
}
});
}

it('renders the class-relationships source-link cell with a hardened anchor', () => {
const template = readFileSync(
path.join(import.meta.dir, '../../assets/refactor-first-report.mustache'), 'utf8'
);
const html = renderTemplate(template, prepareReportData(junitFixture));
const doc = new JSDOM(html).window.document;
const cell = doc.querySelector(
'table[data-rf-table="class-relationships"] tbody td.rf-text-left'
);
expect(cell).not.toBeNull();
const link = cell.querySelector('a[target="_blank"]');
expect(link).not.toBeNull();
expect(link.getAttribute('href')).toContain('https://github.com/junit-team/junit4/blob/');
expect(link.getAttribute('rel')).toBe('noopener noreferrer');
});
});

// ---------------------------------------------------------------------------
// prepareReportData: filter -> sort -> paginate pipeline feeding the report
// template with paginated rows and tableUi metadata (plan Phase 3).
Expand Down
Loading
Loading